diff --git a/Cargo.lock b/Cargo.lock index 40c6164b7b908..2a454ed4f3ab9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2320,8 +2320,10 @@ dependencies = [ "moz_task", "nserror", "nsstring", + "plist", "serde", "serde_json", + "tempfile", "thin-vec", "time", "winapi", diff --git a/browser/base/content/test/about/browser_aboutSupport.js b/browser/base/content/test/about/browser_aboutSupport.js index b9bae8fcf65b6..f57d9039efe3f 100644 --- a/browser/base/content/test/about/browser_aboutSupport.js +++ b/browser/base/content/test/about/browser_aboutSupport.js @@ -75,6 +75,117 @@ add_task(async function () { ); }); +add_task( + { skip_if: () => !AppConstants.MOZ_ENTERPRISE }, + async function test_disk_encryption_row() { + // XPCOM registrations are process-local. + const CASES = [ + { + status: "full", + method: "filevault", + text: "Enabled (FileVault)", + }, + { + status: "full", + method: "zfs", + text: "Enabled (ZFS)", + }, + { + status: "enabled", + method: "dm-crypt", + text: "Enabled (dm-crypt); inspection incomplete", + }, + { + status: "partial", + method: "bitlocker", + text: "Partial (BitLocker); some mounted fixed volumes are not encrypted", + }, + { + status: "disabled", + method: "dm-crypt", + text: "Disabled", + }, + { + status: "in-progress", + method: "bitlocker", + text: "Encryption or decryption in progress", + }, + { + // The wrapper normalizes an empty method to null. + status: "unknown", + method: "", + text: "Unknown", + }, + ]; + + await BrowserTestUtils.withNewTab( + { gBrowser, url: "about:support" }, + async browser => { + for (const testCase of CASES) { + const [l10nArgs, hidden] = await SpecialPowers.spawn( + browser, + [testCase], + async expected => { + const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" + ); + const { Troubleshoot } = ChromeUtils.importESModule( + "resource://gre/modules/Troubleshoot.sys.mjs" + ); + + const cid = MockRegistrar.register( + "@mozilla.org/enterprise/disk-encryption-checker;1", + { + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIDiskEncryptionChecker, + ]), + getDiskEncryption(callback) { + callback.onComplete(expected.status, expected.method); + }, + } + ); + + const doc = content.document; + const id = `security-software-disk-encryption-${expected.status}`; + try { + const snapshot = await Troubleshoot.snapshot(); + content.wrappedJSObject.snapshotFormatters.securitySoftware( + Cu.cloneInto(snapshot.securitySoftware, content) + ); + + const cell = doc.getElementById( + "security-software-disk-encryption" + ); + // Wait for Fluent to replace the previous case's text. + await ContentTaskUtils.waitForCondition( + () => + doc.l10n.getAttributes(cell).id === id && + cell.textContent.trim() === expected.text, + `${id} rendered as "${expected.text}", got "${cell.textContent.trim()}"` + ); + return [ + doc.l10n.getAttributes(cell).args, + doc.getElementById("security-software-disk-encryption-row") + .hidden, + ]; + } finally { + MockRegistrar.unregister(cid); + } + } + ); + + Assert.equal( + l10nArgs.method, + testCase.method, + "The method reaches Fluent, empty when there is none" + ); + Assert.ok(!hidden, "The disk encryption row is shown"); + } + } + ); + } +); + add_task(async function test_nimbus_experiments() { await ExperimentAPI.ready(); let doExperimentCleanup = await NimbusTestUtils.enrollWithFeatureConfig({ diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs index 26613b05fb770..c30d858bd3e4d 100644 --- a/browser/components/BrowserGlue.sys.mjs +++ b/browser/components/BrowserGlue.sys.mjs @@ -816,14 +816,16 @@ BrowserGlue.prototype = { let { Troubleshoot } = ChromeUtils.importESModule( "resource://gre/modules/Troubleshoot.sys.mjs" ); - Troubleshoot.snapshot().then(snapshotData => { - // for privacy we remove crash IDs and all preferences (but bug 1091944 - // exists to expose prefs once we are confident of privacy implications) - delete snapshotData.crashes; - delete snapshotData.modifiedPreferences; - delete snapshotData.printingPreferences; - channel.send(snapshotData, target); - }); + Troubleshoot.snapshot({ includeEnterpriseSecurity: false }).then( + snapshotData => { + // for privacy we remove crash IDs and all preferences (but bug 1091944 + // exists to expose prefs once we are confident of privacy implications) + delete snapshotData.crashes; + delete snapshotData.modifiedPreferences; + delete snapshotData.printingPreferences; + channel.send(snapshotData, target); + } + ); } }); diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 5c32244dc6042..e2b47866daeec 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -55,6 +55,10 @@ notes = "Upstream not yet published changes" audit-as-crates-io = true notes = "This is upstream plus a build fix for bug 2033279." +[policy.felt] +dependency-criteria = { plist = "safe-to-run" } +notes = "plist is used only through Value::from_reader_xml to parse stdout from the absolute, SIP-protected /usr/sbin/diskutil binary. Callers cannot supply plist syntax or structure; diskutil emits no namespaces, internal DTD subset, custom entities, or caller-controlled attributes. The binary and serde entry points are unused." + [policy.firefox-on-glean] audit-as-crates-io = false notes = "The crates.io version of this is just a placeholder to allow public crates to depend on firefox-on-glean." diff --git a/testing/enterprise/test_felt_device_posture.py b/testing/enterprise/test_felt_device_posture.py index 379375739ed3b..7eac1c6aec505 100644 --- a/testing/enterprise/test_felt_device_posture.py +++ b/testing/enterprise/test_felt_device_posture.py @@ -243,6 +243,35 @@ def run_device_posture_content(self): for edr in present_edrs: assert "name" in edr, "Each EDR entry has a name field" + assert "diskEncryption" in device_posture, ( + "Device posture reports diskEncryption" + ) + disk_encryption = device_posture["diskEncryption"] + self._logger.info(f"Disk encryption: {disk_encryption}") + assert disk_encryption["status"] in ( + "full", + "enabled", + "partial", + "disabled", + "in-progress", + "unknown", + ), "diskEncryption reports a documented status" + expected_methods = { + "darwin": ("filevault",), + "win32": ("bitlocker",), + # A ZFS root reports native encryption rather than dm-crypt. + "linux": ("dm-crypt", "zfs"), + }.get(sys.platform, ()) + if disk_encryption["status"] == "unknown": + assert disk_encryption["method"] is None, ( + "An unknown status names no encryption method" + ) + else: + assert disk_encryption["method"] in expected_methods, ( + f"diskEncryption method should be one of {expected_methods} " + f"on {sys.platform}" + ) + assert "mobileEquipmentId" in device_posture["network"], ( "Device posture reports IMEI/MEID" ) diff --git a/toolkit/components/enterprise/modules/DevicePosture.sys.mjs b/toolkit/components/enterprise/modules/DevicePosture.sys.mjs index daa68b824237e..7d1812735c4d4 100644 --- a/toolkit/components/enterprise/modules/DevicePosture.sys.mjs +++ b/toolkit/components/enterprise/modules/DevicePosture.sys.mjs @@ -9,6 +9,7 @@ ChromeUtils.defineESModuleGetters(lazy, { ConsoleClient: "resource://gre/modules/enterprise/ConsoleClient.sys.mjs", createEnterpriseLogger: "resource://gre/modules/enterprise/EnterpriseCommon.sys.mjs", + DiskEncryption: "resource://gre/modules/enterprise/DiskEncryption.sys.mjs", EdrDetection: "resource://gre/modules/enterprise/EdrDetection.sys.mjs", MachineId: "resource://gre/modules/enterprise/MachineId.sys.mjs", setInterval: "resource://gre/modules/Timer.sys.mjs", @@ -199,6 +200,14 @@ export const DevicePosture = { * @property {string} name EDR agent identifier (e.g. "crowdstrike"). */ + /** + * @typedef {object} DeviceDiskEncryption + * @property {"full"|"enabled"|"partial"|"disabled"|"in-progress"|"unknown"} status + * Aggregated encryption status. + * @property {"filevault"|"bitlocker"|"dm-crypt"|"zfs"|null} method + * Platform mechanism checked, or null for an unknown status. + */ + /** * @typedef {object} DevicePosture * @property {object} os Telemetry-reported os information. @@ -210,6 +219,8 @@ export const DevicePosture = { * @property {boolean} secureBootEnabled Whether Secure Boot is enabled. * @property {boolean} isDomainJoined Whether the machine is joined to a domain (Windows on-prem AD or Azure AD/Entra). * @property {DeviceEdr[]} presentEdrs Detected EDR agents (empty if none, or if the console asked us to probe none). + * @property {DeviceDiskEncryption} diskEncryption Disk encryption for the + * boot and other mounted fixed volumes. */ /** @@ -290,15 +301,19 @@ export const DevicePosture = { ); }; - // These probes are independent, and some are slow (subprocess spawns, an - // `ioreg` shell-out), so run them concurrently. - const [mobileEquipmentId, extensions, machineId, presentEdrs] = - await Promise.all([ - getImeiValue(), - this.getExtensions({ profileDir }), - getMachineId(), - getPresentEDRs(), - ]); + const [ + mobileEquipmentId, + extensions, + machineId, + presentEdrs, + diskEncryption, + ] = await Promise.all([ + getImeiValue(), + this.getExtensions({ profileDir }), + getMachineId(), + getPresentEDRs(), + lazy.DiskEncryption.getStatus(), + ]); const devicePosturePayload = { os, @@ -314,6 +329,7 @@ export const DevicePosture = { Services.sysinfo.getPropertyAsBool("secureBootEnabled"), isDomainJoined: Services.sysinfo.getPropertyAsBool("isDomainJoined"), presentEdrs, + diskEncryption, }; return devicePosturePayload; }, diff --git a/toolkit/components/felt/rust/Cargo.toml b/toolkit/components/felt/rust/Cargo.toml index 645c6dde07371..0a9fcef0dce6b 100644 --- a/toolkit/components/felt/rust/Cargo.toml +++ b/toolkit/components/felt/rust/Cargo.toml @@ -17,8 +17,14 @@ time = "0.3.36" serde = { version = "1", features = ["derive"] } serde_json = "1" +[dev-dependencies] +tempfile = "3" + [target.'cfg(unix)'.dependencies] libc = "0.2" +[target.'cfg(target_os = "macos")'.dependencies] +plist = "1" + [target.'cfg(windows)'.dependencies] -winapi = { version = "0.3", features = ["tlhelp32", "handleapi", "processthreadsapi", "winbase", "winnt", "winsvc", "errhandlingapi", "winerror"] } +winapi = { version = "0.3", features = ["tlhelp32", "handleapi", "processthreadsapi", "winbase", "winnt", "winsvc", "errhandlingapi", "winerror", "fileapi", "sysinfoapi"] } diff --git a/toolkit/components/felt/rust/FeltDiskEncryptionWin.cpp b/toolkit/components/felt/rust/FeltDiskEncryptionWin.cpp new file mode 100644 index 0000000000000..9fe7759617c7e --- /dev/null +++ b/toolkit/components/felt/rust/FeltDiskEncryptionWin.cpp @@ -0,0 +1,56 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include + +#include +#include +#include + +#include "mozilla/Assertions.h" +#include "mozilla/RefPtr.h" +#include "mozilla/mscom/Utils.h" +#include "nsCOMPtr.h" + +// Explorer's BitLocker property. Older SDKs omit it from propkey.h. +static const PROPERTYKEY kVolumeBitLockerProtection = { + {0x2d15a9a1, + 0xa556, + 0x4189, + {0x91, 0xad, 0x02, 0x74, 0x58, 0xf1, 0x1a, 0x07}}, + 1717}; + +/** + * Reads System.Volume.BitLockerProtection for a mount path such as "C:\". + * Returns false if the property is missing or is not an integer. + * + * Runs synchronously on the caller's background thread. That thread never + * initializes COM itself; it belongs to the implicit MTA because + * mscom::ProcessRuntime keeps the process MTA alive from startup. + */ +extern "C" bool felt_read_bitlocker_protection(const char16_t* aRoot, + int32_t* aOutValue) { + MOZ_ASSERT(mozilla::mscom::IsCurrentThreadMTA()); + if (!mozilla::mscom::IsCurrentThreadMTA()) { + return false; + } + + RefPtr store; + HRESULT hr = SHGetPropertyStoreFromParsingName( + reinterpret_cast(aRoot), nullptr, GPS_DEFAULT, + IID_IPropertyStore, getter_AddRefs(store)); + if (FAILED(hr) || !store) { + return false; + } + + PROPVARIANT value; + PropVariantInit(&value); + hr = store->GetValue(kVolumeBitLockerProtection, &value); + bool read = SUCCEEDED(hr) && (value.vt == VT_I4 || value.vt == VT_UI4); + if (read) { + *aOutValue = value.lVal; + } + PropVariantClear(&value); + return read; +} diff --git a/toolkit/components/felt/rust/components.conf b/toolkit/components/felt/rust/components.conf index dee6d4232fbe6..1daa17b0f2735 100644 --- a/toolkit/components/felt/rust/components.conf +++ b/toolkit/components/felt/rust/components.conf @@ -30,4 +30,12 @@ Classes = [ 'singleton': True, 'interfaces': ['nsIEdrChecker'], }, + { + 'cid': '{9545d213-062a-4958-82f9-6022f0d3f357}', + 'contract_ids': ['@mozilla.org/enterprise/disk-encryption-checker;1'], + 'headers': ['mozilla/toolkit/components/felt/felt.h'], + 'legacy_constructor': 'disk_encryption_checker_constructor', + 'singleton': True, + 'interfaces': ['nsIDiskEncryptionChecker'], + }, ] diff --git a/toolkit/components/felt/rust/felt.h b/toolkit/components/felt/rust/felt.h index 0f00fdbca4675..452ad7ea2e6c2 100644 --- a/toolkit/components/felt/rust/felt.h +++ b/toolkit/components/felt/rust/felt.h @@ -14,9 +14,11 @@ void felt_init(); bool is_felt_ui(); #ifdef MOZ_WIDGET_GTK -void felt_set_startup_token_or_timestamp(const char* aToken, uint32_t aTimestamp); -void felt_get_startup_token_or_timestamp(const char** aOutToken, uint32_t* aOutTokenLen, - uint32_t* aOutTimestamp); +void felt_set_startup_token_or_timestamp(const char* aToken, + uint32_t aTimestamp); +void felt_get_startup_token_or_timestamp(const char** aOutToken, + uint32_t* aOutTokenLen, + uint32_t* aOutTimestamp); #endif #ifdef XP_MACOSX @@ -37,6 +39,8 @@ nsresult felt_restartforced_constructor(REFNSIID iid, void** result); nsresult edr_checker_constructor(REFNSIID iid, void** result); +nsresult disk_encryption_checker_constructor(REFNSIID iid, void** result); + } // extern "C" #endif // felt_h diff --git a/toolkit/components/felt/rust/moz.build b/toolkit/components/felt/rust/moz.build index 02fc18398740a..d7f0084f0fea5 100644 --- a/toolkit/components/felt/rust/moz.build +++ b/toolkit/components/felt/rust/moz.build @@ -20,10 +20,15 @@ elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "gtk": LOCAL_INCLUDES += [ "/widget/gtk", ] +elif CONFIG["MOZ_WIDGET_TOOLKIT"] == "windows": + SOURCES += [ + "FeltDiskEncryptionWin.cpp", + ] FINAL_LIBRARY = "xul" XPIDL_SOURCES += [ + "nsIDiskEncryptionChecker.idl", "nsIEdrChecker.idl", "nsIFelt.idl", "nsIFeltRestartForced.idl", diff --git a/toolkit/components/felt/rust/nsIDiskEncryptionChecker.idl b/toolkit/components/felt/rust/nsIDiskEncryptionChecker.idl new file mode 100644 index 0000000000000..55432828f98ca --- /dev/null +++ b/toolkit/components/felt/rust/nsIDiskEncryptionChecker.idl @@ -0,0 +1,46 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#include "nsISupports.idl" + +/** Receives one query result on the main thread. */ +[scriptable, uuid(aeae0c99-9c76-4efa-b5b6-bbf2b5192def)] +interface nsIDiskEncryptionCheckerCallback : nsISupports { + /** + * @param status "full" when all relevant volumes were inspected and are + * encrypted; "enabled" when the boot volume is encrypted and + * no plaintext volume was found, but inspection was incomplete; + * "partial" when the boot volume is encrypted but a secondary + * volume is not, taking precedence if another is converting; + * "disabled" when the boot volume is not encrypted; + * "in-progress" during encryption or decryption when no + * plaintext secondary volume was found; otherwise "unknown". + * @param method inspected platform mechanism ("filevault", "bitlocker", + * "dm-crypt" or "zfs"), or empty for an unknown status. + */ + void onComplete(in ACString status, in ACString method); +}; + +/** + * Reports encryption for the operating-system volume, other mounted fixed + * volumes and active Linux swap. Returns "unknown" when unprivileged platform + * APIs cannot determine the status. This is self-reported from local OS state, + * not remote attestation. + */ +[scriptable, uuid(ba74995f-708b-4349-a8c9-53ecb0f118f7)] +interface nsIDiskEncryptionChecker : nsISupports { + /** + * Asynchronously determines disk encryption on a background thread. + * Concurrent callers share one scan; a call throws instead of queueing when + * the running scan appears wedged. Must be called on the main thread, where + * the callback is invoked. + * + * @param callback invoked once with the aggregated status. + */ + void getDiskEncryption(in nsIDiskEncryptionCheckerCallback callback); +}; + +%{C++ +#define NS_DISK_ENCRYPTION_CHECKER_CONTRACTID "@mozilla.org/enterprise/disk-encryption-checker;1" +%} diff --git a/toolkit/components/felt/rust/src/disk_encryption.rs b/toolkit/components/felt/rust/src/disk_encryption.rs new file mode 100644 index 0000000000000..bd8de9146573a --- /dev/null +++ b/toolkit/components/felt/rust/src/disk_encryption.rs @@ -0,0 +1,435 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use moz_task::{DispatchOptions, Task, TaskRunnable, ThreadPtrHandle, ThreadPtrHolder}; +use nserror::{nsresult, NS_ERROR_NOT_AVAILABLE, NS_ERROR_NOT_SAME_THREAD, NS_OK}; +use nsstring::nsCString; +use xpcom::interfaces::nsIDiskEncryptionCheckerCallback; +use xpcom::{xpcom_method, RefPtr}; + +#[cfg(target_os = "linux")] +use crate::disk_encryption_linux as platform; +#[cfg(target_os = "macos")] +use crate::disk_encryption_macos as platform; +#[cfg(target_os = "windows")] +use crate::disk_encryption_win as platform; + +/// Encryption state of one volume. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum VolumeState { + Encrypted, + EncryptedUnverified, + Unencrypted, + /// Encryption or decryption is in progress. + Converting, + Unknown, +} + +/// Aggregated state reported to the console. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum EncryptionStatus { + Full, + Enabled, + Partial, + Disabled, + InProgress, + Unknown, +} + +impl EncryptionStatus { + pub fn as_str(self) -> &'static str { + match self { + EncryptionStatus::Full => "full", + EncryptionStatus::Enabled => "enabled", + EncryptionStatus::Partial => "partial", + EncryptionStatus::Disabled => "disabled", + EncryptionStatus::InProgress => "in-progress", + EncryptionStatus::Unknown => "unknown", + } + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct DiskEncryption { + pub status: EncryptionStatus, + /// Platform mechanism checked, including for negative results. + pub method: Option<&'static str>, +} + +impl DiskEncryption { + pub fn unknown() -> DiskEncryption { + DiskEncryption { + status: EncryptionStatus::Unknown, + method: None, + } + } +} + +/// Aggregates the boot volume and other mounted fixed volumes. `None` means the +/// backend could not enumerate all other volumes. +pub(crate) fn aggregate(boot: VolumeState, others: Option<&[VolumeState]>) -> EncryptionStatus { + match boot { + VolumeState::Unknown => EncryptionStatus::Unknown, + VolumeState::Converting => EncryptionStatus::InProgress, + VolumeState::Unencrypted => EncryptionStatus::Disabled, + VolumeState::Encrypted | VolumeState::EncryptedUnverified => match others { + Some(states) if states.contains(&VolumeState::Unencrypted) => EncryptionStatus::Partial, + Some(states) if states.contains(&VolumeState::Converting) => { + EncryptionStatus::InProgress + } + None => EncryptionStatus::Enabled, + Some(states) + if boot == VolumeState::EncryptedUnverified + || states.iter().any(|state| { + matches!( + state, + VolumeState::Unknown | VolumeState::EncryptedUnverified + ) + }) => + { + EncryptionStatus::Enabled + } + Some(_) => EncryptionStatus::Full, + }, + } +} + +pub(crate) fn summarize( + boot: VolumeState, + others: Option<&[VolumeState]>, + method: &'static str, +) -> DiskEncryption { + let status = aggregate(boot, others); + DiskEncryption { + status, + method: (status != EncryptionStatus::Unknown).then_some(method), + } +} + +// Cache stable results to avoid repeating expensive probes on every poll. +const CACHE_TTL: Duration = Duration::from_secs(10 * 60); + +// Retry inconclusive and transitional results sooner. +const UNKNOWN_CACHE_TTL: Duration = Duration::from_secs(60); + +// Backends stop between probes after this budget. One platform call may still +// block longer. Keep this below the JS timeout. +const SWEEP_BUDGET: Duration = Duration::from_secs(20); + +static CACHE: Mutex> = Mutex::new(None); + +// A sweep older than this is assumed wedged in an unbounded platform call (a +// Windows shell property read can block indefinitely). Later callers then fail +// immediately, reporting unknown, instead of queueing behind it forever. +const WEDGED_SWEEP_TIMEOUT: Duration = Duration::from_secs(60); + +const MAX_WAITING_CALLBACKS: usize = 64; + +/// The running sweep and the main-thread callbacks awaiting its result. +struct Sweep { + started: Instant, + waiting: Vec>, +} + +static SWEEP: Mutex> = Mutex::new(None); + +fn cache_is_usable(cache: &Option<(Instant, DiskEncryption)>, now: Instant) -> bool { + match cache { + Some((at, result)) => { + let ttl = if matches!( + result.status, + EncryptionStatus::Enabled + | EncryptionStatus::InProgress + | EncryptionStatus::Unknown + ) { + UNKNOWN_CACHE_TTL + } else { + CACHE_TTL + }; + now.duration_since(*at) < ttl + } + None => false, + } +} + +/// Determines the machine's disk encryption status. Runs on a background +/// thread; must not touch main-thread-only state. +fn detect_disk_encryption() -> DiskEncryption { + { + // Continue detection after a poisoned cache lock. + let cache = CACHE.lock().unwrap_or_else(|e| e.into_inner()); + if cache_is_usable(&cache, Instant::now()) { + if let Some((_, result)) = *cache { + return result; + } + } + } + + // Start the TTL after detection completes. + let result = platform::detect(Instant::now() + SWEEP_BUDGET); + *CACHE.lock().unwrap_or_else(|e| e.into_inner()) = Some((Instant::now(), result)); + result +} + +struct DiskEncryptionTask { + // Written by `run` on the worker; read by `done` on the dispatching thread. + result: Mutex, +} + +impl Task for DiskEncryptionTask { + fn run(&self) { + *self.result.lock().unwrap() = detect_disk_encryption(); + } + + fn done(&self) -> Result<(), nsresult> { + let result = *self.result.lock().unwrap(); + let status = nsCString::from(result.status.as_str()); + let method = nsCString::from(result.method.unwrap_or("")); + + let sweep = SWEEP.lock().unwrap_or_else(|e| e.into_inner()).take(); + for handle in sweep.into_iter().flat_map(|sweep| sweep.waiting) { + if let Some(callback) = handle.get() { + let _ = unsafe { callback.OnComplete(&*status, &*method) }; + } + } + Ok(()) + } +} + +#[xpcom(implement(nsIDiskEncryptionChecker), atomic)] +pub struct DiskEncryptionCheckerXPCOM {} + +#[allow(non_snake_case)] +impl DiskEncryptionCheckerXPCOM { + pub fn new() -> RefPtr { + DiskEncryptionCheckerXPCOM::allocate(InitDiskEncryptionCheckerXPCOM {}) + } + + xpcom_method!( + get_disk_encryption => GetDiskEncryption( + callback: *const nsIDiskEncryptionCheckerCallback + ) + ); + + fn get_disk_encryption( + &self, + callback: &nsIDiskEncryptionCheckerCallback, + ) -> Result<(), nsresult> { + // `done` invokes every queued callback on the dispatching thread. + if !moz_task::is_main_thread() { + return Err(NS_ERROR_NOT_SAME_THREAD); + } + + let callback = ThreadPtrHolder::new( + cstr!("nsIDiskEncryptionCheckerCallback"), + RefPtr::new(callback), + )?; + + let mut sweep = SWEEP.lock().unwrap_or_else(|e| e.into_inner()); + if let Some(running) = sweep.as_mut() { + if running.started.elapsed() >= WEDGED_SWEEP_TIMEOUT { + return Err(NS_ERROR_NOT_AVAILABLE); + } + if running.waiting.len() >= MAX_WAITING_CALLBACKS { + return Err(NS_ERROR_NOT_AVAILABLE); + } + running.waiting.push(callback); + return Ok(()); + } + *sweep = Some(Sweep { + started: Instant::now(), + waiting: vec![callback], + }); + drop(sweep); + + // Platform detection performs blocking I/O. + let dispatch = || -> Result<(), nsresult> { + let task = Box::new(DiskEncryptionTask { + result: Mutex::new(DiskEncryption::unknown()), + }); + TaskRunnable::new("DiskEncryptionChecker::getDiskEncryption", task)? + .dispatch_background_task_with_options(DispatchOptions::default().may_block(true)) + }; + + dispatch().inspect_err(|_| { + // No task will drain the queue after dispatch fails. + *SWEEP.lock().unwrap_or_else(|e| e.into_inner()) = None; + }) + } +} + +#[no_mangle] +pub extern "C" fn disk_encryption_checker_constructor( + iid: &xpcom::nsIID, + result: *mut *mut xpcom::reexports::libc::c_void, +) -> nsresult { + let obj = DiskEncryptionCheckerXPCOM::new(); + unsafe { obj.QueryInterface(iid, result) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn boot_volume_decides_the_status() { + assert_eq!( + aggregate(VolumeState::Unknown, Some(&[VolumeState::Encrypted])), + EncryptionStatus::Unknown + ); + assert_eq!( + aggregate(VolumeState::Converting, Some(&[])), + EncryptionStatus::InProgress + ); + assert_eq!( + aggregate(VolumeState::Encrypted, Some(&[])), + EncryptionStatus::Full + ); + + assert_eq!( + aggregate(VolumeState::Unencrypted, Some(&[VolumeState::Encrypted])), + EncryptionStatus::Disabled + ); + } + + #[test] + fn a_known_unencrypted_volume_makes_the_machine_partial() { + assert_eq!( + aggregate( + VolumeState::Encrypted, + Some(&[VolumeState::Encrypted, VolumeState::Unencrypted]) + ), + EncryptionStatus::Partial + ); + } + + #[test] + fn an_unknown_secondary_volume_preserves_enabled_status() { + assert_eq!( + aggregate(VolumeState::Encrypted, Some(&[VolumeState::Unknown])), + EncryptionStatus::Enabled + ); + } + + #[test] + fn an_unattested_encryption_mapping_is_enabled() { + assert_eq!( + aggregate(VolumeState::EncryptedUnverified, Some(&[])), + EncryptionStatus::Enabled + ); + } + + #[test] + fn a_converting_volume_makes_the_machine_in_progress() { + assert_eq!( + aggregate( + VolumeState::Encrypted, + Some(&[VolumeState::Encrypted, VolumeState::Converting]) + ), + EncryptionStatus::InProgress + ); + + // A known unencrypted volume takes precedence over conversion. + assert_eq!( + aggregate( + VolumeState::Encrypted, + Some(&[VolumeState::Converting, VolumeState::Unencrypted]) + ), + EncryptionStatus::Partial + ); + } + + #[test] + fn a_plaintext_secondary_takes_precedence_over_a_converting_one() { + assert_eq!( + aggregate( + VolumeState::Encrypted, + Some(&[VolumeState::Converting, VolumeState::Unencrypted]) + ), + EncryptionStatus::Partial + ); + } + + #[test] + fn incomplete_enumeration_respects_boot_status() { + assert_eq!( + aggregate(VolumeState::Encrypted, None), + EncryptionStatus::Enabled + ); + + assert_eq!( + aggregate(VolumeState::Unencrypted, None), + EncryptionStatus::Disabled + ); + assert_eq!( + aggregate(VolumeState::Converting, None), + EncryptionStatus::InProgress + ); + } + + #[test] + fn summarize_sets_status_and_method() { + let known = summarize(VolumeState::Encrypted, Some(&[]), "filevault"); + assert_eq!(known.status, EncryptionStatus::Full); + assert_eq!(known.method, Some("filevault")); + + let disabled = summarize(VolumeState::Unencrypted, Some(&[]), "bitlocker"); + assert_eq!(disabled.status, EncryptionStatus::Disabled); + assert_eq!(disabled.method, Some("bitlocker")); + + let unknown = summarize(VolumeState::Unknown, Some(&[]), "dm-crypt"); + assert_eq!(unknown.status, EncryptionStatus::Unknown); + assert_eq!(unknown.method, None); + } + + #[test] + fn cache_expires_sooner_when_inconclusive() { + let now = Instant::now(); + + assert!(!cache_is_usable(&None, now)); + + let full = Some(( + now, + DiskEncryption { + status: EncryptionStatus::Full, + method: Some("filevault"), + }, + )); + assert!(cache_is_usable(&full, now)); + + let just_past_unknown_ttl = now + UNKNOWN_CACHE_TTL + Duration::from_secs(1); + assert!(cache_is_usable(&full, just_past_unknown_ttl)); + assert!(!cache_is_usable( + &full, + now + CACHE_TTL + Duration::from_secs(1) + )); + + let enabled = Some(( + now, + DiskEncryption { + status: EncryptionStatus::Enabled, + method: Some("dm-crypt"), + }, + )); + assert!(cache_is_usable(&enabled, now)); + assert!(!cache_is_usable(&enabled, just_past_unknown_ttl)); + + let in_progress = Some(( + now, + DiskEncryption { + status: EncryptionStatus::InProgress, + method: Some("bitlocker"), + }, + )); + assert!(cache_is_usable(&in_progress, now)); + assert!(!cache_is_usable(&in_progress, just_past_unknown_ttl)); + + let unknown = Some((now, DiskEncryption::unknown())); + assert!(cache_is_usable(&unknown, now)); + assert!(!cache_is_usable(&unknown, just_past_unknown_ttl)); + } +} diff --git a/toolkit/components/felt/rust/src/disk_encryption_linux.rs b/toolkit/components/felt/rust/src/disk_encryption_linux.rs new file mode 100644 index 0000000000000..a134c87e099b7 --- /dev/null +++ b/toolkit/components/felt/rust/src/disk_encryption_linux.rs @@ -0,0 +1,1577 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use log::trace; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::io::ErrorKind; +use std::os::unix::fs::{FileTypeExt, MetadataExt}; +use std::path::Path; +use std::time::Instant; + +use crate::disk_encryption::{summarize, DiskEncryption, VolumeState}; +use crate::process::{budget_until, run_command_within}; + +const MOUNTINFO: &str = "/proc/self/mountinfo"; +const SWAPS: &str = "/proc/swaps"; +const SYS_DEV_BLOCK: &str = "/sys/dev/block"; +const SYS_FS_BTRFS: &str = "/sys/fs/btrfs"; + +// Limit recursion through malformed device graphs. +const MAX_STACK_DEPTH: u32 = 8; + +// LUKS and plain dm-crypt mapper UUIDs start with CRYPT-. +const DM_CRYPT_UUID_PREFIX: &str = "CRYPT-"; + +// cryptsetup types sharing that prefix which store plaintext. +const DM_INTEGRITY_ONLY_UUID_PREFIXES: &[&str] = &["CRYPT-VERITY-", "CRYPT-INTEGRITY-"]; + +// The mapping table is the only proof of the cipher, and the kernel discloses +// it to root alone. Without it, a LUKS mapping is trusted because a LUKS header +// only carries a null cipher when deliberately created for debugging, whereas a +// plain dm-crypt mapping takes its cipher from the command line. The UUID is +// creator-supplied, so the table is checked whenever it is readable. +const DM_LUKS_UUID_PREFIX: &str = "CRYPT-LUKS"; + +// Ignore virtual filesystems and loop devices whose backing files are checked +// through their mounted volumes. Pooled filesystems report an anonymous device +// number too, but name their storage in the mount source. +const VIRTUAL_MAJOR: &str = "0"; +const LOOP_MAJOR: &str = "7"; + +const BTRFS_FSTYPE: &str = "btrfs"; +const ZFS_FSTYPE: &str = "zfs"; + +// zram is memory-backed and has no data-at-rest exposure. +const VOLATILE_DEVICE_PREFIX: &str = "zram"; + +// Boot partitions contain no user data and are commonly unencrypted. Only these +// exact mount points are skipped, so a data volume below them still counts. +const UNENCRYPTABLE_MOUNTS: &[&str] = &["/boot", "/boot/efi", "/efi"]; + +// The zfs tools live in sbin, which is not always on PATH. +const ZFS_TOOL: &[&str] = &["/usr/sbin/zfs", "/sbin/zfs"]; +const ZPOOL_TOOL: &[&str] = &["/usr/sbin/zpool", "/sbin/zpool"]; +const DMSETUP_TOOL: &[&str] = &["/usr/sbin/dmsetup", "/sbin/dmsetup"]; + +// Values of the ZFS `encryption` property that mean no native encryption. +const ZFS_PLAINTEXT: &[&str] = &["", "-", "off"]; + +const DM_CRYPT_METHOD: &str = "dm-crypt"; +const ZFS_METHOD: &str = "zfs"; + +/// The sysfs directories consulted while resolving storage. +pub(crate) struct Sysfs<'a> { + pub dev_block: &'a Path, + pub fs_btrfs: &'a Path, +} + +pub fn detect(deadline: Instant) -> DiskEncryption { + let Ok(mountinfo) = fs::read_to_string(MOUNTINFO) else { + trace!("DiskEncryption: could not read {}", MOUNTINFO); + return DiskEncryption::unknown(); + }; + let swaps = match fs::read_to_string(SWAPS) { + Ok(swaps) => swap_areas(&swaps), + Err(_) => { + trace!("DiskEncryption: could not read {}", SWAPS); + vec![SwapArea::Unresolved] + } + }; + let zfs = if parse_mountinfo(&mountinfo) + .iter() + .any(|mount| mount.fstype == ZFS_FSTYPE) + { + probe_zfs(deadline) + } else { + ZfsTables::default() + }; + + let inspect_dm_crypt = |dir: &Path| dm_crypt_is_confidential(dir, deadline); + detect_at( + &Sysfs { + dev_block: Path::new(SYS_DEV_BLOCK), + fs_btrfs: Path::new(SYS_FS_BTRFS), + }, + &mountinfo, + &swaps, + &zfs, + deadline, + &inspect_dm_crypt, + ) +} + +/// Runs detection with supplied procfs, sysfs and ZFS data. +pub(crate) fn detect_at( + sysfs: &Sysfs, + mountinfo: &str, + swap_areas: &[SwapArea], + zfs: &ZfsTables, + deadline: Instant, + inspect_dm_crypt: &dyn Fn(&Path) -> Option, +) -> DiskEncryption { + let mounts = parse_mountinfo(mountinfo); + + let Some(root) = root_mount(&mounts) else { + trace!("DiskEncryption: no root mount in mountinfo"); + return DiskEncryption::unknown(); + }; + let Some(root_backing) = backing_of(sysfs, root) else { + trace!("DiskEncryption: no storage found behind {}", root.source); + return DiskEncryption::unknown(); + }; + + let boot = volume_state(sysfs, zfs, &root_backing, inspect_dm_crypt); + let others = if matches!( + boot, + VolumeState::Encrypted | VolumeState::EncryptedUnverified + ) { + other_fixed_volume_states( + sysfs, + zfs, + &mounts, + swap_areas, + &root_backing, + deadline, + inspect_dm_crypt, + ) + } else { + Vec::new() + }; + + summarize(boot, Some(&others), method_of(zfs, &root_backing)) +} + +pub(crate) struct MountEntry { + /// "major:minor" of the device backing the mount, anonymous for pooled + /// filesystems. + pub devno: String, + pub mount_point: String, + pub fstype: String, + /// A device path, or a dataset name for ZFS. + pub source: String, +} + +/// Parses `/proc/self/mountinfo`, whose variable-length optional fields are +/// terminated by a lone "-" before the filesystem type and mount source. +pub(crate) fn parse_mountinfo(text: &str) -> Vec { + text.lines() + .filter_map(|line| { + let mut fields = line.split_whitespace(); + let devno = fields.nth(2)?; + let mount_point = fields.nth(1)?; + let mut tail = fields.skip_while(|field| *field != "-").skip(1); + let fstype = tail.next()?; + let source = tail.next()?; + Some(MountEntry { + devno: devno.to_string(), + mount_point: unescape_mount_path(mount_point), + fstype: fstype.to_string(), + source: unescape_mount_path(source), + }) + }) + .collect() +} + +/// Decodes procfs octal escapes, which encode individual UTF-8 bytes. +fn unescape_mount_path(path: &str) -> String { + let mut out = Vec::with_capacity(path.len()); + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let escaped = bytes.get(i + 1..i + 4).filter(|_| bytes[i] == b'\\'); + match escaped.and_then(|digits| std::str::from_utf8(digits).ok()) { + Some(digits) if digits.bytes().all(|d| (b'0'..=b'7').contains(&d)) => { + out.push(u8::from_str_radix(digits, 8).unwrap_or(b'?')); + i += 4; + } + _ => { + out.push(bytes[i]); + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// The mount backing "/". A later mount over "/" shadows an earlier one, so the +/// last entry wins. +pub(crate) fn root_mount(mounts: &[MountEntry]) -> Option<&MountEntry> { + mounts.iter().rev().find(|m| m.mount_point == "/") +} + +/// Inspects each mounted fixed volume and swap area once. A volume whose +/// storage cannot be resolved counts as unknown, as do the volumes left when +/// the deadline expires, so evidence gathered before then is kept. +fn other_fixed_volume_states( + sysfs: &Sysfs, + zfs: &ZfsTables, + mounts: &[MountEntry], + swap_areas: &[SwapArea], + root: &Backing, + deadline: Instant, + inspect_dm_crypt: &dyn Fn(&Path) -> Option, +) -> Vec { + let mounted = mounts + .iter() + .filter(|mount| is_fixed_volume_mount(mount)) + .map(|mount| backing_of(sysfs, mount)); + let swapped = swap_areas.iter().map(|area| match area { + SwapArea::Device(devno) => swap_backing(sysfs, mounts, devno), + SwapArea::Unresolved => None, + }); + + let mut seen = HashSet::from([backing_key(root)]); + let mut states = Vec::new(); + + for backing in mounted.chain(swapped) { + let Some(backing) = backing else { + states.push(VolumeState::Unknown); + continue; + }; + if !seen.insert(backing_key(&backing)) || is_ignorable(sysfs, &backing) { + continue; + } + if Instant::now() >= deadline { + trace!("DiskEncryption: ran out of time walking the block devices"); + states.push(VolumeState::Unknown); + break; + } + states.push(volume_state(sysfs, zfs, &backing, inspect_dm_crypt)); + } + + states +} + +/// Swap on a pooled filesystem reports that filesystem's anonymous device +/// number, so the mount it belongs to resolves it. +fn swap_backing(sysfs: &Sysfs, mounts: &[MountEntry], devno: &str) -> Option { + match mounts.iter().find(|mount| mount.devno == devno) { + Some(mount) => backing_of(sysfs, mount), + None => Some(Backing::Devices(vec![devno.to_string()])), + } +} + +/// An active swap area from `/proc/swaps`. +pub(crate) enum SwapArea { + /// The "major:minor" of the device holding the swap area. + Device(String), + /// The area exists but its storage could not be determined. + Unresolved, +} + +fn swap_areas(swaps: &str) -> Vec { + parse_swaps(swaps) + .iter() + .map(|path| match devno_of(Path::new(path)) { + Some(devno) => SwapArea::Device(devno), + None => { + trace!("DiskEncryption: could not resolve swap area {}", path); + SwapArea::Unresolved + } + }) + .collect() +} + +/// The first column of `/proc/swaps`, which names either a block device or a +/// file, after its one-line header. +pub(crate) fn parse_swaps(swaps: &str) -> Vec { + swaps + .lines() + .skip(1) + .filter_map(|line| line.split_whitespace().next()) + .map(unescape_mount_path) + .collect() +} + +/// The "major:minor" of a swap area: the device itself for a partition, and the +/// filesystem it lives on for a swap file. +fn devno_of(path: &Path) -> Option { + let metadata = fs::metadata(path).ok()?; + let dev = if metadata.file_type().is_block_device() { + metadata.rdev() + } else { + metadata.dev() + }; + Some(format!("{}:{}", dev_major(dev), dev_minor(dev))) +} + +// Mirror glibc's split-bit gnu_dev_major/gnu_dev_minor encoding. +fn dev_major(dev: u64) -> u32 { + (((dev >> 8) & 0xfff) as u32) | (((dev >> 32) as u32) & !0xfff) +} + +fn dev_minor(dev: u64) -> u32 { + ((dev & 0xff) as u32) | (((dev >> 12) as u32) & !0xff) +} + +fn is_fixed_volume_mount(mount: &MountEntry) -> bool { + let major = major_of(&mount.devno); + if major == LOOP_MAJOR { + return false; + } + if major == VIRTUAL_MAJOR && !is_pooled(&mount.fstype) { + return false; + } + !UNENCRYPTABLE_MOUNTS.contains(&mount.mount_point.as_str()) +} + +fn major_of(devno: &str) -> &str { + devno.split(':').next().unwrap_or_default() +} + +/// Whether the filesystem spans devices it names in the mount source rather +/// than reporting one in mountinfo. +fn is_pooled(fstype: &str) -> bool { + fstype == BTRFS_FSTYPE || fstype == ZFS_FSTYPE +} + +/// The storage a mount is judged by. +enum Backing { + /// Block devices, each walked through sysfs. + Devices(Vec), + /// A ZFS dataset, judged with the zfs tools. + Zfs(String), +} + +/// Resolves a mount to its storage, or `None` when it has none to inspect. +fn backing_of(sysfs: &Sysfs, mount: &MountEntry) -> Option { + if major_of(&mount.devno) != VIRTUAL_MAJOR { + return Some(Backing::Devices(vec![mount.devno.clone()])); + } + match mount.fstype.as_str() { + BTRFS_FSTYPE => btrfs_devnos(sysfs, &mount.source).map(Backing::Devices), + ZFS_FSTYPE => Some(Backing::Zfs(mount.source.clone())), + _ => None, + } +} + +/// Identifies storage so that several mounts of it are inspected once. +fn backing_key(backing: &Backing) -> String { + match backing { + Backing::Devices(devnos) => { + let mut sorted = devnos.clone(); + sorted.sort(); + sorted.join(",") + } + Backing::Zfs(dataset) => format!("{}:{}", ZFS_FSTYPE, dataset), + } +} + +/// Volumes with no data at rest to protect. +fn is_ignorable(sysfs: &Sysfs, backing: &Backing) -> bool { + match backing { + Backing::Devices(devnos) => devnos + .iter() + .any(|devno| is_volatile(sysfs, devno) || is_removable(sysfs, devno)), + Backing::Zfs(_) => false, + } +} + +fn volume_state( + sysfs: &Sysfs, + zfs: &ZfsTables, + backing: &Backing, + inspect_dm_crypt: &dyn Fn(&Path) -> Option, +) -> VolumeState { + match backing { + Backing::Devices(devnos) => combine( + devnos + .iter() + .map(|devno| device_state(sysfs, devno, inspect_dm_crypt)), + ), + Backing::Zfs(dataset) => zfs_state(sysfs, zfs, dataset, inspect_dm_crypt), + } +} + +/// Names the mechanism the boot volume was judged by. +fn method_of(zfs: &ZfsTables, backing: &Backing) -> &'static str { + match backing { + Backing::Zfs(dataset) if zfs_is_natively_encrypted(zfs, dataset) => ZFS_METHOD, + _ => DM_CRYPT_METHOD, + } +} + +/// Plaintext in any member makes the whole set plaintext; otherwise a member +/// that cannot be inspected makes it unknown. +fn combine(states: impl Iterator) -> VolumeState { + let states: Vec = states.collect(); + if states.contains(&VolumeState::Unencrypted) { + VolumeState::Unencrypted + } else if states.is_empty() || states.contains(&VolumeState::Unknown) { + VolumeState::Unknown + } else if states.contains(&VolumeState::EncryptedUnverified) { + VolumeState::EncryptedUnverified + } else { + VolumeState::Encrypted + } +} + +/// Every device of the btrfs filesystem mounted from `source`. Only sysfs +/// reports the other devices of a multi-device filesystem, so the mount source +/// alone is used just when sysfs does not list it. +fn btrfs_devnos(sysfs: &Sysfs, source: &str) -> Option> { + let name = device_name(source); + match btrfs_filesystem(sysfs.fs_btrfs, &name) { + BtrfsFilesystem::Devices(devnos) => Some(devnos), + BtrfsFilesystem::Unreadable => None, + BtrfsFilesystem::Unlisted => Some(vec![devno_for_name(sysfs.dev_block, &name)?]), + } +} + +/// What /sys/fs/btrfs says about a mounted device. +enum BtrfsFilesystem { + /// Every device of the filesystem holding it. + Devices(Vec), + /// Its filesystem was found, but the device list was incomplete. + Unreadable, + /// No filesystem lists it. + Unlisted, +} + +fn btrfs_filesystem(fs_btrfs: &Path, name: &str) -> BtrfsFilesystem { + let Ok(filesystems) = fs::read_dir(fs_btrfs) else { + return BtrfsFilesystem::Unlisted; + }; + + for filesystem in filesystems.flatten() { + let Ok(devices) = fs::read_dir(filesystem.path().join("devices")) else { + continue; + }; + + let mut devnos = Vec::new(); + let mut holds_name = false; + let mut complete = true; + for device in devices.flatten() { + holds_name |= device.file_name().to_str() == Some(name); + match fs::read_to_string(device.path().join("dev")) { + Ok(devno) => devnos.push(devno.trim().to_string()), + Err(_) => complete = false, + } + } + + if holds_name { + return if complete { + BtrfsFilesystem::Devices(devnos) + } else { + BtrfsFilesystem::Unreadable + }; + } + } + + BtrfsFilesystem::Unlisted +} + +/// The kernel name of a device path, resolving links like /dev/mapper/root. +fn device_name(source: &str) -> String { + let path = Path::new(source); + fs::canonicalize(path) + .unwrap_or_else(|_| path.to_path_buf()) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() +} + +/// Finds the device number of a kernel device name, since `/sys/dev/block` +/// entries are named by number and link to the named directory. +fn devno_for_name(sys_dev_block: &Path, name: &str) -> Option { + fs::read_dir(sys_dev_block) + .ok()? + .flatten() + .find_map(|entry| { + let resolved = entry.path().canonicalize().ok()?; + (resolved.file_name()?.to_str()? == name) + .then(|| entry.file_name().to_string_lossy().into_owned()) + }) +} + +/// The ZFS tools report every dataset and pool at once, so one call each is +/// made per sweep. +#[derive(Default)] +pub(crate) struct ZfsTables { + /// Dataset to the value of its `encryption` property. + encryption: HashMap, + /// Pool to the device paths of its vdevs. + vdevs: HashMap>, +} + +fn probe_zfs(deadline: Instant) -> ZfsTables { + ZfsTables { + encryption: run_zfs_tool( + ZFS_TOOL, + &["get", "-H", "-o", "name,value", "encryption"], + deadline, + ) + .as_deref() + .map(parse_zfs_encryption) + .unwrap_or_default(), + vdevs: run_zfs_tool(ZPOOL_TOOL, &["status", "-P"], deadline) + .as_deref() + .map(parse_zpool_status) + .unwrap_or_default(), + } +} + +fn run_zfs_tool(candidates: &[&str], args: &[&str], deadline: Instant) -> Option { + let program = candidates.iter().find(|path| Path::new(path).exists())?; + let output = run_command_within(program, args, budget_until(deadline)?)?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).into_owned()) +} + +/// Parses the tab-separated name and value columns of `zfs get -H`. +pub(crate) fn parse_zfs_encryption(text: &str) -> HashMap { + text.lines() + .filter_map(|line| { + let (name, value) = line.split_once('\t')?; + (!name.is_empty()).then(|| (name.to_string(), value.trim().to_string())) + }) + .collect() +} + +/// Collects the leaf vdev paths `zpool status -P` prints beneath each pool. +/// Cache and log devices hold data and are included; hot spares hold none +/// until they replace a device and are skipped. +pub(crate) fn parse_zpool_status(text: &str) -> HashMap> { + let mut pools: HashMap> = HashMap::new(); + let mut pool = None; + let mut in_spares = false; + + for line in text.lines() { + let trimmed = line.trim_start(); + if let Some(name) = trimmed.strip_prefix("pool:") { + pool = Some(name.trim().to_string()); + in_spares = false; + continue; + } + let (Some(pool), Some(path)) = (pool.as_ref(), trimmed.split_whitespace().next()) else { + continue; + }; + if !path.starts_with('/') { + in_spares = path == "spares"; + } else if !in_spares { + pools + .entry(pool.clone()) + .or_default() + .push(path.to_string()); + } + } + + pools +} + +fn zfs_is_natively_encrypted(zfs: &ZfsTables, dataset: &str) -> bool { + zfs.encryption + .get(dataset) + .is_some_and(|value| !ZFS_PLAINTEXT.contains(&value.as_str())) +} + +/// A dataset is encrypted natively, or by every device under its pool. +fn zfs_state( + sysfs: &Sysfs, + zfs: &ZfsTables, + dataset: &str, + inspect_dm_crypt: &dyn Fn(&Path) -> Option, +) -> VolumeState { + if zfs_is_natively_encrypted(zfs, dataset) { + return VolumeState::Encrypted; + } + + let pool = dataset.split('/').next().unwrap_or(dataset); + let Some(vdevs) = zfs.vdevs.get(pool) else { + trace!("DiskEncryption: no vdevs reported for pool {}", pool); + return VolumeState::Unknown; + }; + + combine(vdevs.iter().map( + |path| match devno_for_name(sysfs.dev_block, &device_name(path)) { + Some(devno) => device_state(sysfs, &devno, inspect_dm_crypt), + None => VolumeState::Unknown, + }, + )) +} + +/// Follows sysfs slave links to find dm-crypt below the mounted device. +fn device_state( + sysfs: &Sysfs, + devno: &str, + inspect_dm_crypt: &dyn Fn(&Path) -> Option, +) -> VolumeState { + stack_encryption_state( + &sysfs.dev_block.join(devno), + MAX_STACK_DEPTH, + inspect_dm_crypt, + ) +} + +fn stack_encryption_state( + dir: &Path, + depth: u32, + inspect_dm_crypt: &dyn Fn(&Path) -> Option, +) -> VolumeState { + if depth == 0 { + trace!("DiskEncryption: device stack too deep at {}", dir.display()); + return VolumeState::Unknown; + } + if !dir.exists() { + trace!("DiskEncryption: no sysfs entry at {}", dir.display()); + return VolumeState::Unknown; + } + // A loop device's backing file lives on a filesystem this walk cannot see, + // as with the squashfs root of a snap-confined browser. + if dir.join("loop").is_dir() { + trace!("DiskEncryption: loop device at {}", dir.display()); + return VolumeState::Unknown; + } + + match fs::read_to_string(dir.join("dm").join("uuid")) { + Ok(uuid) => { + let uuid = uuid.trim(); + // Integrity-only devices are examined through their slaves instead. + if uuid.starts_with(DM_CRYPT_UUID_PREFIX) + && !DM_INTEGRITY_ONLY_UUID_PREFIXES + .iter() + .any(|prefix| uuid.starts_with(prefix)) + { + return match inspect_dm_crypt(dir) { + Some(false) => VolumeState::Unencrypted, + Some(true) => VolumeState::Encrypted, + None if uuid.starts_with(DM_LUKS_UUID_PREFIX) => VolumeState::Encrypted, + None => VolumeState::EncryptedUnverified, + }; + } + } + // Non-device-mapper devices have no dm/uuid. + Err(e) if e.kind() == ErrorKind::NotFound => {} + Err(_) => return VolumeState::Unknown, + } + + let slaves = match fs::read_dir(dir.join("slaves")) { + Ok(slaves) => slaves, + // Physical storage, the bottom of the stack. + Err(e) if e.kind() == ErrorKind::NotFound => return VolumeState::Unencrypted, + Err(_) => return VolumeState::Unknown, + }; + + let mut layers = Vec::new(); + for slave in slaves { + let Ok(slave) = slave else { + return VolumeState::Unknown; + }; + layers.push(stack_encryption_state( + &slave.path(), + depth - 1, + inspect_dm_crypt, + )); + } + + if layers.is_empty() { + VolumeState::Unencrypted + } else { + combine(layers.into_iter()) + } +} + +/// Reads the mapping table, which the kernel only discloses to root. +fn dm_crypt_is_confidential(dir: &Path, deadline: Instant) -> Option { + if unsafe { libc::geteuid() } != 0 { + return None; + } + let name = fs::read_to_string(dir.join("dm").join("name")).ok()?; + let program = DMSETUP_TOOL.iter().find(|path| Path::new(path).exists())?; + let output = run_command_within(program, &["table", name.trim()], budget_until(deadline)?)?; + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout)) + .and_then(|table| parse_dm_crypt_table(&table)) +} + +/// A CRYPT- device whose table holds a target other than crypt merely carries +/// the UUID and stores plaintext. +pub(crate) fn parse_dm_crypt_table(table: &str) -> Option { + let mut found = false; + for line in table.lines().filter(|line| !line.trim().is_empty()) { + let mut fields = line.split_whitespace(); + fields.next()?; + fields.next()?; + if fields.next()? != "crypt" { + return Some(false); + } + found = true; + let cipher = fields.next()?.to_ascii_lowercase(); + if cipher + .split(|c: char| !c.is_ascii_alphanumeric() && c != '_') + .any(|part| matches!(part, "null" | "cipher_null")) + { + return Some(false); + } + } + found.then_some(true) +} + +/// Whether the device exists only in memory, as zram swap does. +fn is_volatile(sysfs: &Sysfs, devno: &str) -> bool { + let Ok(resolved) = sysfs.dev_block.join(devno).canonicalize() else { + return false; + }; + resolved + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with(VOLATILE_DEVICE_PREFIX)) +} + +/// Checks the device and its parent because partitions inherit the whole disk's +/// removable flag. +fn is_removable(sysfs: &Sysfs, devno: &str) -> bool { + let dir = sysfs.dev_block.join(devno); + let Ok(resolved) = dir.canonicalize() else { + return false; + }; + + let mut candidates = vec![resolved.clone()]; + if let Some(parent) = resolved.parent() { + candidates.push(parent.to_path_buf()); + } + + candidates.iter().any(|candidate| { + fs::read_to_string(candidate.join("removable")).is_ok_and(|flag| flag.trim() == "1") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + use std::time::Duration; + + fn detect(sysfs: &SysfsFixture, mountinfo: &str) -> DiskEncryption { + detect_with(sysfs, mountinfo, &[], &ZfsTables::default()) + } + + fn detect_with( + sysfs: &SysfsFixture, + mountinfo: &str, + swap_areas: &[SwapArea], + zfs: &ZfsTables, + ) -> DiskEncryption { + detect_at( + &sysfs.paths(), + mountinfo, + swap_areas, + zfs, + Instant::now() + Duration::from_secs(60), + &|_| Some(true), + ) + } + + fn swap_on(devno: &str) -> [SwapArea; 1] { + [SwapArea::Device(devno.to_string())] + } + + const ROOT_ON_LUKS: &str = "\ +23 28 0:21 / /proc rw,nosuid,nodev,noexec,relatime shared:12 - proc proc rw +24 28 0:22 / /sys rw,nosuid,nodev,noexec,relatime shared:2 - sysfs sysfs rw +28 1 253:1 / / rw,relatime shared:1 - ext4 /dev/mapper/vg-root rw +31 28 8:2 / /boot rw,relatime shared:15 - ext2 /dev/sda2 rw +32 28 8:1 / /boot/efi rw,relatime shared:16 - vfat /dev/sda1 rw +40 28 7:0 / /snap/core/1234 ro,nodev,relatime shared:20 - squashfs /dev/loop0 ro +45 28 8:16 / /data rw,relatime shared:25 - ext4 /dev/sdb1 rw"; + + #[test] + fn mountinfo_fields_are_read_by_position() { + let mounts = parse_mountinfo(ROOT_ON_LUKS); + assert_eq!(mounts.len(), 7); + assert_eq!(mounts[2].devno, "253:1"); + assert_eq!(mounts[2].mount_point, "/"); + assert_eq!(mounts[2].fstype, "ext4"); + assert_eq!(mounts[2].source, "/dev/mapper/vg-root"); + assert_eq!(mounts[6].devno, "8:16"); + assert_eq!(mounts[6].mount_point, "/data"); + } + + #[test] + fn the_optional_fields_before_the_separator_are_skipped() { + let padded = "\ +28 1 8:2 / / rw shared:1 master:2 propagate_from:3 unbindable - ext4 /dev/sda2 rw +29 1 8:3 / /srv rw - ext4 /dev/sda3 rw"; + let mounts = parse_mountinfo(padded); + assert_eq!(mounts[0].fstype, "ext4"); + assert_eq!(mounts[0].source, "/dev/sda2"); + assert_eq!(mounts[1].source, "/dev/sda3"); + } + + #[test] + fn root_is_the_last_mount_over_slash() { + let shadowed = "\ +28 1 8:2 / / rw,relatime shared:1 - ext4 /dev/sda2 rw +99 1 253:1 / / rw,relatime shared:1 - ext4 /dev/mapper/vg-root rw"; + let mounts = parse_mountinfo(shadowed); + assert_eq!(root_mount(&mounts).unwrap().devno, "253:1"); + assert!(root_mount(&parse_mountinfo("")).is_none()); + } + + #[test] + fn mount_points_are_unescaped() { + let escaped = "28 1 8:2 / /mnt/my\\040disk rw,relatime - ext4 /dev/sdb1 rw"; + assert_eq!(parse_mountinfo(escaped)[0].mount_point, "/mnt/my disk"); + + // Escapes encode UTF-8 bytes, and unrecognized sequences are kept. + assert_eq!(unescape_mount_path("/mnt/caf\\303\\251"), "/mnt/café"); + assert_eq!(unescape_mount_path("/mnt/a\\b\\12"), "/mnt/a\\b\\12"); + } + + #[test] + fn fixed_volume_filter_keeps_root_and_data_mounts() { + let mounts = parse_mountinfo(ROOT_ON_LUKS); + let fixed: Vec<&str> = mounts + .iter() + .filter(|m| is_fixed_volume_mount(m)) + .map(|m| m.mount_point.as_str()) + .collect(); + assert_eq!(fixed, vec!["/", "/data"]); + } + + /// Minimal sysfs model with `dev/block` and `slaves` represented by relative + /// symlinks. + struct SysfsFixture { + dir: tempfile::TempDir, + dev_block: std::path::PathBuf, + fs_btrfs: std::path::PathBuf, + } + + impl SysfsFixture { + fn new() -> SysfsFixture { + let dir = tempfile::tempdir().unwrap(); + let dev_block = dir.path().join("dev").join("block"); + let fs_btrfs = dir.path().join("fs").join("btrfs"); + fs::create_dir_all(dir.path().join("devices")).unwrap(); + fs::create_dir_all(&dev_block).unwrap(); + fs::create_dir_all(&fs_btrfs).unwrap(); + SysfsFixture { + dir, + dev_block, + fs_btrfs, + } + } + + fn root(&self) -> &Path { + self.dir.path() + } + + fn dev_block(&self) -> &Path { + &self.dev_block + } + + fn paths(&self) -> Sysfs<'_> { + Sysfs { + dev_block: &self.dev_block, + fs_btrfs: &self.fs_btrfs, + } + } + + /// Registers a btrfs filesystem spanning `devices`, as the + /// `/sys/fs/btrfs//devices/` links do. + fn btrfs(&self, fsid: &str, devices: &[&Path]) { + let dir = self.fs_btrfs.join(fsid).join("devices"); + fs::create_dir_all(&dir).unwrap(); + for device in devices { + let name = device.file_name().unwrap(); + symlink(relative(&dir, device), dir.join(name)).unwrap(); + } + } + + fn disk(&self, name: &str, devno: Option<&str>) -> std::path::PathBuf { + self.make_device(self.root().join("devices").join(name), devno) + } + + fn partition(&self, disk: &str, name: &str, devno: &str) -> std::path::PathBuf { + let parent = self.root().join("devices").join(disk); + fs::create_dir_all(&parent).unwrap(); + self.make_device(parent.join(name), Some(devno)) + } + + fn mapper(&self, name: &str, devno: Option<&str>, uuid: &str) -> std::path::PathBuf { + let dir = self.make_device(self.root().join("devices").join(name), devno); + fs::create_dir_all(dir.join("dm")).unwrap(); + fs::write(dir.join("dm").join("uuid"), uuid).unwrap(); + dir + } + + fn make_device(&self, dir: std::path::PathBuf, devno: Option<&str>) -> std::path::PathBuf { + fs::create_dir_all(&dir).unwrap(); + if let Some(devno) = devno { + fs::write(dir.join("dev"), format!("{}\n", devno)).unwrap(); + let link = self.dev_block().join(devno); + symlink(relative(self.dev_block(), &dir), link).unwrap(); + } + dir + } + + fn slave(&self, dir: &Path, target: &Path) { + let slaves = dir.join("slaves"); + fs::create_dir_all(&slaves).unwrap(); + let name = target.file_name().unwrap(); + symlink(relative(&slaves, target), slaves.join(name)).unwrap(); + } + + fn dangling_slave(&self, dir: &Path, name: &str) { + let slaves = dir.join("slaves"); + fs::create_dir_all(&slaves).unwrap(); + symlink("../../gone", slaves.join(name)).unwrap(); + } + } + + fn relative(from: &Path, to: &Path) -> std::path::PathBuf { + let from: Vec<_> = from.components().collect(); + let to: Vec<_> = to.components().collect(); + let shared = from.iter().zip(&to).take_while(|(a, b)| a == b).count(); + + let mut rel = std::path::PathBuf::new(); + for _ in shared..from.len() { + rel.push(".."); + } + for component in &to[shared..] { + rel.push(component.as_os_str()); + } + rel + } + + #[test] + fn plain_root_partition_is_unencrypted() { + let sysfs = SysfsFixture::new(); + sysfs.partition("sda", "sda2", "8:2"); + + let mountinfo = "28 1 8:2 / / rw,relatime - ext4 /dev/sda2 rw"; + let result = detect(&sysfs, mountinfo); + assert_eq!(result.status.as_str(), "disabled"); + assert_eq!(result.method, Some("dm-crypt")); + } + + #[test] + fn root_directly_on_dm_crypt_is_encrypted() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + let mountinfo = "28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "full"); + } + + #[test] + fn lvm_on_luks_root_is_encrypted_through_the_slaves_chain() { + let sysfs = SysfsFixture::new(); + let lv = sysfs.mapper("dm-1", Some("253:1"), "LVM-abcdef-root\n"); + let crypt = sysfs.mapper("dm-0", None, "CRYPT-LUKS2-9a7b-luks\n"); + sysfs.slave(&lv, &crypt); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + let mountinfo = "28 1 253:1 / / rw,relatime - ext4 /dev/mapper/vg-root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "full"); + } + + #[test] + fn a_verity_protected_root_is_not_encrypted() { + let sysfs = SysfsFixture::new(); + // dm-verity shares the CRYPT- uuid prefix but stores plaintext. + let verity = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-VERITY-4f1c-root\n"); + sysfs.slave(&verity, &sysfs.partition("sda", "sda2", "8:2")); + + let mountinfo = "28 1 253:0 / / ro,relatime - ext4 /dev/mapper/root ro"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "disabled"); + } + + #[test] + fn integrity_only_devices_defer_to_the_storage_beneath_them() { + let sysfs = SysfsFixture::new(); + let integrity = sysfs.mapper("dm-1", Some("253:1"), "CRYPT-INTEGRITY-root\n"); + let crypt = sysfs.mapper("dm-0", None, "CRYPT-LUKS2-9a7b-luks\n"); + sysfs.slave(&integrity, &crypt); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + let mountinfo = "28 1 253:1 / / rw,relatime - ext4 /dev/mapper/root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "full"); + } + + #[test] + fn plaintext_storage_makes_the_stack_unencrypted() { + let sysfs = SysfsFixture::new(); + let lv = sysfs.mapper("dm-1", Some("253:1"), "LVM-abcdef-root\n"); + let crypt = sysfs.mapper("dm-0", None, "CRYPT-LUKS2-9a7b-luks\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.slave(&lv, &crypt); + sysfs.slave(&lv, &sysfs.partition("sdb", "sdb1", "8:17")); + + let mountinfo = "28 1 253:1 / / rw,relatime - ext4 /dev/mapper/vg-root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "disabled"); + } + + #[test] + fn a_dangling_slave_makes_the_volume_unknown() { + let sysfs = SysfsFixture::new(); + let lv = sysfs.mapper("dm-1", Some("253:1"), "LVM-abcdef-root\n"); + sysfs.dangling_slave(&lv, "sdb1"); + + let mountinfo = "28 1 253:1 / / rw,relatime - ext4 /dev/mapper/vg-root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "unknown"); + } + + #[test] + fn plaintext_beneath_a_leg_outranks_an_unwalkable_one() { + let sysfs = SysfsFixture::new(); + let lv = sysfs.mapper("dm-1", Some("253:1"), "LVM-abcdef-root\n"); + sysfs.slave(&lv, &sysfs.partition("sdb", "sdb1", "8:17")); + sysfs.dangling_slave(&lv, "sdc1"); + + let mountinfo = "28 1 253:1 / / rw,relatime - ext4 /dev/mapper/vg-root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "disabled"); + } + + #[test] + fn unknown_when_the_root_device_is_absent_from_sysfs() { + let sysfs = SysfsFixture::new(); + + let mountinfo = "28 1 8:2 / / rw,relatime - ext4 /dev/sda2 rw"; + let result = detect(&sysfs, mountinfo); + assert_eq!(result.status.as_str(), "unknown"); + assert_eq!(result.method, None); + + assert_eq!(detect(&sysfs, "").status.as_str(), "unknown"); + } + + #[test] + fn a_loop_backed_root_is_unknown() { + let sysfs = SysfsFixture::new(); + // A snap-confined browser sees the base snap's squashfs as its root. + let loop_dev = sysfs.disk("loop9", Some("7:9")); + fs::create_dir_all(loop_dev.join("loop")).unwrap(); + fs::create_dir_all(loop_dev.join("slaves")).unwrap(); + + let mountinfo = "28 1 7:9 / / ro,nodev,relatime - squashfs /dev/loop9 ro"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "unknown"); + } + + const BTRFS_ROOT: &str = "28 1 0:35 / / rw,relatime - btrfs /dev/mapper/root rw"; + + #[test] + fn a_btrfs_root_is_resolved_through_its_mount_source() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("root", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.btrfs("4f1c-abcd", &[&crypt]); + + let result = detect(&sysfs, BTRFS_ROOT); + assert_eq!(result.status.as_str(), "full"); + assert_eq!(result.method, Some("dm-crypt")); + } + + #[test] + fn a_btrfs_root_missing_from_sysfs_falls_back_to_the_named_device() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("root", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + // Kernels too old to list devices leave /sys/fs/btrfs empty. + assert_eq!(detect(&sysfs, BTRFS_ROOT).status.as_str(), "full"); + } + + #[test] + fn every_device_of_a_multi_device_btrfs_is_inspected() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("root", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + let plain = sysfs.partition("sdb", "sdb1", "8:17"); + sysfs.btrfs("4f1c-abcd", &[&crypt, &plain]); + + // The mount source names the encrypted half of the mirror only. + assert_eq!(detect(&sysfs, BTRFS_ROOT).status.as_str(), "disabled"); + } + + #[test] + fn an_unencrypted_btrfs_data_volume_makes_the_machine_partial() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + let data = sysfs.partition("sdb", "sdb1", "8:17"); + sysfs.btrfs("9a7b-ef01", &[&data]); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +45 28 0:36 / /data rw,relatime - btrfs /dev/sdb1 rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "partial"); + } + + #[test] + fn a_btrfs_data_volume_with_an_unreadable_device_list_is_enabled() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + // The member is listed but its dev file cannot be read. + let data = sysfs.disk("sdb1", None); + sysfs.btrfs("9a7b-ef01", &[&data]); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +45 28 0:36 / /data rw,relatime - btrfs /dev/sdb1 rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "enabled"); + } + + #[test] + fn subvolume_mounts_of_one_btrfs_are_inspected_once() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("root", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.btrfs("4f1c-abcd", &[&crypt]); + + let mountinfo = "\ +28 1 0:35 /@ / rw,relatime - btrfs /dev/mapper/root rw +45 28 0:35 /@home /home rw,relatime - btrfs /dev/mapper/root rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "full"); + } + + const ZFS_ROOT: &str = "28 1 0:40 / / rw,relatime - zfs rpool/ROOT/default rw"; + + fn zfs_tables(encryption: &[(&str, &str)], vdevs: &[(&str, &[&str])]) -> ZfsTables { + ZfsTables { + encryption: encryption + .iter() + .map(|(name, value)| (name.to_string(), value.to_string())) + .collect(), + vdevs: vdevs + .iter() + .map(|(pool, paths)| { + ( + pool.to_string(), + paths.iter().map(|p| p.to_string()).collect(), + ) + }) + .collect(), + } + } + + #[test] + fn a_natively_encrypted_zfs_root_reports_zfs_as_the_method() { + let sysfs = SysfsFixture::new(); + let zfs = zfs_tables(&[("rpool/ROOT/default", "aes-256-gcm")], &[]); + + let result = detect_with(&sysfs, ZFS_ROOT, &[], &zfs); + assert_eq!(result.status.as_str(), "full"); + assert_eq!(result.method, Some("zfs")); + } + + #[test] + fn a_zfs_pool_on_luks_is_encrypted_through_its_vdevs() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-pool\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + let zfs = zfs_tables( + &[("rpool/ROOT/default", "off")], + &[("rpool", &["/dev/mapper/dm-0"])], + ); + + let result = detect_with(&sysfs, ZFS_ROOT, &[], &zfs); + assert_eq!(result.status.as_str(), "full"); + assert_eq!(result.method, Some("dm-crypt")); + } + + #[test] + fn a_plaintext_vdev_makes_the_zfs_pool_unencrypted() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-pool\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sdb", "sdb1", "8:17"); + let zfs = zfs_tables( + &[("rpool/ROOT/default", "off")], + &[("rpool", &["/dev/mapper/dm-0", "/dev/sdb1"])], + ); + + assert_eq!( + detect_with(&sysfs, ZFS_ROOT, &[], &zfs).status.as_str(), + "disabled" + ); + } + + #[test] + fn a_zfs_root_is_unknown_without_tool_output() { + let sysfs = SysfsFixture::new(); + + assert_eq!( + detect_with(&sysfs, ZFS_ROOT, &[], &ZfsTables::default()) + .status + .as_str(), + "unknown" + ); + } + + #[test] + fn zfs_properties_are_read_from_the_tab_separated_columns() { + let output = "rpool\toff\nrpool/ROOT\taes-256-gcm\nrpool/home\t-\n"; + let encryption = parse_zfs_encryption(output); + assert_eq!(encryption["rpool"], "off"); + assert_eq!(encryption["rpool/ROOT"], "aes-256-gcm"); + assert_eq!(encryption["rpool/home"], "-"); + assert!(parse_zfs_encryption("").is_empty()); + } + + #[test] + fn zpool_vdev_paths_are_grouped_by_pool() { + let output = "\ + pool: rpool + state: ONLINE +config: + +\tNAME STATE READ WRITE CKSUM +\trpool ONLINE 0 0 0 +\t mirror-0 ONLINE 0 0 0 +\t /dev/mapper/luks-root ONLINE 0 0 0 +\t /dev/disk/by-id/x1 ONLINE 0 0 0 + +errors: No known data errors + + pool: tank + state: ONLINE +config: + +\tNAME STATE READ WRITE CKSUM +\ttank ONLINE 0 0 0 +\t /dev/sdc1 ONLINE 0 0 0 +\tcache +\t /dev/sdd1 ONLINE 0 0 0 +\tspares +\t /dev/sde1 AVAIL + +errors: No known data errors"; + + let vdevs = parse_zpool_status(output); + assert_eq!( + vdevs["rpool"], + ["/dev/mapper/luks-root", "/dev/disk/by-id/x1"] + ); + assert_eq!(vdevs["tank"], ["/dev/sdc1", "/dev/sdd1"]); + assert!(parse_zpool_status("no pools available").is_empty()); + } + + #[test] + fn a_second_unencrypted_disk_makes_the_machine_partial() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sdb", "sdb1", "8:17"); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +45 28 8:17 / /data rw,relatime - ext4 /dev/sdb1 rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "partial"); + } + + #[test] + fn boot_partitions_are_ignored() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sda", "sda1", "8:1"); + sysfs.partition("sda", "sda2", "8:2"); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +31 28 8:2 / /boot rw,relatime - ext2 /dev/sda2 rw +32 28 8:1 / /boot/efi rw,relatime - vfat /dev/sda1 rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "full"); + } + + #[test] + fn evidence_gathered_before_the_deadline_is_kept() { + let sysfs = SysfsFixture::new(); + let root = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&root, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sdb", "sdb1", "8:17"); + let home = sysfs.mapper("dm-1", Some("253:1"), "CRYPT-PLAIN-home\n"); + sysfs.slave(&home, &sysfs.partition("sdc", "sdc1", "8:33")); + sysfs.partition("sdd", "sdd1", "8:49"); + + // Attesting the root and then /home takes 400ms in total, so plaintext + // /data is inspected but /srv is never reached. + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +45 28 8:17 / /data rw,relatime - ext4 /dev/sdb1 rw +46 28 253:1 / /home rw,relatime - ext4 /dev/mapper/home rw +47 28 8:49 / /srv rw,relatime - ext4 /dev/sdd1 rw"; + let deadline = Instant::now() + Duration::from_millis(300); + let slow_attestation = |_: &Path| { + std::thread::sleep(Duration::from_millis(200)); + Some(true) + }; + let result = detect_at( + &sysfs.paths(), + mountinfo, + &[], + &ZfsTables::default(), + deadline, + &slow_attestation, + ); + assert_eq!(result.status.as_str(), "partial"); + + let expired = Instant::now() - Duration::from_secs(1); + let result = detect_at( + &sysfs.paths(), + mountinfo, + &[], + &ZfsTables::default(), + expired, + &|_| Some(true), + ); + assert_eq!(result.status.as_str(), "enabled"); + } + + #[test] + fn only_the_boot_mount_points_themselves_are_skipped() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sda", "sda2", "8:2"); + sysfs.partition("sdb", "sdb1", "8:17"); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +31 28 8:2 / /boot rw,relatime - ext2 /dev/sda2 rw +45 28 8:17 / /boot/data rw,relatime - ext4 /dev/sdb1 rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "partial"); + } + + #[test] + fn swap_areas_are_read_from_the_first_column() { + let swaps = "\ +Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority +/dev/sda4 partition\t8388604\t\t0\t\t-2 +/swap\\040file file\t\t2097148\t\t0\t\t-3"; + assert_eq!(parse_swaps(swaps), ["/dev/sda4", "/swap file"]); + assert!(parse_swaps("").is_empty()); + assert!(parse_swaps("Filename\tType\tSize\tUsed\tPriority\n").is_empty()); + } + + #[test] + fn swap_paths_are_resolved_to_the_device_holding_them() { + let dir = tempfile::tempdir().unwrap(); + let swapfile = dir.path().join("swapfile"); + fs::write(&swapfile, b"stand-in for a swap file").unwrap(); + + let swaps = format!( + "Filename\t\t\t\tType\t\tSize\t\tUsed\t\tPriority\n\ + {}\tfile\t2097148\t0\t-3\n\ + /var/gone\tfile\t2097148\t0\t-4", + swapfile.display() + ); + + // Swap files use the containing filesystem's device number. + let areas = swap_areas(&swaps); + assert!( + matches!(&areas[0], SwapArea::Device(devno) if *devno == devno_of(dir.path()).unwrap()) + ); + assert!(matches!(areas[1], SwapArea::Unresolved)); + } + + #[test] + fn an_unresolvable_swap_area_makes_the_machine_enabled() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + let mountinfo = "28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw"; + assert_eq!( + detect_with( + &sysfs, + mountinfo, + &[SwapArea::Unresolved], + &ZfsTables::default() + ) + .status + .as_str(), + "enabled" + ); + } + + #[test] + fn dev_t_halves_are_recovered_from_both_bit_ranges() { + // Match glibc's gnu_dev_makedev encoding. + let makedev = |major: u64, minor: u64| { + ((major & 0xfff) << 8) + | (minor & 0xff) + | ((major & !0xfff) << 32) + | ((minor & !0xff) << 12) + }; + + for (major, minor) in [(8u64, 17u64), (253, 0), (0, 35), (259, 300), (4095, 255)] { + let dev = makedev(major, minor); + assert_eq!( + (dev_major(dev) as u64, dev_minor(dev) as u64), + (major, minor) + ); + } + } + + #[test] + fn an_unencrypted_swap_partition_makes_the_machine_partial() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sda", "sda4", "8:4"); + + let mountinfo = "28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw"; + assert_eq!( + detect_with(&sysfs, mountinfo, &swap_on("8:4"), &ZfsTables::default()) + .status + .as_str(), + "partial" + ); + } + + #[test] + fn zram_swap_is_ignored() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.disk("zram0", Some("252:0")); + + let mountinfo = "28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw"; + assert_eq!( + detect_with(&sysfs, mountinfo, &swap_on("252:0"), &ZfsTables::default()) + .status + .as_str(), + "full" + ); + } + + #[test] + fn swap_on_the_root_volume_is_deduplicated() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + // A swap file on the encrypted root reports the root's own devno. + let mountinfo = "28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw"; + assert_eq!( + detect_with(&sysfs, mountinfo, &swap_on("253:0"), &ZfsTables::default()) + .status + .as_str(), + "full" + ); + } + + #[test] + fn a_sweep_that_runs_out_of_time_preserves_enabled_status() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + sysfs.partition("sdb", "sdb1", "8:17"); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +45 28 8:17 / /data rw,relatime - ext4 /dev/sdb1 rw"; + assert_eq!( + detect_at( + &sysfs.paths(), + mountinfo, + &[], + &ZfsTables::default(), + Instant::now(), + &|_| Some(true), + ) + .status + .as_str(), + "enabled" + ); + } + + #[test] + fn removable_media_is_ignored() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + // The flag lives on the whole disk, not the mounted partition. + sysfs.partition("sdb", "sdb1", "8:17"); + fs::write( + sysfs.root().join("devices").join("sdb").join("removable"), + "1\n", + ) + .unwrap(); + + let mountinfo = "\ +28 1 253:0 / / rw,relatime - ext4 /dev/mapper/root rw +45 28 8:17 / /media/usb rw,relatime - vfat /dev/sdb1 rw"; + assert_eq!(detect(&sysfs, mountinfo).status.as_str(), "full"); + } + + #[test] + fn dm_crypt_table_rejects_null_ciphers() { + assert_eq!( + parse_dm_crypt_table("0 2097152 crypt aes-xts-plain64 :64:logon:key 0 8:3 4096\n"), + Some(true) + ); + assert_eq!( + parse_dm_crypt_table("0 2097152 crypt cipher_null 00 0 8:3 0\n"), + Some(false) + ); + assert_eq!( + parse_dm_crypt_table("0 2097152 crypt null-ecb 00 0 8:3 0\n"), + Some(false) + ); + assert_eq!( + parse_dm_crypt_table("0 2097152 crypt capi:ecb(cipher_null)-plain64 00 0 8:3 0\n"), + Some(false) + ); + assert_eq!( + parse_dm_crypt_table("0 2097152 crypt nullify-xts-plain64 00 0 8:3 0\n"), + Some(true) + ); + assert_eq!( + parse_dm_crypt_table("0 2097152 linear 8:3 0\n"), + Some(false) + ); + assert_eq!(parse_dm_crypt_table(""), None); + assert_eq!(parse_dm_crypt_table("0 2097152\n"), None); + } + + #[test] + fn plain_dm_crypt_mappings_are_attested() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-PLAIN-secret\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + assert_eq!( + device_state(&sysfs.paths(), "253:0", &|_| Some(true)), + VolumeState::Encrypted + ); + assert_eq!( + device_state(&sysfs.paths(), "253:0", &|_| Some(false)), + VolumeState::Unencrypted + ); + assert_eq!( + device_state(&sysfs.paths(), "253:0", &|_| None), + VolumeState::EncryptedUnverified + ); + } + + #[test] + fn luks_mappings_are_trusted_only_when_the_table_is_unreadable() { + let sysfs = SysfsFixture::new(); + let crypt = sysfs.mapper("dm-0", Some("253:0"), "CRYPT-LUKS2-4f1c-root\n"); + sysfs.slave(&crypt, &sysfs.partition("sda", "sda3", "8:3")); + + assert_eq!( + device_state(&sysfs.paths(), "253:0", &|_| None), + VolumeState::Encrypted + ); + assert_eq!( + device_state(&sysfs.paths(), "253:0", &|_| Some(true)), + VolumeState::Encrypted + ); + assert_eq!( + device_state(&sysfs.paths(), "253:0", &|_| Some(false)), + VolumeState::Unencrypted + ); + } +} diff --git a/toolkit/components/felt/rust/src/disk_encryption_macos.rs b/toolkit/components/felt/rust/src/disk_encryption_macos.rs new file mode 100644 index 0000000000000..beaf8456162eb --- /dev/null +++ b/toolkit/components/felt/rust/src/disk_encryption_macos.rs @@ -0,0 +1,323 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use log::trace; +use std::io::Cursor; +use std::time::Instant; + +use crate::disk_encryption::{summarize, DiskEncryption, VolumeState}; +use crate::process::{budget_until, run_command_within}; + +const FDESETUP: &str = "/usr/bin/fdesetup"; +const DISKUTIL: &str = "/usr/sbin/diskutil"; + +// Ignore auxiliary boot-container volumes, which may be intentionally unencrypted. +const SYSTEM_VOLUMES_PREFIX: &str = "/System/Volumes/"; + +// Mounted physical volumes use /dev/disk*, regardless of mount point. +const DISK_DEVICE_PREFIX: &str = "/dev/disk"; + +pub fn detect(deadline: Instant) -> DiskEncryption { + let boot = boot_volume_state(deadline); + // Secondary volumes affect only an encrypted boot volume. + let others = if boot == VolumeState::Encrypted { + other_fixed_volume_states(deadline) + } else { + Some(Vec::new()) + }; + summarize(boot, others.as_deref(), "filevault") +} + +fn boot_volume_state(deadline: Instant) -> VolumeState { + let Some(output) = + budget_until(deadline).and_then(|budget| run_command_within(FDESETUP, &["status"], budget)) + else { + trace!("DiskEncryption: fdesetup did not complete"); + return VolumeState::Unknown; + }; + if !output.status.success() { + trace!("DiskEncryption: fdesetup failed"); + return VolumeState::Unknown; + } + parse_fdesetup_status(&String::from_utf8_lossy(&output.stdout)) +} + +/// Parses `fdesetup status`. Conversion takes precedence over on/off, and +/// deferred enablement remains unencrypted until restart. +pub(crate) fn parse_fdesetup_status(output: &str) -> VolumeState { + let lowered = output.to_ascii_lowercase(); + if lowered.contains("encryption in progress") || lowered.contains("decryption in progress") { + return VolumeState::Converting; + } + if lowered.contains("filevault is on") { + return VolumeState::Encrypted; + } + if lowered.contains("filevault is off") { + return VolumeState::Unencrypted; + } + trace!("DiskEncryption: unrecognized fdesetup output"); + VolumeState::Unknown +} + +/// Returns `None` if the mount table is unavailable. Volumes left when the +/// deadline expires count as unknown, so evidence gathered before then is kept. +fn other_fixed_volume_states(deadline: Instant) -> Option> { + let devices = mounted_filesystems()? + .into_iter() + .filter(|(device, mount_point)| is_other_volume_mount(device, mount_point)) + .map(|(device, _)| device); + + let mut states = Vec::new(); + for device in devices { + let Some(budget) = budget_until(deadline) else { + trace!("DiskEncryption: ran out of time inspecting volumes"); + states.push(VolumeState::Unknown); + break; + }; + if let Some(state) = volume_state(&device, budget) { + states.push(state); + } + } + Some(states) +} + +/// Selects mounted disk volumes other than boot-container members. +fn is_other_volume_mount(device: &str, mount_point: &str) -> bool { + device.starts_with(DISK_DEVICE_PREFIX) + && mount_point != "/" + && !mount_point.starts_with(SYSTEM_VOLUMES_PREFIX) +} + +/// Reads mounted filesystems with `getfsstat(MNT_NOWAIT)`, avoiding +/// `getmntinfo`'s process-wide buffer and filesystem-stat refreshes. +fn mounted_filesystems() -> Option> { + let count = unsafe { libc::getfsstat(std::ptr::null_mut(), 0, libc::MNT_NOWAIT) }; + if count < 0 { + trace!("DiskEncryption: getfsstat failed"); + return None; + } + + let mut buf: Vec = vec![unsafe { std::mem::zeroed() }; count as usize]; + let size = std::mem::size_of_val(buf.as_slice()) as libc::c_int; + let written = unsafe { libc::getfsstat(buf.as_mut_ptr(), size, libc::MNT_NOWAIT) }; + if written < 0 { + trace!("DiskEncryption: getfsstat failed"); + return None; + } + + // Ignore filesystems mounted after sizing the snapshot. + buf.truncate((written as usize).min(count as usize)); + Some( + buf.iter() + .map(|fs| { + ( + c_array_to_string(&fs.f_mntfromname), + c_array_to_string(&fs.f_mntonname), + ) + }) + .collect(), + ) +} + +fn c_array_to_string(field: &[libc::c_char]) -> String { + let bytes: Vec = field + .iter() + .take_while(|&&c| c != 0) + .map(|&c| c as u8) + .collect(); + String::from_utf8_lossy(&bytes).into_owned() +} + +/// Returns `None` for non-internal or removable media. +fn volume_state(device: &str, budget: std::time::Duration) -> Option { + let Some(output) = run_command_within(DISKUTIL, &["info", "-plist", device], budget) else { + trace!("DiskEncryption: diskutil did not complete for {}", device); + return Some(VolumeState::Unknown); + }; + if !output.status.success() { + trace!("DiskEncryption: diskutil failed for {}", device); + return Some(VolumeState::Unknown); + } + parse_diskutil_info(&output.stdout) +} + +pub(crate) fn parse_diskutil_info(plist: &[u8]) -> Option { + let Ok(value) = plist::Value::from_reader_xml(Cursor::new(plist)) else { + trace!("DiskEncryption: could not parse diskutil output"); + return Some(VolumeState::Unknown); + }; + let Some(info) = value.as_dictionary() else { + return Some(VolumeState::Unknown); + }; + let flag = |key: &str| info.get(key).and_then(plist::Value::as_boolean); + + let internal = flag("Internal"); + let removable = flag("RemovableMedia"); + if internal == Some(false) || removable == Some(true) { + return None; + } + // Treat missing classification as unknown. + if internal.is_none() || removable.is_none() { + return Some(VolumeState::Unknown); + } + Some(match flag("Encryption") { + Some(true) => VolumeState::Encrypted, + Some(false) => VolumeState::Unencrypted, + None => VolumeState::Unknown, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fdesetup_states() { + assert_eq!( + parse_fdesetup_status("FileVault is On.\n"), + VolumeState::Encrypted + ); + assert_eq!( + parse_fdesetup_status("FileVault is Off.\n"), + VolumeState::Unencrypted + ); + assert_eq!( + parse_fdesetup_status( + "FileVault is Off, but will be enabled after the next restart.\n" + ), + VolumeState::Unencrypted + ); + assert_eq!( + parse_fdesetup_status( + "FileVault is On.\nEncryption in progress: Percent completed = 12\n" + ), + VolumeState::Converting + ); + assert_eq!( + parse_fdesetup_status("Decryption in progress: Percent completed = 42\n"), + VolumeState::Converting + ); + assert_eq!(parse_fdesetup_status(""), VolumeState::Unknown); + assert_eq!( + parse_fdesetup_status("Error: unable to determine status."), + VolumeState::Unknown + ); + } + + #[test] + fn volumes_are_taken_from_any_mount_point_on_a_disk() { + // Include fstab mounts outside /Volumes. + assert!(is_other_volume_mount("/dev/disk4s1", "/srv/data")); + assert!(is_other_volume_mount("/dev/disk4s1", "/Volumes/Data")); + + // Exclude the boot volume and its auxiliary container volumes. + assert!(!is_other_volume_mount("/dev/disk3s1s1", "/")); + assert!(!is_other_volume_mount( + "/dev/disk3s5", + "/System/Volumes/Data" + )); + assert!(!is_other_volume_mount( + "/dev/disk3s2", + "/System/Volumes/Preboot" + )); + + // Exclude synthetic roots, network shares and virtual filesystems. + assert!(!is_other_volume_mount("map -hosts", "/net")); + assert!(!is_other_volume_mount("//user@nas/share", "/Volumes/share")); + assert!(!is_other_volume_mount("devfs", "/dev")); + } + + /// Representative `diskutil info -plist` output for an encrypted internal + /// APFS volume. + const REAL_DISKUTIL_INFO: &str = r#" + + + + AESHardware + + APFSPhysicalStores + + + APFSPhysicalStore + disk0s2 + + + DeviceNode + /dev/disk3s1s1 + Encryption + + EncryptionThisVolumeProper + + Internal + + RemovableMedia + + VolumeName + Macintosh HD + + +"#; + + fn diskutil_info(internal: &str, removable: &str, encryption: &str) -> String { + format!( + "\ + Encryption{}\ + Internal{}\ + RemovableMedia{}\ + ", + encryption, internal, removable + ) + } + + #[test] + fn real_diskutil_output_is_understood() { + assert_eq!( + parse_diskutil_info(REAL_DISKUTIL_INFO.as_bytes()), + Some(VolumeState::Encrypted) + ); + } + + #[test] + fn diskutil_info_reports_fixed_internal_volumes() { + assert_eq!( + parse_diskutil_info(diskutil_info("", "", "").as_bytes()), + Some(VolumeState::Encrypted) + ); + assert_eq!( + parse_diskutil_info(diskutil_info("", "", "").as_bytes()), + Some(VolumeState::Unencrypted) + ); + assert_eq!( + parse_diskutil_info( + diskutil_info("", "", "?").as_bytes() + ), + Some(VolumeState::Unknown) + ); + } + + #[test] + fn diskutil_info_filters_external_and_removable_volumes() { + assert_eq!( + parse_diskutil_info(diskutil_info("", "", "").as_bytes()), + None + ); + assert_eq!( + parse_diskutil_info(diskutil_info("", "", "").as_bytes()), + None + ); + } + + #[test] + fn unparseable_diskutil_output_is_unknown() { + assert_eq!(parse_diskutil_info(b""), Some(VolumeState::Unknown)); + assert_eq!( + parse_diskutil_info(b"Truncat"), + Some(VolumeState::Unknown) + ); + assert_eq!( + parse_diskutil_info(b""), + Some(VolumeState::Unknown) + ); + } +} diff --git a/toolkit/components/felt/rust/src/disk_encryption_win.rs b/toolkit/components/felt/rust/src/disk_encryption_win.rs new file mode 100644 index 0000000000000..5ab2951147af5 --- /dev/null +++ b/toolkit/components/felt/rust/src/disk_encryption_win.rs @@ -0,0 +1,276 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use log::trace; +use std::time::Instant; + +use winapi::shared::minwindef::MAX_PATH; +use winapi::shared::winerror::{ERROR_MORE_DATA, ERROR_NO_MORE_FILES}; +use winapi::um::errhandlingapi::GetLastError; +use winapi::um::fileapi::{ + FindFirstVolumeW, FindNextVolumeW, FindVolumeClose, GetDriveTypeW, + GetVolumeNameForVolumeMountPointW, GetVolumePathNamesForVolumeNameW, +}; +use winapi::um::handleapi::INVALID_HANDLE_VALUE; +use winapi::um::sysinfoapi::GetWindowsDirectoryW; +use winapi::um::winbase::DRIVE_FIXED; + +use crate::disk_encryption::{summarize, DiskEncryption, VolumeState}; + +extern "C" { + // Reads Explorer's unprivileged BitLocker property; see FeltDiskEncryptionWin.cpp. + fn felt_read_bitlocker_protection(root: *const u16, out_value: *mut i32) -> bool; +} + +// A volume GUID path, "\\?\Volume{...}\", is 49 characters plus a terminator, +// so the MAX_PATH + 1 buffer the documentation suggests is not needed. +const VOLUME_NAME_LEN: usize = 64; + +/// A fixed volume identified by GUID, with one path used to query it. +struct Volume { + guid_path: String, + mount_path: String, +} + +/// The mounted fixed volumes. `complete` is false when a volume could not be +/// examined and may be missing from `list`. +struct Volumes { + list: Vec, + complete: bool, +} + +/// Shell property reads are synchronous, so the deadline is checked between calls. +pub fn detect(deadline: Instant) -> DiskEncryption { + if Instant::now() >= deadline { + return DiskEncryption::unknown(); + } + let Some(volumes) = fixed_volumes() else { + trace!("DiskEncryption: could not enumerate volumes"); + return DiskEncryption::unknown(); + }; + let Some(boot_guid) = boot_volume_guid_path() else { + trace!("DiskEncryption: could not determine the boot volume"); + return DiskEncryption::unknown(); + }; + + let Some(boot_volume) = volumes + .list + .iter() + .find(|volume| volume.guid_path.eq_ignore_ascii_case(&boot_guid)) + else { + trace!("DiskEncryption: the boot volume is not among the fixed volumes"); + return DiskEncryption::unknown(); + }; + + if Instant::now() >= deadline { + trace!("DiskEncryption: deadline expired before checking the boot volume"); + return DiskEncryption::unknown(); + } + let boot = volume_state(&boot_volume.mount_path); + // Secondary volumes affect only an encrypted boot volume. + let others = if boot == VolumeState::Encrypted { + let mut states = other_volume_states(&volumes.list, &boot_guid, deadline); + if !volumes.complete { + // A volume that could not be examined counts as unknown. + states.push(VolumeState::Unknown); + } + states + } else { + Vec::new() + }; + + summarize(boot, Some(&others), "bitlocker") +} + +/// Volumes left when the deadline expires count as unknown, so evidence +/// gathered before then is kept. +fn other_volume_states(volumes: &[Volume], boot_guid: &str, deadline: Instant) -> Vec { + let mut states = Vec::new(); + for volume in volumes { + if volume.guid_path.eq_ignore_ascii_case(boot_guid) { + continue; + } + if Instant::now() >= deadline { + trace!("DiskEncryption: deadline expired while checking volumes"); + states.push(VolumeState::Unknown); + break; + } + states.push(volume_state(&volume.mount_path)); + } + states +} + +/// Enumerates mounted fixed volumes, or returns `None` if enumeration cannot +/// start at all. +fn fixed_volumes() -> Option { + let mut name = [0u16; VOLUME_NAME_LEN]; + let handle = unsafe { FindFirstVolumeW(name.as_mut_ptr(), name.len() as u32) }; + if handle == INVALID_HANDLE_VALUE { + return None; + } + + let mut list = Vec::new(); + let mut complete = true; + loop { + let guid_path = from_wide(&name); + match first_mount_path(&guid_path) { + Ok(Some(mount_path)) => { + if is_fixed_drive(&guid_path) { + list.push(Volume { + guid_path, + mount_path, + }); + } + } + // Unmounted fixed volumes are the EFI system and recovery + // partitions, which hold no user data and are never encrypted. + Ok(None) => {} + Err(()) => complete = false, + } + + if unsafe { FindNextVolumeW(handle, name.as_mut_ptr(), name.len() as u32) } == 0 { + // Only ERROR_NO_MORE_FILES means enumeration completed. + complete &= unsafe { GetLastError() } == ERROR_NO_MORE_FILES; + break; + } + } + unsafe { FindVolumeClose(handle) }; + + Some(Volumes { list, complete }) +} + +/// Returns the first mount point, or `Ok(None)` if the volume is unmounted. +fn first_mount_path(guid_path: &str) -> Result, ()> { + let name = to_wide(guid_path); + // The API requires room for the complete MULTI_SZ even though only one path is used. + let mut buf = vec![0u16; MAX_PATH + 1]; + loop { + let mut len = 0u32; + let ok = unsafe { + GetVolumePathNamesForVolumeNameW( + name.as_ptr(), + buf.as_mut_ptr(), + buf.len() as u32, + &mut len, + ) + }; + if ok != 0 { + let first = from_wide(&buf); + return Ok((!first.is_empty()).then_some(first)); + } + if unsafe { GetLastError() } != ERROR_MORE_DATA || len as usize <= buf.len() { + trace!("DiskEncryption: mount-point query failed for {}", guid_path); + return Err(()); + } + buf.resize(len as usize, 0); + } +} + +fn boot_volume_guid_path() -> Option { + let mut buf = [0u16; MAX_PATH + 1]; + let len = unsafe { GetWindowsDirectoryW(buf.as_mut_ptr(), buf.len() as u32) }; + if len == 0 || len as usize > buf.len() { + return None; + } + let root = drive_root(&String::from_utf16_lossy(&buf[..len as usize]))?; + + let mut name = [0u16; VOLUME_NAME_LEN]; + let ok = unsafe { + GetVolumeNameForVolumeMountPointW( + to_wide(&root).as_ptr(), + name.as_mut_ptr(), + name.len() as u32, + ) + }; + (ok != 0).then(|| from_wide(&name)) +} + +/// Returns the root of a drive-letter path. +fn drive_root(path: &str) -> Option { + let mut chars = path.chars(); + let letter = chars.next()?; + if !letter.is_ascii_alphabetic() || chars.next()? != ':' { + return None; + } + Some(format!("{}:\\", letter)) +} + +fn is_fixed_drive(volume_path: &str) -> bool { + unsafe { GetDriveTypeW(to_wide(volume_path).as_ptr()) == DRIVE_FIXED } +} + +fn volume_state(mount_path: &str) -> VolumeState { + let path = to_wide(mount_path); + let mut value: i32 = 0; + if unsafe { felt_read_bitlocker_protection(path.as_ptr(), &mut value) } { + map_bitlocker_value(value) + } else { + trace!("DiskEncryption: no BitLocker property for {}", mount_path); + VolumeState::Unknown + } +} + +/// Maps System.Volume.BitLockerProtection values to volume states. +/// Locked volumes remain encrypted; suspended and pre-provisioned volumes have +/// a clear key and count as unencrypted. +pub(crate) fn map_bitlocker_value(value: i32) -> VolumeState { + match value { + 1 | 6 => VolumeState::Encrypted, + 2 | 5 | 7 | 8 => VolumeState::Unencrypted, + 3 | 4 => VolumeState::Converting, + _ => VolumeState::Unknown, + } +} + +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn from_wide(buf: &[u16]) -> String { + let end = buf.iter().position(|&c| c == 0).unwrap_or(buf.len()); + String::from_utf16_lossy(&buf[..end]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bitlocker_values_map_to_volume_states() { + for (value, state) in [ + (1, VolumeState::Encrypted), // on + (2, VolumeState::Unencrypted), // off + (3, VolumeState::Converting), // encrypting + (4, VolumeState::Converting), // decrypting + (5, VolumeState::Unencrypted), // suspended: clear key on the disk + (6, VolumeState::Encrypted), // on, not unlocked this session + (7, VolumeState::Unencrypted), // off and cannot be turned on + (8, VolumeState::Unencrypted), // waiting for activation: clear key + ] { + assert_eq!(map_bitlocker_value(value), state, "value {}", value); + } + + for undefined in [-1, 0, 9, 42] { + assert_eq!(map_bitlocker_value(undefined), VolumeState::Unknown); + } + } + + #[test] + fn drive_roots_are_reduced_from_paths() { + assert_eq!(drive_root("C:\\Windows"), Some("C:\\".to_string())); + assert_eq!(drive_root("D:\\"), Some("D:\\".to_string())); + assert_eq!(drive_root("\\\\server\\share"), None); + assert_eq!(drive_root(""), None); + } + + #[test] + fn wide_strings_stop_at_the_terminator() { + let mut buf = [0u16; 8]; + for (slot, c) in buf.iter_mut().zip("D:\\\0junk".encode_utf16()) { + *slot = c; + } + assert_eq!(from_wide(&buf), "D:\\"); + assert_eq!(from_wide(&[0u16; 4]), ""); + } +} diff --git a/toolkit/components/felt/rust/src/edr_checker.rs b/toolkit/components/felt/rust/src/edr_checker.rs index 6870aa807a4b6..15dd7b07abd34 100644 --- a/toolkit/components/felt/rust/src/edr_checker.rs +++ b/toolkit/components/felt/rust/src/edr_checker.rs @@ -3,9 +3,7 @@ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ use std::collections::HashMap; -use std::process::Command; use std::sync::Mutex; -use std::thread; use std::time::{Duration, Instant}; use moz_task::{DispatchOptions, Task, TaskRunnable, ThreadPtrHandle, ThreadPtrHolder}; @@ -305,62 +303,6 @@ const CACHE_TTL: Duration = Duration::from_secs(10 * 60); // Guarded by a Mutex because detection runs on a moz_task background thread. static CACHE: Mutex)>> = Mutex::new(None); -// Upper bound on a single external probe (a service-status command, the -// system-extension listing, etc.). A probe that exceeds this is treated as -// "could not determine" so one wedged command cannot stall the whole sweep. -// Kept shorter than the JS-side detection timeout that bounds the caller. -pub(crate) const PROBE_TIMEOUT: Duration = Duration::from_secs(5); - -// How often run_command_bounded re-checks a still-running child for exit. -const PROBE_POLL_INTERVAL: Duration = Duration::from_millis(100); - -/// Runs an external command, waiting up to `PROBE_TIMEOUT` for it to exit. -/// Unlike `Command::output()`, a command that overruns the timeout is killed -/// and reaped rather than left to linger. Returns `None` if the command could -/// not be spawned, was killed for overrunning, or could not be waited on. -/// -/// stdout is read only after the child exits, which assumes the small output of -/// our probes (`sc query`, `systemextensionsctl list`, ...); a child that -/// flooded the pipe would be killed at the timeout instead. -pub(crate) fn run_command_bounded(program: &str, args: &[&str]) -> Option { - use std::io::Read; - use std::process::Stdio; - - let mut child = Command::new(program) - .args(args) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .stdout(Stdio::piped()) - .spawn() - .ok()?; - - let start = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => { - let mut stdout = Vec::new(); - if let Some(mut out) = child.stdout.take() { - let _ = out.read_to_end(&mut stdout); - } - return Some(std::process::Output { - status, - stdout, - stderr: Vec::new(), - }); - } - Ok(None) => { - if start.elapsed() >= PROBE_TIMEOUT { - let _ = child.kill(); - let _ = child.wait(); - return None; - } - thread::sleep(PROBE_POLL_INTERVAL); - } - Err(_) => return None, - } - } -} - // Serializes detection sweeps: the console controls the poll interval, so an // interval shorter than a sweep would otherwise let sweeps stack up, each // spawning its own probes. A concurrent caller blocks here until the running diff --git a/toolkit/components/felt/rust/src/edr_checker_linux.rs b/toolkit/components/felt/rust/src/edr_checker_linux.rs index 77e3fc7f068cb..979607e159366 100644 --- a/toolkit/components/felt/rust/src/edr_checker_linux.rs +++ b/toolkit/components/felt/rust/src/edr_checker_linux.rs @@ -6,7 +6,8 @@ use log::trace; use std::cell::OnceCell; use std::path::Path; -use crate::edr_checker::{run_command_bounded, DetectMethod}; +use crate::edr_checker::DetectMethod; +use crate::process::run_command_bounded; /// A one-time capture of the system state used to evaluate every requested /// agent without re-walking `/proc` per agent/method. The process table is diff --git a/toolkit/components/felt/rust/src/edr_checker_macos.rs b/toolkit/components/felt/rust/src/edr_checker_macos.rs index 9a95a365a144f..5728217eb1e4f 100644 --- a/toolkit/components/felt/rust/src/edr_checker_macos.rs +++ b/toolkit/components/felt/rust/src/edr_checker_macos.rs @@ -5,7 +5,8 @@ use log::trace; use std::cell::OnceCell; -use crate::edr_checker::{run_command_bounded, DetectMethod}; +use crate::edr_checker::DetectMethod; +use crate::process::run_command_bounded; /// A one-time capture of the system state used to evaluate every requested /// agent without re-enumerating processes (or re-running diff --git a/toolkit/components/felt/rust/src/edr_checker_win.rs b/toolkit/components/felt/rust/src/edr_checker_win.rs index 6bd85703ed2d1..7dbb6ab329e05 100644 --- a/toolkit/components/felt/rust/src/edr_checker_win.rs +++ b/toolkit/components/felt/rust/src/edr_checker_win.rs @@ -5,7 +5,8 @@ use log::trace; use std::cell::OnceCell; -use crate::edr_checker::{run_command_bounded, DetectMethod}; +use crate::edr_checker::DetectMethod; +use crate::process::run_command_bounded; /// Lower-cased full paths and executable file names of all running processes. struct ProcessList { diff --git a/toolkit/components/felt/rust/src/lib.rs b/toolkit/components/felt/rust/src/lib.rs index 7c8ca282aa60d..237c92734eac8 100644 --- a/toolkit/components/felt/rust/src/lib.rs +++ b/toolkit/components/felt/rust/src/lib.rs @@ -26,6 +26,14 @@ mod edr_checker_linux; mod edr_checker_macos; #[cfg(target_os = "windows")] mod edr_checker_win; +mod disk_encryption; +#[cfg(target_os = "linux")] +mod disk_encryption_linux; +#[cfg(target_os = "macos")] +mod disk_encryption_macos; +#[cfg(target_os = "windows")] +mod disk_encryption_win; +mod process; mod utils; pub use utils::{CONSOLE_URL, TOKENS}; diff --git a/toolkit/components/felt/rust/src/process.rs b/toolkit/components/felt/rust/src/process.rs new file mode 100644 index 0000000000000..1d0e5cde88049 --- /dev/null +++ b/toolkit/components/felt/rust/src/process.rs @@ -0,0 +1,135 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ + +use std::process::{Command, Output, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +pub(crate) const PROBE_TIMEOUT: Duration = Duration::from_secs(5); + +const PROBE_POLL_INTERVAL: Duration = Duration::from_millis(100); + +pub(crate) fn run_command_bounded(program: &str, args: &[&str]) -> Option { + run_command_within(program, args, PROBE_TIMEOUT) +} + +/// Returns the remaining sweep budget capped at `PROBE_TIMEOUT`, or `None` if +/// expired. +#[cfg(any(target_os = "macos", target_os = "linux"))] +pub(crate) fn budget_until(deadline: Instant) -> Option { + let left = deadline.saturating_duration_since(Instant::now()); + (!left.is_zero()).then(|| left.min(PROBE_TIMEOUT)) +} + +/// Runs a command for at most `budget`, killing and reaping it on timeout. +/// +/// Output is read after the child exits; a child blocked on a full pipe times out. +pub(crate) fn run_command_within(program: &str, args: &[&str], budget: Duration) -> Option { + use std::io::Read; + + let mut command = Command::new(program); + scrub_environment(&mut command); + let mut child = command + .args(args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .stdout(Stdio::piped()) + .spawn() + .ok()?; + + let start = Instant::now(); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let mut stdout = Vec::new(); + if let Some(mut out) = child.stdout.take() { + let _ = out.read_to_end(&mut stdout); + } + return Some(Output { + status, + stdout, + stderr: Vec::new(), + }); + } + Ok(None) => { + if start.elapsed() >= budget { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + thread::sleep(PROBE_POLL_INTERVAL); + } + Err(_) => { + // Dropping a Child neither kills nor reaps it. + let _ = child.kill(); + let _ = child.wait(); + return None; + } + } + } +} + +/// Probe output is trusted, so the child must not inherit variables such as +/// LD_PRELOAD or DYLD_INSERT_LIBRARIES that let the user alter it. Windows +/// system tools need their inherited environment and have no equivalent. +#[cfg(unix)] +fn scrub_environment(command: &mut Command) { + command + .env_clear() + .env("PATH", "/usr/sbin:/usr/bin:/sbin:/bin") + .env("LC_ALL", "C"); +} + +#[cfg(not(unix))] +fn scrub_environment(_command: &mut Command) {} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[test] + fn children_do_not_inherit_the_environment() { + std::env::set_var("FELT_PROBE_LEAK", "inherited"); + let output = run_command_bounded( + "/bin/sh", + &["-c", "echo \"${FELT_PROBE_LEAK-unset}|$LC_ALL\""], + ) + .unwrap(); + assert_eq!(String::from_utf8_lossy(&output.stdout), "unset|C\n"); + } + + #[test] + fn relative_programs_resolve_through_the_fixed_path() { + let output = run_command_bounded("sh", &["-c", "echo ok"]).unwrap(); + assert_eq!(String::from_utf8_lossy(&output.stdout), "ok\n"); + } + + #[test] + fn captures_output() { + let output = run_command_bounded("/bin/echo", &["hello"]).unwrap(); + assert!(output.status.success()); + assert_eq!(String::from_utf8_lossy(&output.stdout), "hello\n"); + } + + #[test] + fn preserves_nonzero_exit_status_and_output() { + let output = run_command_bounded("/bin/sh", &["-c", "echo partial; exit 3"]).unwrap(); + assert!(!output.status.success()); + assert_eq!(output.status.code(), Some(3)); + assert_eq!(String::from_utf8_lossy(&output.stdout), "partial\n"); + } + + #[test] + fn returns_none_when_spawn_fails() { + assert!(run_command_bounded("/nonexistent/felt-probe", &[]).is_none()); + } + + #[test] + fn returns_none_on_timeout() { + let budget = Duration::from_millis(200); + let start = Instant::now(); + assert!(run_command_within("/bin/sleep", &["300"], budget).is_none()); + assert!(start.elapsed() < Duration::from_secs(1)); + } +} diff --git a/toolkit/components/reportbrokensite/ReportBrokenSiteParent.sys.mjs b/toolkit/components/reportbrokensite/ReportBrokenSiteParent.sys.mjs index 91db5940a277b..307e9e4216151 100644 --- a/toolkit/components/reportbrokensite/ReportBrokenSiteParent.sys.mjs +++ b/toolkit/components/reportbrokensite/ReportBrokenSiteParent.sys.mjs @@ -617,10 +617,14 @@ export class ReportBrokenSiteParent extends JSWindowActorParent { #getSecurityInfo(troubleshootingInfo) { const result = {}; - for (const [k, v] of Object.entries(troubleshootingInfo.securitySoftware)) { - result[k.replace("registered", "").toLowerCase()] = v - ? v.split(";") - : null; + for (const key of [ + "registeredAntiVirus", + "registeredAntiSpyware", + "registeredFirewall", + ]) { + const value = troubleshootingInfo.securitySoftware[key]; + result[key.replace("registered", "").toLowerCase()] = + typeof value === "string" && value ? value.split(";") : null; } // Right now, security data is only available for Windows builds, and @@ -684,7 +688,9 @@ export class ReportBrokenSiteParent extends JSWindowActorParent { } async #getBrowserInfo() { - const troubleshootingInfo = await Troubleshoot.snapshot(); + const troubleshootingInfo = await Troubleshoot.snapshot({ + includeEnterpriseSecurity: false, + }); return { addons: this.#getActiveAddons(troubleshootingInfo), app: this.#getAppInfo(troubleshootingInfo), diff --git a/toolkit/content/aboutSupport.js b/toolkit/content/aboutSupport.js index acdc35ee935f1..5475f3f834ca3 100644 --- a/toolkit/content/aboutSupport.js +++ b/toolkit/content/aboutSupport.js @@ -379,7 +379,17 @@ var snapshotFormatters = { $("security-software-edr").textContent = data.presentEdrs.join(", "); } - let hasContent = isWin || hasEdrs; + let diskEncryption = data.diskEncryption; + $("security-software-disk-encryption-row").hidden = !diskEncryption; + if (diskEncryption) { + document.l10n.setAttributes( + $("security-software-disk-encryption"), + `security-software-disk-encryption-${diskEncryption.status}`, + { method: diskEncryption.method ?? "" } + ); + } + + let hasContent = isWin || hasEdrs || !!diskEncryption; $("security-software").hidden = !hasContent; $("security-software-table").hidden = !hasContent; }, diff --git a/toolkit/content/aboutSupport.xhtml b/toolkit/content/aboutSupport.xhtml index a4c4083cb063c..ce4cc91615706 100644 --- a/toolkit/content/aboutSupport.xhtml +++ b/toolkit/content/aboutSupport.xhtml @@ -437,6 +437,13 @@ + + + + + + + diff --git a/toolkit/locales/en-US/toolkit/enterprise/enterprise.ftl b/toolkit/locales/en-US/toolkit/enterprise/enterprise.ftl index 7b1bc430b3301..a07e772c28df8 100644 --- a/toolkit/locales/en-US/toolkit/enterprise/enterprise.ftl +++ b/toolkit/locales/en-US/toolkit/enterprise/enterprise.ftl @@ -12,6 +12,54 @@ app-basics-device-id = Device ID # Endpoint Detection and Response is an industry term and must remain in English. security-software-edr = Endpoint Detection and Response +# Shown in the about:support "Security Software" section on enterprise builds. +security-software-disk-encryption = Disk Encryption + +# $method identifies the platform encryption mechanism. FileVault, BitLocker, +# dm-crypt, and ZFS are product names and should not be translated. +# Variables: +# $method (String): "filevault", "bitlocker", "dm-crypt" or "zfs". +security-software-disk-encryption-full = + { $method -> + [bitlocker] Enabled (BitLocker) + [dm-crypt] Enabled (dm-crypt) + [filevault] Enabled (FileVault) + [zfs] Enabled (ZFS) + *[other] Enabled + } + +# No plaintext volume was found, but at least one relevant volume or encryption +# mapping could not be inspected completely. +# Variables: +# $method (String): "filevault", "bitlocker", "dm-crypt" or "zfs". +security-software-disk-encryption-enabled = + { $method -> + [bitlocker] Enabled (BitLocker); inspection incomplete + [dm-crypt] Enabled (dm-crypt); inspection incomplete + [filevault] Enabled (FileVault); inspection incomplete + [zfs] Enabled (ZFS); inspection incomplete + *[other] Enabled; inspection incomplete + } + +# The boot volume is encrypted, but another mounted fixed volume is not. +# Variables: +# $method (String): "filevault", "bitlocker", "dm-crypt" or "zfs". +security-software-disk-encryption-partial = + { $method -> + [bitlocker] Partial (BitLocker); some mounted fixed volumes are not encrypted + [dm-crypt] Partial (dm-crypt); some mounted fixed volumes are not encrypted + [filevault] Partial (FileVault); some mounted fixed volumes are not encrypted + [zfs] Partial (ZFS); some mounted fixed volumes are not encrypted + *[other] Partial; some mounted fixed volumes are not encrypted + } + +security-software-disk-encryption-disabled = Disabled + +# A volume is currently being encrypted or decrypted. +security-software-disk-encryption-in-progress = Encryption or decryption in progress + +security-software-disk-encryption-unknown = Unknown + enterprise-toolbar-button = .label = { -brand-short-name } .tooltiptext = { -brand-short-name } diff --git a/toolkit/modules/DiskEncryption.sys.mjs b/toolkit/modules/DiskEncryption.sys.mjs new file mode 100644 index 0000000000000..bb403106f148e --- /dev/null +++ b/toolkit/modules/DiskEncryption.sys.mjs @@ -0,0 +1,82 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +const DEFAULT_TIMEOUT_MS = 30000; + +const UNKNOWN = Object.freeze({ status: "unknown", method: null }); +const VALID_STATUSES = new Set([ + "full", + "enabled", + "partial", + "disabled", + "in-progress", +]); +const VALID_METHODS = new Set(["filevault", "bitlocker", "dm-crypt", "zfs"]); + +/** + * @typedef {object} DiskEncryptionStatus + * @property {"full"|"enabled"|"partial"|"disabled"|"in-progress"|"unknown"} status + * Aggregated encryption status. + * @property {"filevault"|"bitlocker"|"dm-crypt"|"zfs"|null} method + * Platform mechanism checked, or null for an unknown status. + */ + +export const DiskEncryption = { + /** + * Returns the machine's disk encryption status, or "unknown" if detection + * fails or times out. + * + * @param {number} [timeoutMs] + * Maximum wait in milliseconds. + * @returns {Promise} + */ + getStatus(timeoutMs = DEFAULT_TIMEOUT_MS) { + return new Promise(resolve => { + let timer = null; + let settled = false; + const finish = result => { + if (settled) { + return; + } + settled = true; + if (timer) { + lazy.clearTimeout(timer); + } + resolve(result); + }; + + timer = lazy.setTimeout(() => { + console.warn("Disk encryption detection timed out; reporting unknown."); + finish(UNKNOWN); + }, timeoutMs); + + try { + Cc["@mozilla.org/enterprise/disk-encryption-checker;1"] + .getService() + .QueryInterface(Ci.nsIDiskEncryptionChecker) + .getDiskEncryption({ + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIDiskEncryptionCheckerCallback, + ]), + onComplete(status, method) { + if (!VALID_STATUSES.has(status) || !VALID_METHODS.has(method)) { + finish(UNKNOWN); + return; + } + finish({ status, method }); + }, + }); + } catch (e) { + finish(UNKNOWN); + } + }); + }, +}; diff --git a/toolkit/modules/Troubleshoot.sys.mjs b/toolkit/modules/Troubleshoot.sys.mjs index f50791862f683..3fc2ac12c952b 100644 --- a/toolkit/modules/Troubleshoot.sys.mjs +++ b/toolkit/modules/Troubleshoot.sys.mjs @@ -8,6 +8,7 @@ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { + DiskEncryption: "resource://gre/modules/enterprise/DiskEncryption.sys.mjs", EdrDetection: "resource://gre/modules/enterprise/EdrDetection.sys.mjs", MachineId: "resource://gre/modules/enterprise/MachineId.sys.mjs", PlacesDBUtils: "resource://gre/modules/PlacesDBUtils.sys.mjs", @@ -175,10 +176,13 @@ export var Troubleshoot = { * Captures a snapshot of data that may help troubleshooters troubleshoot * trouble. * + * @param {object} [options] + * @param {boolean} [options.includeEnterpriseSecurity=true] + * Whether to run and include the enterprise EDR and disk-encryption probes. * @returns {Promise} * A promise that is resolved with the snapshot data. */ - snapshot() { + snapshot({ includeEnterpriseSecurity = true } = {}) { return new Promise(resolve => { let snapshot = {}; let numPending = Object.keys(dataProviders).length; @@ -191,7 +195,9 @@ export var Troubleshoot = { } for (let name in dataProviders) { try { - dataProviders[name](providerDone.bind(null, name)); + dataProviders[name](providerDone.bind(null, name), { + includeEnterpriseSecurity, + }); } catch (err) { let msg = "Troubleshoot data provider failed: " + name + "\n" + err; console.error(msg); @@ -388,7 +394,10 @@ var dataProviders = { ); }, - securitySoftware: async function securitySoftware(done) { + securitySoftware: async function securitySoftware( + done, + { includeEnterpriseSecurity } + ) { let data = {}; const keys = [ @@ -405,8 +414,11 @@ var dataProviders = { data[key] = prop; } - if (AppConstants.MOZ_ENTERPRISE) { - data.presentEdrs = await lazy.EdrDetection.getPresentEdrs(); + if (AppConstants.MOZ_ENTERPRISE && includeEnterpriseSecurity) { + [data.presentEdrs, data.diskEncryption] = await Promise.all([ + lazy.EdrDetection.getPresentEdrs(), + lazy.DiskEncryption.getStatus(), + ]); } done(data); diff --git a/toolkit/modules/moz.build b/toolkit/modules/moz.build index f2ce07e60949b..2990db72aca99 100644 --- a/toolkit/modules/moz.build +++ b/toolkit/modules/moz.build @@ -229,6 +229,7 @@ MOZ_SRC_FILES += [ if CONFIG["MOZ_ENTERPRISE"]: EXTRA_JS_MODULES.enterprise += [ + "DiskEncryption.sys.mjs", "EdrDetection.sys.mjs", "MachineId.sys.mjs", ] diff --git a/toolkit/modules/tests/browser/browser_Troubleshoot.js b/toolkit/modules/tests/browser/browser_Troubleshoot.js index 66cb4dd594919..7d74efe6218e0 100644 --- a/toolkit/modules/tests/browser/browser_Troubleshoot.js +++ b/toolkit/modules/tests/browser/browser_Troubleshoot.js @@ -38,6 +38,20 @@ add_task(async function snapshotSchema() { } }); +add_task(async function enterpriseSecurityCanBeExcluded() { + let snapshot = await Troubleshoot.snapshot({ + includeEnterpriseSecurity: false, + }); + ok( + !("presentEdrs" in snapshot.securitySoftware), + "EDR products are excluded" + ); + ok( + !("diskEncryption" in snapshot.securitySoftware), + "disk encryption is excluded" + ); +}); + add_task(async function modifiedPreferences() { let prefs = [ "javascript.troubleshoot", @@ -453,6 +467,20 @@ const SNAPSHOT_SCHEMA = { required: false, type: "array", }, + diskEncryption: { + required: false, + type: "object", + properties: { + status: { + required: true, + type: "string", + }, + method: { + required: true, + type: ["string", "null"], + }, + }, + }, }, }, processes: { diff --git a/toolkit/modules/tests/xpcshell/test_DiskEncryption.js b/toolkit/modules/tests/xpcshell/test_DiskEncryption.js new file mode 100644 index 0000000000000..2bff925aae912 --- /dev/null +++ b/toolkit/modules/tests/xpcshell/test_DiskEncryption.js @@ -0,0 +1,140 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); +const { MockRegistrar } = ChromeUtils.importESModule( + "resource://testing-common/MockRegistrar.sys.mjs" +); + +const enterpriseOnly = () => ({ skip_if: () => !AppConstants.MOZ_ENTERPRISE }); + +const CONTRACT_ID = "@mozilla.org/enterprise/disk-encryption-checker;1"; +const VALID_STATUSES = [ + "full", + "enabled", + "partial", + "disabled", + "in-progress", + "unknown", +]; + +let DiskEncryption; +if (AppConstants.MOZ_ENTERPRISE) { + ({ DiskEncryption } = ChromeUtils.importESModule( + "resource://gre/modules/enterprise/DiskEncryption.sys.mjs" + )); +} + +async function withMockChecker(mock, callback) { + let cid = MockRegistrar.register(CONTRACT_ID, { + QueryInterface: ChromeUtils.generateQI([Ci.nsIDiskEncryptionChecker]), + ...mock, + }); + try { + await callback(); + } finally { + MockRegistrar.unregister(cid); + } +} + +add_task(enterpriseOnly(), async function test_native_component_result_shape() { + let result = await DiskEncryption.getStatus(); + info(`Disk encryption: ${JSON.stringify(result)}`); + + Assert.ok( + VALID_STATUSES.includes(result.status), + `${result.status} is a documented status` + ); + Assert.equal( + result.method === null, + result.status === "unknown", + "a method is reported unless the status is unknown" + ); + if (result.method !== null) { + Assert.ok( + { + macosx: ["filevault"], + win: ["bitlocker"], + // A ZFS root reports native encryption rather than dm-crypt. + linux: ["dm-crypt", "zfs"], + }[AppConstants.platform].includes(result.method), + `${result.method} is a method of this platform` + ); + } +}); + +add_task(enterpriseOnly(), async function test_empty_method_becomes_null() { + await withMockChecker( + { + getDiskEncryption(callback) { + callback.onComplete("unknown", ""); + }, + }, + async () => { + Assert.deepEqual(await DiskEncryption.getStatus(), { + status: "unknown", + method: null, + }); + } + ); +}); + +add_task( + enterpriseOnly(), + async function test_invalid_result_becomes_unknown() { + for (let [status, method] of [ + ["unexpected", "dm-crypt"], + ["full", ""], + ["full", "unexpected"], + ["unknown", "dm-crypt"], + ]) { + await withMockChecker( + { + getDiskEncryption(callback) { + callback.onComplete(status, method); + }, + }, + async () => { + Assert.deepEqual( + await DiskEncryption.getStatus(), + { status: "unknown", method: null }, + `${status}/${method} is normalized to unknown` + ); + } + ); + } + } +); + +add_task(enterpriseOnly(), async function test_lost_callback_times_out() { + await withMockChecker( + { + getDiskEncryption() {}, + }, + async () => { + Assert.deepEqual( + await DiskEncryption.getStatus(50), + { status: "unknown", method: null }, + "A missing callback resolves to unknown" + ); + } + ); +}); + +add_task(enterpriseOnly(), async function test_failing_component_is_unknown() { + await withMockChecker( + { + getDiskEncryption() { + throw Components.Exception("", Cr.NS_ERROR_FAILURE); + }, + }, + async () => { + Assert.deepEqual(await DiskEncryption.getStatus(), { + status: "unknown", + method: null, + }); + } + ); +}); diff --git a/toolkit/modules/tests/xpcshell/xpcshell.toml b/toolkit/modules/tests/xpcshell/xpcshell.toml index a428f4f542149..fd06bdf447b9f 100644 --- a/toolkit/modules/tests/xpcshell/xpcshell.toml +++ b/toolkit/modules/tests/xpcshell/xpcshell.toml @@ -50,6 +50,8 @@ skip-if = [ ["test_DeferredTask_window.js"] support-files = ["test_DeferredTask_window/*"] +["test_DiskEncryption.js"] + ["test_E10SUtils_getRemoteTypeForURIObject.js"] ["test_EventEmitter.js"]