From 80b8d5ef222d9b821e3f85e6863288bc25a1b276 Mon Sep 17 00:00:00 2001 From: cf-eglendye Date: Fri, 1 May 2026 17:40:18 +0100 Subject: [PATCH] #39 Feature: FileVault no-mount unlock and read-only mounting Fix #39 Feature: FileVault no-mount unlock and read-only mounting. NOTE: The updates made in this commit were implemented by Claude Code. All prompts written and code functionality for unlocking FileVault-encrypted volumes tested by Elliot Glendye on a MacBook Pro M2 macOS 26. --------------------------------------------------- Three runtime bugs and one build-time bug corrected after initial deployment of the FileVault unlock feature in macOS Recovery. - Allow locked volumes to be focused in the device list on_item_focused previously bounced focus away from any row without a mount point. Locked FileVault volumes have no mount point, so they could not be clicked or highlighted. Added elif device.is_locked branch to permit focus without mounting anything. - Trigger unlock dialog on double-click of a locked volume on_item_activated had the same gatekeeper. Added elif device.is_locked branch that calls _run_unlock_dialog(preferred_identifier=...) so double-clicking a locked row opens the dialog pre-aimed at that volume. - Fix AttributeError crash before password dialog appeared FileVaultUnlockDialog expects List[EncryptedVolume] but the unlock handler was passing List[DeviceInfo]. The first attribute access (v.roles) crashed before the dialog rendered. Refactored the unlock logic into _run_unlock_dialog(), which now calls list_encrypted_volumes() directly to obtain correctly typed objects. on_unlock_filevault() is now a one-line delegate to _run_unlock_dialog(). - Ensure launcher script has execute bit after build and RAM disk copy shutil.copy() inherits permissions from the source file. When built from a Windows/NTFS filesystem, Fuji.sh has no execute bit, producing a non-executable launcher and "The application can't be opened" in Recovery. Added explicit chmod(0o755) in Fuji.spec after the copy. Added a matching chmod in attempt_ramdisk() after the ditto copy as defence in depth. No forensic behaviour is altered. All changes affect either the tool's own UI interaction model or its build artefacts. No source disk writes are introduced. --- CHANGES.md | 318 ++++++++++++++++++++++++++++++++++ checks/readonly.py | 86 ++++++++++ fuji.py | 407 ++++++++++++++++++++++++++++++++++++-------- shared/filevault.py | 253 +++++++++++++++++++++++++++ 4 files changed, 992 insertions(+), 72 deletions(-) create mode 100644 CHANGES.md create mode 100644 checks/readonly.py create mode 100644 shared/filevault.py diff --git a/CHANGES.md b/CHANGES.md new file mode 100644 index 0000000..2b476cb --- /dev/null +++ b/CHANGES.md @@ -0,0 +1,318 @@ +# Fuji — FileVault read-only unlock + +Changes to the Fuji (Lazza/Fuji) codebase to support unlocking a +FileVault-encrypted APFS Data volume from within macOS Recovery and +mounting it **read-only** for forensic acquisition. + +Applies to Fuji 1.2.0 (commit layout as of April 2026). The following +files are touched across two sets of changes (initial feature, then +bug fixes): + +**Initial feature additions:** + +1. **New:** `shared/filevault.py` — GUI-free unlock helpers. +2. **New:** `checks/readonly.py` — read-only verification check. +3. **Modified:** `fuji.py` — `DevicesWindow` gains FileVault state, + an unlock button, and a password dialog. + +**Bug fixes and build hardening (April 2026):** + +4. **Modified:** `fuji.py` — selection, double-click, and dialog fixes + (see *Bug fixes* section below). +5. **Modified:** `Fuji.spec` — execute-bit fix for the launcher script. +6. **Modified:** `shared/environment.py` — execute-bit fix for the + RAM-disk launcher after `ditto` copy. + +No acquisition method, check, or environment logic is altered in ways +that affect evidence handling. No command is introduced that writes to +the source disk. (Note: `shared/environment.py` is modified, but only +in `attempt_ramdisk()` which operates exclusively on the tool's own +volatile RAM disk, not on any evidence volume.) + +## Forensic contract + +The implementation honours the stated constraints: + +- **Read-only mount is mandatory.** The unlock flow is two steps: + `diskutil apfs unlockVolume -nomount -stdinpassphrase`, followed + by `diskutil mount readOnly `. The `-nomount` flag suppresses + `diskutil`'s default auto-mount, which would otherwise come up + read-write and violate the contract. +- **No writes to the source disk.** Nothing in the new code invokes + `diskutil repairVolume`, `fsck_apfs`, journal replay, or any other + mutating command. After mounting, `verify_readonly()` checks the + kernel-level mount flags (parsed from `mount(8)`) to confirm the + volume is genuinely read-only; if somehow it is not, `UnlockError` + is raised rather than silently succeeding. +- **Password hygiene.** The password is passed to `diskutil` via + stdin (`-stdinpassphrase`) so it never appears in argv (which is + visible to other processes via `ps`). It is never written to a + temp file, never logged, and never printed. The `subprocess.run` + call uses `capture_output=True`; diskutil's stderr is classified + into a generic error message rather than surfaced raw, so there + is no risk of leaking a passphrase that might appear in an edge + case. References to the password string are dropped promptly after + use. Python strings are immutable so true memory zeroisation is + not possible at the language level; this is a known limitation of + Python-based forensic tools. + +## Why the existing code didn't work for encrypted Macs + +Three gaps, each addressed by one of the changes: + +1. `shared/environment.py` (lines 18–37) auto-discovers the user + Data volume by looking for `.fseventsd` inside mounted APFS + volumes. A locked FileVault volume is not mounted at all, so this + discovery silently falls through to `SOURCE_PATH = "/"`. +2. `fuji.py`, `DevicesWindow.on_item_activated` only accepts + selection for volumes with a mount point. Locked volumes have no + mount point, so they cannot be selected as a source. +3. There was no FileVault UI, no lock-state reporting, and no unlock + code path anywhere in the tree. + +The initial changes add the missing unlock path — after unlock, the +newly-mounted volume flows through the existing selection logic +naturally. Subsequent bug fixes (see *Bug fixes* section) corrected +three issues that prevented the dialog from appearing and the +recovery-mode launcher from executing. + +## `shared/filevault.py` (new) + +Pure subprocess wrappers, GUI-free, unit-testable in isolation. + +- `EncryptedVolume` dataclass — one per encrypted APFS volume, with + identifier, name, container reference, APFS roles, FileVault / + locked booleans, and current mount point. The `is_user_data` + property is True when `"Data"` is in roles; this is the volume the + examiner usually wants. +- `list_encrypted_volumes()` — parses `diskutil apfs list -plist` + using `plistlib`. This is deliberately structured parsing (not + column-pivot parsing like the existing `_parse_stanza`) because + lock state is per-volume and needs to be reliable. +- `unlock_volume_readonly(identifier, password)` — the two-step + unlock flow with the forensic guarantees described above. Returns + the resulting mount point on success; raises `UnlockError` with + a sanitised message on any failure. Confirms read-only via + `verify_readonly` before returning. +- `verify_readonly(mount_point)` — parses `mount(8)` output and + checks for the `read-only` flag. Used both as a final guard inside + `unlock_volume_readonly` and by the new check. +- `_classify_unlock_error(stderr, stdout)` — maps diskutil failures + into user-safe messages ("Incorrect FileVault password.", + "Volume is not locked.", etc.) without echoing diskutil's raw + output to the UI. + +## `checks/readonly.py` (new) + +Implements `ReadOnlySourceCheck`, a standard Fuji check that runs +before acquisition and confirms `PARAMS.source` sits on a read-only +mount. The check is strict: if mount flags can't be determined, it +fails (false negatives are harmless, false positives defeat the +point). The root filesystem case is skipped — in a live OS it's +meaningless, and in Recovery a `source = "/"` means no source has +been selected yet. + +The check is wired into `fuji.py`'s `ALL_CHECKS` list so it runs +alongside the existing checks on the overview screen, with the +same visual pass/fail semantics. + +## `fuji.py` modifications + +All changes are localised to the `DevicesWindow` class and the +module-level imports / `ALL_CHECKS` list. `InputWindow`, +`OverviewWindow`, `ProcessingWindow`, and the main loop are +untouched. + +- Module imports: pull in `ReadOnlySourceCheck` and the public + names from `shared.filevault`. +- `ALL_CHECKS`: `ReadOnlySourceCheck()` added. It runs in all + modes (live OS and Recovery) — in the live OS `source = "/"` + is common and the check short-circuits with a pass. +- `DeviceInfo`: two new fields, `filevault_status` (str label) + and `is_locked` (bool). Defaults preserve the old behaviour + when no FileVault state is present. +- `DevicesWindow.__init__`: refactored to delegate to two new + methods, `_enumerate_devices()` and `_populate_list()`, so the + list can be rebuilt after a successful unlock. The single + `SetMinSize` call still happens once at init. +- New column `FileVault` inserted between `Device status` and + `Mount point`. Locked rows render in the accent red rather than + greyed-out, to distinguish "locked, can be unlocked" from + "unmounted and unusable". +- `_enumerate_devices()`: the old `df` + `diskutil list` parsing + block, now with a final overlay step that merges + `list_encrypted_volumes()` output onto the matching + `DeviceInfo` rows by identifier. The overlay is wrapped in + `try/except`: if `diskutil apfs list -plist` fails for any + reason, the UI degrades to the pre-FileVault behaviour rather + than erroring out. +- `_populate_list()`: the old row-insertion block, now using the + two new columns and applying the locked-red colour. +- `_refresh_unlock_button_state()`: enables the unlock button only + when at least one locked volume is visible. +- `_run_unlock_dialog(preferred_identifier="")`: the core unlock + helper, extracted from `on_unlock_filevault` to allow both the + button and a double-click to invoke it. Fetches locked volumes via + `list_encrypted_volumes()` (which returns `EncryptedVolume` + objects, as required by `FileVaultUnlockDialog`). If + `preferred_identifier` is set, that volume is sorted to the front + of the drop-down. Calls `unlock_volume_readonly`, drops the + password reference before returning, re-enumerates the list on + success, and shows an informational dialog with the mount point; + the examiner then explicitly double-clicks the newly mounted volume + to set it as the source. +- `on_unlock_filevault(event)`: now a one-line button handler that + delegates to `_run_unlock_dialog()` with no preferred identifier. +- `FileVaultUnlockDialog`: new modal dialog. `wx.Choice` of locked + volumes (pre-selecting the `Data` role if present), a + `wx.TE_PASSWORD` field, Unlock and Cancel buttons. Pressing Enter + in the password field submits. The dialog has no writable-mount + option because the forensic contract forbids one. + +## Things deliberately not done + +- **No recovery-key paths (PRK / IRK).** The spec restricted the + unlock path to `diskutil apfs unlockVolume` with a passphrase. + Adding institutional- or personal-recovery-key support would + require a different diskutil subcommand + (`-recoveryKeychain`) and a different UI; it's a natural follow-up + if you need it. +- **No password storage, pre-staging, or "remember me" UI.** The + password is captured per-unlock, used once, and dropped. +- **No changes to acquisition methods.** Rsync, Ditto, and ASR + all read from the source; with the source mounted read-only they + remain compliant. `_create_temporary_image` and related writes + target the destination / temporary volume, not the source, so + they are unaffected. +- **No `diskutil repairVolume` or `fsck`.** These were listed as + forbidden in the forensic constraints. Nothing in the new code + invokes them, and the existing code does not either. + +## Testing + +On a Mac running macOS 12+ (Intel or Apple Silicon) booted into +Recovery from a Fuji USB: + +1. Launch Fuji; open "List of drives and partitions". +2. The Data volume on the target disk should appear with + `FileVault: Locked` in the new column and red text. The row is + now selectable (clicking it will highlight it without bouncing + focus away). +3. **Either** click "Unlock FileVault volume…" (shows all locked + volumes in the drop-down), **or** double-click the locked row + directly (opens the same dialog pre-aimed at that volume). +4. Confirm the volume choice (`Data` role pre-selected), type the + FileVault password, press Enter or click Unlock. +5. On success, a dialog shows the new mount point under `/Volumes`. + The device list refreshes; the volume now shows with its mount + point and `FileVault: Unlocked`. +6. Double-click the (now mounted) volume to set it as the source, + proceed to the overview screen. `Source read-only check` should + pass (green). +7. Verify independently with: + `mount | grep ` — expect `read-only` in the flags. +8. Proceed with a Ditto or ASR acquisition as usual. + +Wrong-password behaviour: the unlock call fails with "Incorrect +FileVault password." and the volume remains locked; no retry +counter is implemented (diskutil itself does not lock out). + +## Bug fixes (April 2026) + +Three runtime bugs and one build-time bug were identified and corrected +after initial deployment in Recovery mode. + +### 1. Locked volumes could not be selected in the device list (`fuji.py`) + +**Root cause.** `DevicesWindow.on_item_focused` only allowed focus to +remain on a row when `device.disk_space and device.disk_space.mount_point` +was truthy. Locked FileVault volumes have no `disk_space` entry (they are +absent from `df` output because they are not mounted), so every click +immediately bounced focus back to the previously selected row. + +**Fix.** Added `elif device.is_locked` branch: locked volumes may be +focused and highlighted in the list, giving the examiner visual +confirmation of which volume they are about to unlock. + +**Forensic impact.** None. Allowing a row to be highlighted does not +alter the source disk or mount anything. + +--- + +### 2. Double-clicking a locked volume did nothing useful (`fuji.py`) + +**Root cause.** `DevicesWindow.on_item_activated` also fell through to +`_back_to_selected()` for any row without a mount point, including locked +volumes. There was no path from double-click to the unlock flow. + +**Fix.** Added `elif device.is_locked` branch that calls +`_run_unlock_dialog(preferred_identifier=device.identifier)`. This opens +the unlock dialog pre-aimed at the double-clicked volume, matching the +expectation that double-click = "do the right thing for this row". + +**Forensic impact.** None. The dialog still requires an explicit password +entry and a second double-click on the mounted volume to set it as source. + +--- + +### 3. The unlock dialog crashed before appearing — `AttributeError: 'DeviceInfo' object has no attribute 'roles'` (`fuji.py`) + +**Root cause.** `_run_unlock_dialog` (and the original `on_unlock_filevault` +before refactoring) built its list of locked volumes from `self.devices`, +which contains `DeviceInfo` objects. `FileVaultUnlockDialog` is typed to +receive `List[EncryptedVolume]` and immediately calls `_format_choice(v)`, +which accesses `v.roles`. `DeviceInfo` has no `roles` attribute, so the +dialog crashed with an `AttributeError` before the password field was +ever rendered. + +**Fix.** `_run_unlock_dialog` now calls `list_encrypted_volumes()` directly +to obtain a fresh `List[EncryptedVolume]`, which carries the correct +attributes. The `preferred_identifier` matching still works because both +types use the same disk identifier string (e.g. `disk3s1`). + +**Forensic impact.** None. `list_encrypted_volumes()` is read-only +(parses `diskutil apfs list -plist` output). No disk is written. + +--- + +### 4. Recovery-mode launcher script lacked the execute bit (`Fuji.spec` + `shared/environment.py`) + +**Root cause.** `Fuji.spec` copies `packaging/Fuji.sh` into the app bundle +as `Fuji` using `shutil.copy`. When the source file resides on an NTFS +(Windows) filesystem, NTFS does not track Unix execute bits, so the copied +script had mode `0o644` — readable but not executable. macOS Launch +Services refused to open the app with "The application 'Fuji' can't be +opened.", and running the binary directly from Terminal confirmed +`Permission denied` on the launcher script. + +A secondary failure occurred in `attempt_ramdisk()`: `ditto` faithfully +preserved the missing execute bit when copying the app to the RAM disk, +so the re-launched process from the RAM disk also failed with +`Permission denied`. + +**Fix 1 (`Fuji.spec`).** After the `shutil.copy` call, an explicit +`(executable_path / "Fuji").chmod(0o755)` is applied. This ensures every +build sets the correct execute bit regardless of the host filesystem. + +**Fix 2 (`shared/environment.py`).** After `ditto` copies the app to the +RAM disk, `ramdisk_launcher.chmod(0o755)` is called as a defence-in-depth +measure. Even if a future build omits the spec fix, the RAM-disk launch +will still succeed. + +**Forensic impact.** Both `chmod` calls operate on the tool itself — the +built app bundle and the tool's own volatile RAM disk. Neither the source +disk under examination nor any evidence volume is touched. + +--- + +## Applying the patch + +```bash +cd your-fuji-fork +patch -p1 < fuji-filevault.patch +``` + +Or drop the three files into place directly (new files as-is, +replace `fuji.py`). + +No new Python dependencies; `plistlib` is in the standard library. diff --git a/checks/readonly.py b/checks/readonly.py new file mode 100644 index 0000000..d99c535 --- /dev/null +++ b/checks/readonly.py @@ -0,0 +1,86 @@ +""" +Check that the configured source is mounted read-only. + +This is belt-and-braces verification: after the examiner has (hopefully) +unlocked the FileVault volume via the read-only path in shared.filevault, +this check confirms that the kernel-level mount flags on PARAMS.source +actually reflect a read-only mount before acquisition begins. + +The check is intentionally strict: any uncertainty is treated as a failure, +on the principle that a false negative (wrongly flagging a read-only mount +as writable) is harmless, whereas a false positive would defeat the point +of the check. +""" + +import os +import subprocess + +from acquisition.abstract import Parameters +from checks.abstract import Check, CheckResult + + +class ReadOnlySourceCheck(Check): + name = "Source read-only check" + + def _find_mount_line(self, mount_point: str) -> str: + """Return the mount(8) line whose mount point matches, or ''.""" + try: + out = subprocess.run( + ["mount"], capture_output=True, check=True, text=True + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + return "" + + for line in out.splitlines(): + if f" on {mount_point} (" in line: + return line + return "" + + def _walk_to_mount(self, path: str) -> str: + """Walk up from a path to the nearest mount point.""" + path = os.path.realpath(path) + while path and not os.path.ismount(path): + parent = os.path.dirname(path) + if parent == path: + break + path = parent + return path + + def execute(self, params: Parameters) -> CheckResult: + result = CheckResult() + + source = f"{params.source}" + if not source or source == "/": + # The root filesystem case: in the live OS this is the running + # system and this check is not meaningful. In Recovery, SOURCE_PATH + # defaults to "/" only when auto-discovery failed and the user + # hasn't yet selected anything. + result.passed = True + result.write("Source is the root filesystem; read-only check skipped.") + return result + + mount_point = self._walk_to_mount(source) + line = self._find_mount_line(mount_point) + + if not line: + result.passed = False + result.write( + f"Could not determine mount flags for {mount_point}. " + "Refusing to assume read-only." + ) + return result + + flags = line.split("(", 1)[1].rstrip(") ").lower() + tokens = [t.strip() for t in flags.split(",")] + + if "read-only" in tokens: + result.passed = True + result.write(f"Source mount {mount_point} is read-only.") + else: + result.passed = False + result.write( + f"Source mount {mount_point} is NOT read-only. " + "Acquisition from a writable source risks altering evidence." + ) + + return result diff --git a/fuji.py b/fuji.py index 066bb54..3a52bd2 100644 --- a/fuji.py +++ b/fuji.py @@ -21,9 +21,16 @@ from checks.free_space import FreeSpaceCheck from checks.name import NameCheck from checks.network import NetworkCheck +from checks.readonly import ReadOnlySourceCheck from checks.running_apps import RunningAppsCheck from meta import AUTHOR, HOMEPAGE, VERSION from shared.environment import RECOVERY, AdaptiveHyperLinkCtrl, attempt_ramdisk +from shared.filevault import ( + EncryptedVolume, + UnlockError, + list_encrypted_volumes, + unlock_volume_readonly, +) from shared.utils import ( ACCENT_COLOR, GREEN_COLOR, @@ -46,6 +53,7 @@ FoldersCheck(), FreeSpaceCheck(), NetworkCheck(), + ReadOnlySourceCheck(), RunningAppsCheck(), ] CHECKS = [c for c in ALL_CHECKS if c.active()] @@ -94,6 +102,8 @@ class DeviceInfo: identifier: str = "" status: str = "" disk_space: Optional[DiskSpaceInfo] = None + filevault_status: str = "" + is_locked: bool = False class DevicesWindow(wx.Frame): @@ -138,6 +148,7 @@ def __init__(self, parent): super().__init__(parent, title="Fuji - Drives and partitions") self.parent = parent panel = wx.Panel(self) + self._panel = panel title = wx.StaticText(panel, label="List of drives and partitions") set_font(title, size=18, weight=wx.FONTWEIGHT_BOLD) @@ -151,106 +162,57 @@ def __init__(self, parent): self.list_ctrl.Bind(wx.EVT_LIST_ITEM_FOCUSED, self.on_item_focused) self.list_ctrl.Bind(wx.EVT_LIST_ITEM_ACTIVATED, self.on_item_activated) - mount_info: dict[str, DiskSpaceInfo] = {} - df_lines = subprocess.check_output(["df"], universal_newlines=True).splitlines() - for line in df_lines: - if not line.startswith("/dev/disk"): - continue - identifier, size, used, free, _, _, _, _, mount_point = re.split( - r"\s+", line, maxsplit=8 - ) - short_identifier: str = identifier[5:] - mount_info[short_identifier] = DiskSpaceInfo( - identifier=identifier, - size=int(size) * 512, - used_space=int(used) * 512, - free_space=int(free) * 512, - mount_point=mount_point, - ) - - self.devices: List[DeviceInfo] = [] - - diskutil_list = subprocess.check_output( - ["diskutil", "list"], universal_newlines=True - ) - stanzas = diskutil_list.strip().split("\n\n") - for stanza in stanzas: - self.devices.extend(self._parse_stanza(stanza, mount_info)) - - # Add columns to the list control - columns = [ + # Add columns to the list control. "FileVault" is inserted between + # "Device status" and "Mount point" so that lock state is visible in + # the same row as the rest of the per-partition information. + self._columns = [ "Identifier", "Type", "Name", "Size", "Device status", + "FileVault", "Mount point", "Used space", ] - for index, col in enumerate(columns): + for index, col in enumerate(self._columns): self.list_ctrl.InsertColumn(index, col, width=-1) + self.devices: List[DeviceInfo] = [] self.selected_index = -1 - highlight = wx.Colour() - highlight.SetRGBA(0x18808080) - for index, line in enumerate(self.devices): - mount_point = "" - size_str = line.size - used_str = "" - if line.type in ("APFS Volume", "APFS Snapshot"): - used_str = size_str - size_str = "^" - disk_space = line.disk_space - if disk_space: - mount_point = disk_space.mount_point - used_str = humanize.naturalsize(disk_space.used_space) - - index = self.list_ctrl.InsertItem( - index, f"{' ' * line.indent}{line.identifier}" - ) - self.list_ctrl.SetItem(index, 1, line.type) - self.list_ctrl.SetItem(index, 2, line.name) - self.list_ctrl.SetItem(index, 3, size_str) - self.list_ctrl.SetItem(index, 4, line.status) - self.list_ctrl.SetItem(index, 5, mount_point) - self.list_ctrl.SetItem(index, 6, used_str) - self.list_ctrl.SetItemData(index, index) - if f"{PARAMS.source}" == mount_point: - self.list_ctrl.Select(index) - self.list_ctrl.Focus(index) - self.selected_index = index - if index % 2: - self.list_ctrl.SetItemBackgroundColour(index, highlight) - if not mount_point: - self.list_ctrl.SetItemTextColour(index, (128, 128, 128)) + self._enumerate_devices() + self._populate_list() + # Compute a sensible minimum size based on the initial contents. This + # is done only once; _populate_list resizes columns on every refresh + # without altering the window minimum. padding = 10 width = padding * 4 height = padding * 4 - for index in range(len(columns)): - self.list_ctrl.SetColumnWidth(index, wx.LIST_AUTOSIZE) - # Add a bit of padding - padded_width = self.list_ctrl.GetColumnWidth(index) + padding - padded_width = max(padded_width, 100) - if index == 2: - padded_width = min(padded_width, 180) - self.list_ctrl.SetColumnWidth(index, padded_width) - width = width + padded_width - - for index in range((self.list_ctrl.ItemCount)): + for index in range(len(self._columns)): + width = width + self.list_ctrl.GetColumnWidth(index) + for index in range(self.list_ctrl.ItemCount): rect: wx.Rect = self.list_ctrl.GetItemRect(index) height = height + rect.GetHeight() self.list_ctrl.SetMinSize(wx.Size(width, height)) + # Unlock button for FileVault-encrypted volumes. This is the only + # affordance for bringing a locked APFS volume online; the existing + # double-click flow intentionally refuses unmounted volumes. + self.unlock_btn = wx.Button(panel, label="Unlock FileVault volume\u2026") + self.unlock_btn.Bind(wx.EVT_BUTTON, self.on_unlock_filevault) + self._refresh_unlock_button_state() + # Add controls to the sizer vbox = wx.BoxSizer(wx.VERTICAL) vbox.Add(title, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 20) vbox.Add(devices_label, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.TOP, 10) vbox.Add((0, 10)) vbox.Add(self.list_ctrl, 1, wx.EXPAND | wx.ALL, border=10) + vbox.Add(self.unlock_btn, 0, wx.ALIGN_CENTER_HORIZONTAL | wx.BOTTOM, 10) panel.SetSizerAndFit(vbox) sizer = wx.GridSizer(1) @@ -273,6 +235,11 @@ def on_item_focused(self, event): device: DeviceInfo = self.devices[index] if device.disk_space and device.disk_space.mount_point: self.selected_index = event.GetIndex() + elif device.is_locked: + # Allow focusing locked FileVault volumes so the examiner can + # highlight them and use "Unlock FileVault volume…" or + # double-click to trigger the unlock dialog directly. + self.selected_index = event.GetIndex() else: self._back_to_selected() @@ -292,9 +259,305 @@ def on_item_activated(self, event): wx.EVT_LIST_ITEM_ACTIVATED, handler=self.on_item_activated ) self.Close() + elif device.is_locked: + # Double-clicking a locked FileVault volume launches the unlock + # dialog pre-aimed at that specific volume. + self._run_unlock_dialog(preferred_identifier=device.identifier) else: self._back_to_selected() + def _enumerate_devices(self) -> None: + """Re-read disk state and rebuild ``self.devices``. + + Called on window creation and again after a successful FileVault + unlock so the newly-mounted volume appears in the list. + """ + mount_info: dict[str, DiskSpaceInfo] = {} + df_lines = subprocess.check_output( + ["df"], universal_newlines=True + ).splitlines() + for line in df_lines: + if not line.startswith("/dev/disk"): + continue + identifier, size, used, free, _, _, _, _, mount_point = re.split( + r"\s+", line, maxsplit=8 + ) + short_identifier: str = identifier[5:] + mount_info[short_identifier] = DiskSpaceInfo( + identifier=identifier, + size=int(size) * 512, + used_space=int(used) * 512, + free_space=int(free) * 512, + mount_point=mount_point, + ) + + self.devices = [] + diskutil_list = subprocess.check_output( + ["diskutil", "list"], universal_newlines=True + ) + stanzas = diskutil_list.strip().split("\n\n") + for stanza in stanzas: + self.devices.extend(self._parse_stanza(stanza, mount_info)) + + # Overlay FileVault / encryption state from ``diskutil apfs list``. + # If the lookup fails for any reason, the overlay is silently skipped + # and the UI degrades to the pre-FileVault behaviour. + try: + encrypted_map = { + v.identifier: v for v in list_encrypted_volumes() + } + except Exception: + encrypted_map = {} + + for device in self.devices: + enc = encrypted_map.get(device.identifier) + if enc is not None: + device.filevault_status = enc.display_status + device.is_locked = enc.locked + + def _populate_list(self) -> None: + """Fill the list control from ``self.devices``. + + Clears any existing rows first so this method can be used for the + initial populate and for post-unlock refreshes. + """ + self.list_ctrl.DeleteAllItems() + self.selected_index = -1 + + highlight = wx.Colour() + highlight.SetRGBA(0x18808080) + + locked_colour = wx.Colour(181, 78, 78) # matches ACCENT_COLOR + + for index, line in enumerate(self.devices): + mount_point = "" + size_str = line.size + used_str = "" + if line.type in ("APFS Volume", "APFS Snapshot"): + used_str = size_str + size_str = "^" + disk_space = line.disk_space + if disk_space: + mount_point = disk_space.mount_point + used_str = humanize.naturalsize(disk_space.used_space) + + index = self.list_ctrl.InsertItem( + index, f"{' ' * line.indent}{line.identifier}" + ) + self.list_ctrl.SetItem(index, 1, line.type) + self.list_ctrl.SetItem(index, 2, line.name) + self.list_ctrl.SetItem(index, 3, size_str) + self.list_ctrl.SetItem(index, 4, line.status) + self.list_ctrl.SetItem(index, 5, line.filevault_status) + self.list_ctrl.SetItem(index, 6, mount_point) + self.list_ctrl.SetItem(index, 7, used_str) + self.list_ctrl.SetItemData(index, index) + if f"{PARAMS.source}" == mount_point: + self.list_ctrl.Select(index) + self.list_ctrl.Focus(index) + self.selected_index = index + if index % 2: + self.list_ctrl.SetItemBackgroundColour(index, highlight) + if line.is_locked: + # Locked FileVault volumes stand out so the examiner knows + # an unlock step is required before selection. + self.list_ctrl.SetItemTextColour(index, locked_colour) + elif not mount_point: + self.list_ctrl.SetItemTextColour(index, (128, 128, 128)) + + padding = 10 + for col_index in range(len(self._columns)): + self.list_ctrl.SetColumnWidth(col_index, wx.LIST_AUTOSIZE) + padded_width = self.list_ctrl.GetColumnWidth(col_index) + padding + padded_width = max(padded_width, 100) + if col_index == 2: + padded_width = min(padded_width, 180) + self.list_ctrl.SetColumnWidth(col_index, padded_width) + + def _refresh_unlock_button_state(self) -> None: + """Enable the unlock button only when at least one volume is locked.""" + has_locked = any(d.is_locked for d in self.devices) + self.unlock_btn.Enable(has_locked) + if has_locked: + self.unlock_btn.SetToolTip( + "Unlock a FileVault-encrypted volume and mount it read-only" + ) + else: + self.unlock_btn.SetToolTip("No locked FileVault volumes detected") + + def _run_unlock_dialog(self, preferred_identifier: str = "") -> None: + """Show the FileVault unlock dialog and process the result. + + If *preferred_identifier* is provided (e.g. from a double-click on a + specific locked row), that volume is moved to the front of the choice + list so it is pre-selected in the dialog. The examiner can still pick a + different volume from the drop-down if needed. + + The unlocked volume is mounted read-only; the device list is then + re-enumerated so the new mount point appears. The examiner still has to + explicitly double-click the newly mounted volume to set it as the + acquisition source, matching the existing UI contract. + """ + # FileVaultUnlockDialog expects EncryptedVolume objects (which carry + # .roles, .name, .is_user_data). self.devices holds DeviceInfo objects + # that only carry .is_locked as a flag — passing those caused an + # AttributeError on .roles before the dialog could appear. + try: + locked = [v for v in list_encrypted_volumes() if v.locked] + except Exception: + locked = [] + + if not locked: + wx.MessageBox( + "No locked FileVault volumes are currently visible.", + "Nothing to unlock", + wx.OK | wx.ICON_INFORMATION, + ) + return + + if preferred_identifier: + # Move the preferred volume to the front so it is pre-selected. + locked = sorted( + locked, key=lambda v: v.identifier != preferred_identifier + ) + + dialog = FileVaultUnlockDialog(self, locked) + try: + if dialog.ShowModal() != wx.ID_OK: + return + identifier = dialog.get_identifier() + password = dialog.get_password() + finally: + dialog.Destroy() + + if not identifier or password is None: + return + + try: + mount_point = unlock_volume_readonly(identifier, password) + except UnlockError as exc: + wx.MessageBox( + f"{exc}", + "Unlock failed", + wx.OK | wx.ICON_ERROR, + ) + return + finally: + # Drop our reference to the password as early as possible. Python + # strings are immutable, so this does not zero memory, but it + # does remove the only live reference from our side. + password = None # noqa: F841 + + wx.MessageBox( + f"Volume unlocked and mounted READ-ONLY at:\n\n{mount_point}\n\n" + "Select it from the list to use it as the acquisition source.", + "Unlock successful", + wx.OK | wx.ICON_INFORMATION, + ) + + # Refresh the device list so the new mount appears. + self._enumerate_devices() + self._populate_list() + self._refresh_unlock_button_state() + + def on_unlock_filevault(self, event): + """Button handler: open the unlock dialog with no volume pre-selected.""" + self._run_unlock_dialog() + + +class FileVaultUnlockDialog(wx.Dialog): + """Modal dialog: pick a locked volume, enter its FileVault password. + + The password field uses ``wx.TE_PASSWORD`` so characters are masked. + A read-only notice is displayed prominently; this dialog has no option + to choose a writable mount because the forensic contract forbids it. + """ + + def __init__(self, parent, locked_volumes: List[EncryptedVolume]): + super().__init__( + parent, + title="Unlock FileVault volume", + style=wx.DEFAULT_DIALOG_STYLE, + ) + self._locked = locked_volumes + + panel = wx.Panel(self) + + intro = wx.StaticText( + panel, + label=( + "The selected volume will be unlocked and mounted " + "READ-ONLY.\nThe password is sent to diskutil via stdin " + "and is never stored or logged." + ), + ) + + volume_label = wx.StaticText(panel, label="Locked volume:") + choices = [self._format_choice(v) for v in self._locked] + self._choice = wx.Choice(panel, choices=choices) + if choices: + # Prefer the Data role volume if present, otherwise the first entry. + default_index = next( + (i for i, v in enumerate(self._locked) if v.is_user_data), + 0, + ) + self._choice.SetSelection(default_index) + + password_label = wx.StaticText(panel, label="FileVault password:") + self._password_text = wx.TextCtrl( + panel, style=wx.TE_PASSWORD | wx.TE_PROCESS_ENTER + ) + self._password_text.Bind(wx.EVT_TEXT_ENTER, self._on_enter) + + ok_btn = wx.Button(panel, wx.ID_OK, label="Unlock") + ok_btn.SetDefault() + cancel_btn = wx.Button(panel, wx.ID_CANCEL, label="Cancel") + + # Layout + grid = wx.FlexGridSizer(cols=2, hgap=10, vgap=10) + grid.AddGrowableCol(1, 1) + grid.Add(volume_label, 0, wx.ALIGN_CENTER_VERTICAL) + grid.Add(self._choice, 1, wx.EXPAND) + grid.Add(password_label, 0, wx.ALIGN_CENTER_VERTICAL) + grid.Add(self._password_text, 1, wx.EXPAND) + + btn_box = wx.BoxSizer(wx.HORIZONTAL) + btn_box.Add(cancel_btn, 0, wx.RIGHT, 10) + btn_box.Add(ok_btn, 0) + + vbox = wx.BoxSizer(wx.VERTICAL) + vbox.Add(intro, 0, wx.ALL, 15) + vbox.Add(grid, 0, wx.EXPAND | wx.ALL, 15) + vbox.Add(btn_box, 0, wx.ALIGN_RIGHT | wx.ALL, 15) + + panel.SetSizerAndFit(vbox) + outer = wx.BoxSizer(wx.VERTICAL) + outer.Add(panel, 1, wx.EXPAND) + self.SetSizerAndFit(outer) + self.Center() + + self._password_text.SetFocus() + + @staticmethod + def _format_choice(v: EncryptedVolume) -> str: + role = ", ".join(v.roles) if v.roles else "no role" + name = v.name or "(unnamed)" + return f"{v.identifier} — {name} [{role}]" + + def _on_enter(self, event): + # Enter in the password field submits the dialog. + if self.IsModal(): + self.EndModal(wx.ID_OK) + + def get_identifier(self) -> str: + index = self._choice.GetSelection() + if index < 0 or index >= len(self._locked): + return "" + return self._locked[index].identifier + + def get_password(self) -> Optional[str]: + return self._password_text.GetValue() + class InputWindow(wx.Frame): method: AcquisitionMethod diff --git a/shared/filevault.py b/shared/filevault.py new file mode 100644 index 0000000..c651920 --- /dev/null +++ b/shared/filevault.py @@ -0,0 +1,253 @@ +""" +FileVault (APFS) discovery and read-only unlock helpers for Fuji. + +This module is deliberately GUI-free so it can be unit tested and reasoned +about independently of wxPython. + +Forensic design notes +--------------------- +* The unlock is performed in two steps: + 1. ``diskutil apfs unlockVolume -nomount -stdinpassphrase`` + 2. ``diskutil mount readOnly `` + The ``-nomount`` flag on step 1 is essential: without it, ``diskutil`` + auto-mounts the unlocked volume **read-write**, which would violate the + forensic requirement that no writes reach the source disk. +* ``-stdinpassphrase`` is used so the password never appears in argv + (argv is visible to any other process on the system via ``ps``). +* The password is passed to ``subprocess.run`` via ``input=`` and is never + written to a temp file, logged, or printed to stdout/stderr. +* Python strings are immutable, so true zeroisation of the password in + memory is not possible at the language level. Callers should drop their + reference promptly; this module does the same. +* Nothing in this module invokes ``diskutil repairVolume``, ``fsck_apfs``, + or any other command that could modify the source disk. +""" + +import plistlib +import subprocess +from dataclasses import dataclass +from typing import List, Optional + + +@dataclass +class EncryptedVolume: + """A single APFS volume that is encrypted (possibly FileVault-protected).""" + + identifier: str # e.g. "disk3s1" (no /dev/ prefix) + name: str # APFS volume name as reported by diskutil + container: str # e.g. "disk3" + roles: List[str] # e.g. ["Data"], ["System"], ["Preboot"], [] + filevault: bool # True if FileVault is enabled on the volume + locked: bool # True if currently locked (needs unlock) + mount_point: str = "" # Current mount point, if any + + @property + def is_user_data(self) -> bool: + """Heuristic: this is the volume an examiner usually wants.""" + return "Data" in self.roles + + @property + def display_status(self) -> str: + """Short human-readable label for the device list UI.""" + if not self.filevault and not self.locked: + return "" + if self.locked: + return "FileVault: Locked" + return "FileVault: Unlocked" + + +def _diskutil_apfs_list() -> dict: + """Run ``diskutil apfs list -plist`` and return the parsed plist.""" + result = subprocess.run( + ["diskutil", "apfs", "list", "-plist"], + capture_output=True, + check=True, + ) + return plistlib.loads(result.stdout) + + +def _diskutil_info(identifier: str) -> dict: + """Run ``diskutil info -plist `` and return the parsed plist.""" + result = subprocess.run( + ["diskutil", "info", "-plist", identifier], + capture_output=True, + check=True, + ) + return plistlib.loads(result.stdout) + + +def list_encrypted_volumes() -> List[EncryptedVolume]: + """Return every APFS volume that is encrypted, whether locked or not. + + This is discovery only. It does not unlock anything, does not prompt for + anything, and does not write to any disk. + """ + volumes: List[EncryptedVolume] = [] + try: + data = _diskutil_apfs_list() + except (subprocess.CalledProcessError, FileNotFoundError, plistlib.InvalidFileException): + return volumes + + for container in data.get("Containers", []): + container_ref = container.get("ContainerReference", "") + for vol in container.get("Volumes", []): + # Only surface encrypted volumes. Unencrypted volumes are of no + # interest to this module and are handled by the existing + # DevicesWindow enumeration. + encryption = bool(vol.get("Encryption", False)) + filevault = bool(vol.get("FileVault", False)) + if not (encryption or filevault): + continue + + identifier = vol.get("DeviceIdentifier", "") + if not identifier: + continue + + locked = bool(vol.get("Locked", False)) + volumes.append( + EncryptedVolume( + identifier=identifier, + name=vol.get("Name", "") or "", + container=container_ref, + roles=list(vol.get("Roles", []) or []), + filevault=filevault, + locked=locked, + mount_point=_current_mount_point(identifier), + ) + ) + + return volumes + + +def _current_mount_point(identifier: str) -> str: + """Return the current mount point for a volume, or '' if not mounted.""" + try: + info = _diskutil_info(identifier) + except (subprocess.CalledProcessError, FileNotFoundError, plistlib.InvalidFileException): + return "" + return info.get("MountPoint", "") or "" + + +class UnlockError(Exception): + """Raised when a FileVault unlock or read-only mount fails.""" + + +def unlock_volume_readonly(identifier: str, password: str) -> str: + """Unlock an APFS volume with FileVault and mount it read-only. + + Returns the mount point on success. Raises :class:`UnlockError` on failure. + + The password is sent to ``diskutil`` via stdin (``-stdinpassphrase``) so + that it never appears in the process argument list. It is not written to + any file, not logged, and not returned to the caller. + """ + if not identifier: + raise UnlockError("No volume identifier supplied.") + if password is None: + raise UnlockError("No password supplied.") + + # Step 1: unlock WITHOUT mounting. This prevents the default auto-mount, + # which would otherwise be read-write. + try: + unlock = subprocess.run( + [ + "diskutil", + "apfs", + "unlockVolume", + identifier, + "-nomount", + "-stdinpassphrase", + ], + input=password.encode("utf-8"), + capture_output=True, + check=False, + ) + except FileNotFoundError as exc: + raise UnlockError("diskutil is not available on this system.") from exc + + if unlock.returncode != 0: + # Never surface raw stderr to the UI in case it contains the passphrase + # echoed back in some edge case. Map return codes to a safe message. + raise UnlockError(_classify_unlock_error(unlock.stderr, unlock.stdout)) + + # Step 2: mount read-only. This is the only mount step; the source disk + # is never mounted read-write by this code path. + try: + mount = subprocess.run( + ["diskutil", "mount", "readOnly", identifier], + capture_output=True, + check=False, + ) + except FileNotFoundError as exc: + raise UnlockError("diskutil is not available on this system.") from exc + + if mount.returncode != 0: + raise UnlockError( + "Volume was unlocked but read-only mount failed. " + "Refusing to fall back to a writable mount." + ) + + mount_point = _current_mount_point(identifier) + if not mount_point: + raise UnlockError( + "Volume was unlocked and mount reported success, but no mount " + "point could be determined." + ) + + if not verify_readonly(mount_point): + # Defence in depth: if somehow the mount came up writable, refuse + # silently succeeding. We leave the volume mounted so the examiner + # can decide what to do, but we flag it as an error. + raise UnlockError( + f"Volume mounted at {mount_point} but is NOT read-only. " + "Do not proceed with acquisition from this mount." + ) + + return mount_point + + +def _classify_unlock_error(stderr: bytes, stdout: bytes) -> str: + """Return a safe, human-readable error message. + + We intentionally do not surface raw ``diskutil`` output to avoid any + chance of leaking the passphrase in corner cases. + """ + blob = (stderr + stdout).lower() + if b"passphrase" in blob and (b"incorrect" in blob or b"wrong" in blob): + return "Incorrect FileVault password." + if b"not locked" in blob: + return "Volume is not locked." + if b"not encrypted" in blob or b"no crypto" in blob: + return "Volume is not encrypted." + if b"could not find" in blob or b"no such" in blob: + return "Volume identifier not found." + return "Unlock failed. Check the volume identifier and try again." + + +def verify_readonly(mount_point: str) -> bool: + """Return True if the given mount point is mounted read-only. + + Parses the output of ``mount`` rather than relying on diskutil, because + the kernel-level mount flags are what actually determine whether writes + can happen. + """ + if not mount_point: + return False + try: + out = subprocess.run( + ["mount"], capture_output=True, check=True, text=True + ).stdout + except (subprocess.CalledProcessError, FileNotFoundError): + return False + + for line in out.splitlines(): + # Format: "/dev/diskXsY on /mount/point (apfs, local, read-only, ...)" + if f" on {mount_point} (" not in line: + continue + flags = line.split("(", 1)[1].rstrip(") ").lower() + tokens = [t.strip() for t in flags.split(",")] + if "read-only" in tokens: + return True + return False + + return False