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