Skip to main content

apfs_core/
encryption.rs

1//! Encryption: keybag parsing and crypto-state records — **state surfacing
2//! only** (no key cracking, no hand-rolled crypto).
3//!
4//! An encrypted container/volume stores wrapped keys in keybags. The container
5//! keybag (referenced from `nx_keylocker`) holds, per volume,
6//! `KB_TAG_VOLUME_KEY 0x02` (a wrapped volume encryption key / KEK packed
7//! object) and `KB_TAG_VOLUME_UNLOCK_RECORDS 0x03` (the volume keybag extent);
8//! the volume keybag holds `KB_TAG_WRAPPING_KEY 0x01` and
9//! `KB_TAG_VOLUME_PASSPHRASE_HINT 0x04`. Keybag tag values (libfsapfs):
10//! `KB_TAG_UNKNOWN 0x00`, `KB_TAG_WRAPPING_KEY 0x01`, `KB_TAG_VOLUME_KEY 0x02`,
11//! `KB_TAG_VOLUME_UNLOCK_RECORDS 0x03`, `KB_TAG_VOLUME_PASSPHRASE_HINT 0x04`,
12//! `KB_TAG_USER_PAYLOAD 0xf8`.
13//!
14//! Per-file crypto state is `APFS_TYPE_CRYPTO_STATE 7` (`j_crypto_val_t` with a
15//! `wrapped_meta_crypto_state_t`). This module **reports** what is present —
16//! locked/unlocked, which tags, hint presence — and, only when a key/passphrase
17//! is *supplied*, unwraps via a vetted crate (`RustCrypto` AES/HMAC/PBKDF2,
18//! AES-XTS). With no key it **refuses** to return plaintext; it never fabricates.
19
20/// Keybag tag values.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22#[non_exhaustive]
23pub enum KeybagTag {
24    Unknown = 0x00,
25    WrappingKey = 0x01,
26    VolumeKey = 0x02,
27    VolumeUnlockRecords = 0x03,
28    VolumePassphraseHint = 0x04,
29    UserPayload = 0xf8,
30}
31
32impl KeybagTag {
33    /// Map a raw `ke_tag` value to a known tag, or [`KeybagTag::Unknown`].
34    #[must_use]
35    pub fn from_u16(tag: u16) -> Self {
36        match tag {
37            0x01 => Self::WrappingKey,
38            0x02 => Self::VolumeKey,
39            0x03 => Self::VolumeUnlockRecords,
40            0x04 => Self::VolumePassphraseHint,
41            0xf8 => Self::UserPayload,
42            _ => Self::Unknown,
43        }
44    }
45}
46
47/// Observed encryption state of a volume (no secrets).
48#[derive(Debug, Clone)]
49#[non_exhaustive]
50pub struct EncryptionState {
51    pub encrypted: bool,
52    pub tags_present: Vec<KeybagTag>,
53    pub has_passphrase_hint: bool,
54    /// Raw `(ke_tag, entry offset)` pairs for keybag entries whose tag is not a
55    /// recognised `KB_TAG_*` value — surfaced so an audit can report the
56    /// offending value + location (show-the-value rule), not just "unknown".
57    pub unknown_tags: Vec<(u16, u64)>,
58}
59
60// `kb_locker` header field offsets, then 16-byte-aligned `keybag_entry_t`s.
61// A keybag block is an APFS OBJECT: a 32-byte obj_phys header (cksum, oid, xid,
62// type, subtype) precedes the kb_locker. Confirmed two ways -- the magic sits in
63// o_type at +24 ("keys" for a container bag, "recs" for a volume bag) in the real
64// fixture, and apfs-fuse's media_keybag_t carries an mk_obj member whose
65// mk_obj.o_type it validates.
66//
67// Omitting this made kl_nkeys read as 12824 (garbage); entry scanning resynced by
68// luck, which is why it appeared to work.
69const OBJ_PHYS_LEN: usize = 32;
70const KL_NKEYS: usize = OBJ_PHYS_LEN + 2; // kl_nkeys, u16
71const KL_ENTRIES_OFF: usize = OBJ_PHYS_LEN + 16; // entries follow the 16-byte kb_locker
72const KE_TAG: usize = 16; // u16 within an entry
73const KE_KEYLEN: usize = 18; // u16 within an entry
74const KE_HEADER_LEN: usize = 24; // uuid(16) + tag(2) + keylen(2) + pad(4)
75/// Cap on `kl_nkeys` (a hostile blob must not drive an unbounded loop).
76const MAX_KEYBAG_ENTRIES: usize = 4096;
77
78/// Parse a container/volume keybag (`kb_locker`) into observed state — which
79/// tags are present, whether a passphrase hint exists, and whether key material
80/// is present — **without** unwrapping any key.
81///
82/// # Errors
83/// [`crate::ApfsError::Io`] never (in-memory); returns `Ok` with whatever the
84/// blob structurally yields. A malformed entry stops the walk early rather than
85/// over-reading.
86pub fn read_keybag(data: &[u8]) -> crate::Result<EncryptionState> {
87    let nkeys = (crate::bytes::le_u16(data, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
88    let mut tags_present = Vec::new();
89    let mut unknown_tags = Vec::new();
90    let mut off = KL_ENTRIES_OFF;
91    for _ in 0..nkeys {
92        // Stop if the entry header would run past the blob (never over-read).
93        if off + KE_HEADER_LEN > data.len() {
94            break;
95        }
96        let raw_tag = crate::bytes::le_u16(data, off + KE_TAG);
97        let tag = KeybagTag::from_u16(raw_tag);
98        let keylen = crate::bytes::le_u16(data, off + KE_KEYLEN) as usize;
99        if tag == KeybagTag::Unknown {
100            unknown_tags.push((raw_tag, off as u64));
101        }
102        if !tags_present.contains(&tag) {
103            tags_present.push(tag);
104        }
105        // Advance by the 16-byte-aligned entry size.
106        let entry_len = (KE_HEADER_LEN + keylen + 15) & !15;
107        off += entry_len.max(16);
108    }
109    let has_passphrase_hint = tags_present.contains(&KeybagTag::VolumePassphraseHint);
110    // "Encrypted" = actual key material is present (a wrapping key, a wrapped
111    // volume key, or the volume-keybag unlock records).
112    let encrypted = tags_present.iter().any(|t| {
113        matches!(
114            t,
115            KeybagTag::WrappingKey | KeybagTag::VolumeKey | KeybagTag::VolumeUnlockRecords
116        )
117    });
118    Ok(EncryptionState {
119        encrypted,
120        tags_present,
121        has_passphrase_hint,
122        unknown_tags,
123    })
124}
125
126/// The three inputs the FileVault unwrap chain needs, read from a wrapped-KEK
127/// object in a volume keybag entry.
128#[derive(Debug, Clone)]
129#[non_exhaustive]
130pub struct WrappedKek {
131    /// RFC 3394 wrapped key encryption key — always 40 bytes (32-byte key + the
132    /// 8-byte integrity check AES-KW prepends).
133    pub wrapped_key: Vec<u8>,
134    /// PBKDF2 iteration count used to derive the unwrapping key from a password.
135    pub iterations: u32,
136    /// PBKDF2 salt — always 16 bytes.
137    pub salt: Vec<u8>,
138}
139
140/// Parse a wrapped-KEK object (BER TLV) into its unwrap inputs.
141///
142/// Framing is BER definite-length, as the libfsapfs reference reads it: a tag
143/// byte, then a length byte that is either the length itself (high bit clear),
144/// or `0x81`/`0x82` announcing a 1- or 2-byte length that follows. Any other
145/// leading length byte is rejected rather than guessed at.
146///
147/// Unrecognised tags are skipped, not fatal: the format carries fields this
148/// chain does not need (`0x81` volume GUID, `0x82` metadata) and may gain more.
149///
150/// # Errors
151/// [`crate::ApfsError::FieldOutOfRange`] when the blob is truncated, a length is
152/// unsupported, or a field has a size the format does not permit. Sizes are
153/// invariants: a wrong-sized wrapped key or salt cannot yield a correct key, and
154/// unwrapping it would produce plausible garbage rather than an error.
155pub fn parse_wrapped_kek(data: &[u8]) -> crate::Result<WrappedKek> {
156    const TAG_WRAPPED_KEK: u8 = 0x83;
157    const TAG_ITERATIONS: u8 = 0x84;
158    const TAG_SALT: u8 = 0x85;
159    /// RFC 3394 output for a 256-bit key: 32-byte key + 8-byte integrity check.
160    const WRAPPED_KEK_LEN: usize = 40;
161    const SALT_LEN: usize = 16;
162
163    // Reuse the crate's existing error vocabulary rather than inventing one, and
164    // carry the offending value: "wrong size" without the size is a prompt to go
165    // and look, not a diagnosis.
166    let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
167        structure: "wrapped_kek_object",
168        field,
169        value,
170        cap,
171    };
172
173    let mut wrapped_key: Option<Vec<u8>> = None;
174    let mut iterations: Option<u32> = None;
175    let mut salt: Option<Vec<u8>> = None;
176
177    let mut off = 0usize;
178    while off < data.len() {
179        let tag = *data
180            .get(off)
181            .ok_or_else(|| bad("tag", off as u64, data.len() as u64))?;
182        off += 1;
183        let first = *data
184            .get(off)
185            .ok_or_else(|| bad("length", off as u64, data.len() as u64))?;
186        off += 1;
187
188        let len = if first & 0x80 == 0 {
189            first as usize
190        } else if first == 0x81 {
191            let b = *data
192                .get(off)
193                .ok_or_else(|| bad("length_1byte", off as u64, data.len() as u64))?;
194            off += 1;
195            b as usize
196        } else if first == 0x82 {
197            let hi = *data
198                .get(off)
199                .ok_or_else(|| bad("length_2byte", off as u64, data.len() as u64))?;
200            let lo = *data
201                .get(off + 1)
202                .ok_or_else(|| bad("length_2byte", off as u64, data.len() as u64))?;
203            off += 2;
204            ((hi as usize) << 8) | lo as usize
205        } else {
206            return Err(bad("ber_length_form", u64::from(first), 0x82));
207        };
208
209        let end = off
210            .checked_add(len)
211            .ok_or_else(|| bad("value_length", len as u64, data.len() as u64))?;
212        let value = data
213            .get(off..end)
214            .ok_or_else(|| bad("value_end", end as u64, data.len() as u64))?;
215        off = end;
216
217        match tag {
218            TAG_WRAPPED_KEK => {
219                if value.len() != WRAPPED_KEK_LEN {
220                    return Err(bad(
221                        "wrapped_kek_len",
222                        value.len() as u64,
223                        WRAPPED_KEK_LEN as u64,
224                    ));
225                }
226                wrapped_key = Some(value.to_vec());
227            }
228            TAG_ITERATIONS => {
229                if value.is_empty() || value.len() > 8 {
230                    return Err(bad("iterations_len", value.len() as u64, 8));
231                }
232                // Big-endian, minimal width. Saturate rather than wrap: a hostile
233                // count must not silently become a small, fast one.
234                let mut n: u64 = 0;
235                for b in value {
236                    n = (n << 8) | u64::from(*b);
237                }
238                iterations = Some(u32::try_from(n).unwrap_or(u32::MAX));
239            }
240            TAG_SALT => {
241                if value.len() != SALT_LEN {
242                    return Err(bad("salt_len", value.len() as u64, SALT_LEN as u64));
243                }
244                salt = Some(value.to_vec());
245            }
246            _ => {}
247        }
248    }
249
250    Ok(WrappedKek {
251        wrapped_key: wrapped_key.ok_or_else(|| bad("wrapped_kek_tag_0x83", 0, 1))?,
252        iterations: iterations.ok_or_else(|| bad("iterations_tag_0x84", 0, 1))?,
253        salt: salt.ok_or_else(|| bad("salt_tag_0x85", 0, 1))?,
254    })
255}
256
257/// Unwrap an RFC 3394 AES-KW wrapped key.
258///
259/// The 8-byte integrity check AES-KW carries is what makes a wrong password
260/// *detectable*: it fails here rather than silently yielding a wrong key that
261/// only shows up later as an unreadable volume.
262///
263/// # Errors
264/// [`crate::ApfsError::FieldOutOfRange`] if the integrity check fails — which in
265/// practice means the derived key, and therefore the password, is wrong.
266pub fn aes_key_unwrap(kek: &[u8; 32], wrapped: &[u8; 40]) -> crate::Result<Vec<u8>> {
267    use aes_kw::KekAes256;
268    let kek = KekAes256::from(*kek);
269    kek.unwrap_vec(wrapped)
270        .map_err(|_| crate::ApfsError::FieldOutOfRange {
271            structure: "aes_key_wrap",
272            field: "integrity_check",
273            value: 0,
274            cap: 1,
275        })
276}
277
278/// PBKDF2-HMAC-SHA256 — the APFS password-stretching step.
279#[must_use]
280pub fn derive_key_from_password(
281    password: &[u8],
282    salt: &[u8],
283    iterations: u32,
284    out_len: usize,
285) -> Vec<u8> {
286    use hmac::Hmac;
287    use sha2::Sha256;
288
289    let mut out = vec![0u8; out_len];
290    // Iterations come from the volume and are attacker-influenced; a zero count
291    // would make derivation instant, so floor it at 1 rather than trusting it.
292    let iters = iterations.max(1);
293    // Same call shape filevault-forensic uses against a real FVDE volume.
294    // The only failure mode is an invalid output length, which cannot happen for
295    // a Vec we just sized — but it is handled rather than unwrapped, because the
296    // fleet lints deny unwrap/expect in production for exactly this reason.
297    if pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, iters, &mut out).is_err() {
298        return Vec::new();
299    }
300    out
301}
302
303/// Walk a BER definite-length TLV blob, returning the value for `want_tag`.
304///
305/// Shared by every packed object in a keybag, so a fix to the framing reaches
306/// all of them rather than only the copy that was noticed.
307fn ber_find(data: &[u8], want_tag: u8) -> Option<&[u8]> {
308    let mut off = 0usize;
309    while off + 2 <= data.len() {
310        let tag = *data.get(off)?;
311        let first = *data.get(off + 1)?;
312        off += 2;
313        let len = if first & 0x80 == 0 {
314            first as usize
315        } else if first == 0x81 {
316            let b = *data.get(off)?;
317            off += 1;
318            b as usize
319        } else if first == 0x82 {
320            let hi = *data.get(off)?;
321            let lo = *data.get(off + 1)?;
322            off += 2;
323            ((hi as usize) << 8) | lo as usize
324        } else {
325            return None;
326        };
327        let end = off.checked_add(len)?;
328        let value = data.get(off..end)?;
329        if tag == want_tag {
330            return Some(value);
331        }
332        off = end;
333    }
334    None
335}
336
337/// Offsets into `nx_superblock_t`, from the libfsapfs reference structure.
338const NX_BLOCK_SIZE: usize = 0x024;
339const NX_CONTAINER_UUID: usize = 0x048;
340const NX_KEYBAG_BLOCK: usize = 0x510;
341const NX_KEYBAG_BLOCKS: usize = 0x518;
342/// XTS sector size APFS uses for the keybag.
343const KEYBAG_SECTOR: usize = 512;
344
345/// Decrypt the container keybag referenced by the container superblock's
346/// `nx_keylocker`.
347///
348/// The keybag is AES-128-XTS encrypted with the container UUID as BOTH XTS
349/// keys. That UUID sits in plaintext in the superblock, so this layer is
350/// obfuscation rather than protection and needs no password — which is why
351/// container information is readable from a locked volume. The password only
352/// enters later, when unwrapping the KEK.
353///
354/// Reads the superblock at block 0. A container also keeps newer superblocks in
355/// its checkpoint descriptor area; selecting the highest-xid one is a separate
356/// concern and not needed to reach the keybag.
357///
358/// # Errors
359/// [`crate::ApfsError::FieldOutOfRange`] if the superblock magic is absent or
360/// the keylocker extent falls outside the image.
361pub fn decrypt_container_keybag(image: &[u8]) -> crate::Result<Vec<u8>> {
362    let uuid: [u8; 16] = image
363        .get(NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16)
364        .and_then(|s| s.try_into().ok())
365        .ok_or(crate::ApfsError::FieldOutOfRange {
366            structure: "nx_superblock",
367            field: "container_uuid",
368            value: image.len() as u64,
369            cap: (NX_CONTAINER_UUID + 16) as u64,
370        })?;
371    decrypt_container_keybag_with_uuid(image, &uuid)
372}
373
374/// As [`decrypt_container_keybag`] but with an explicit key, so a test can prove
375/// a WRONG key fails to produce a parseable keybag.
376///
377/// # Errors
378/// Same as [`decrypt_container_keybag`].
379pub fn decrypt_container_keybag_with_uuid(image: &[u8], uuid: &[u8; 16]) -> crate::Result<Vec<u8>> {
380    let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
381        structure: "nx_keylocker",
382        field,
383        value,
384        cap,
385    };
386
387    if image.get(32..36) != Some(b"NXSB") {
388        return Err(bad("magic", 0, 1));
389    }
390    let block_size = crate::bytes::le_u32(image, NX_BLOCK_SIZE) as usize;
391    if block_size == 0 || block_size % KEYBAG_SECTOR != 0 {
392        return Err(bad("block_size", block_size as u64, KEYBAG_SECTOR as u64));
393    }
394    let paddr = crate::bytes::le_u64(image, NX_KEYBAG_BLOCK) as usize;
395    let blocks = crate::bytes::le_u64(image, NX_KEYBAG_BLOCKS) as usize;
396    if paddr == 0 || blocks == 0 {
397        return Err(bad("keylocker_extent", paddr as u64, blocks as u64));
398    }
399
400    let start = paddr
401        .checked_mul(block_size)
402        .ok_or_else(|| bad("keybag_offset", paddr as u64, block_size as u64))?;
403    let len = blocks
404        .checked_mul(block_size)
405        .ok_or_else(|| bad("keybag_length", blocks as u64, block_size as u64))?;
406    let end = start
407        .checked_add(len)
408        .ok_or_else(|| bad("keybag_end", start as u64, len as u64))?;
409    let ct = image
410        .get(start..end)
411        .ok_or_else(|| bad("keybag_past_image", end as u64, image.len() as u64))?;
412
413    // One implementation of the XTS step, shared with the ranged entry point:
414    // two copies of a tweak calculation is two places for an offset bug to
415    // diverge, and the ranged path is the one real evidence goes through.
416    // Tweak is the absolute sector index, matching how the data was written.
417    Ok(decrypt_keybag_area(ct, uuid, start / KEYBAG_SECTOR))
418}
419
420/// Where a volume's unlock material lives, read from the container keybag.
421#[derive(Debug, Clone)]
422#[non_exhaustive]
423pub struct VolumeRecords {
424    /// RFC 3394 wrapped volume encryption key (40 bytes), from `KB_TAG_VOLUME_KEY`.
425    pub wrapped_vek: Vec<u8>,
426    /// Physical block address of the volume keybag, from `KB_TAG_VOLUME_UNLOCK_RECORDS`.
427    pub volume_keybag_block: u64,
428    /// Length of the volume keybag in blocks.
429    pub volume_keybag_blocks: u64,
430}
431
432/// Find a volume's records in a DECRYPTED container keybag, by volume UUID.
433///
434/// Each entry carries the UUID it belongs to, so a multi-volume container keeps
435/// key material separate. Matching on that UUID is what stops one volume's keys
436/// being handed out for another, which would decrypt to garbage and read as
437/// corruption rather than as the lookup error it is.
438///
439/// # Errors
440/// [`crate::ApfsError::FieldOutOfRange`] if the volume is absent, or an entry
441/// has a size the format does not permit.
442pub fn volume_records(keybag: &[u8], volume_uuid: &[u8; 16]) -> crate::Result<VolumeRecords> {
443    /// RFC 3394 output for a 256-bit key.
444    const WRAPPED_VEK_LEN: usize = 40;
445    /// A `prange` is two little-endian u64s: block address then block count.
446    const PRANGE_LEN: usize = 16;
447    /// BER SEQUENCE wrapping a packed object.
448    const TAG_SEQUENCE: u8 = 0x30;
449    /// Context-specific constructed tag holding the wrapped-key object.
450    const TAG_NESTED_OBJECT: u8 = 0xa3;
451    /// BER tag carrying the wrapped key itself.
452    const TAG_WRAPPED_KEY: u8 = 0x83;
453
454    let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
455        structure: "container_keybag",
456        field,
457        value,
458        cap,
459    };
460
461    let nkeys = (crate::bytes::le_u16(keybag, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
462    let mut wrapped_vek: Option<Vec<u8>> = None;
463    let mut extent: Option<(u64, u64)> = None;
464
465    let mut off = KL_ENTRIES_OFF;
466    for _ in 0..nkeys {
467        if off + KE_HEADER_LEN > keybag.len() {
468            break;
469        }
470        let entry_uuid = keybag.get(off..off + 16).unwrap_or(&[]);
471        let raw_tag = crate::bytes::le_u16(keybag, off + KE_TAG);
472        let keylen = crate::bytes::le_u16(keybag, off + KE_KEYLEN) as usize;
473        let data_off = off + KE_HEADER_LEN;
474        let data = keybag.get(data_off..data_off + keylen).unwrap_or(&[]);
475
476        if entry_uuid == volume_uuid.as_slice() {
477            match KeybagTag::from_u16(raw_tag) {
478                KeybagTag::VolumeKey => {
479                    // Nested TWO levels, per the libfsapfs reference:
480                    //   30 SEQUENCE { 80 version, 81 hmac(32), 82 meta(8),
481                    //                 a3 { 83 wrapped key(40), 84 iters, 85 salt } }
482                    // The 32-byte 0x81 is an HMAC, NOT key material. Reading it
483                    // as the key is the mistake this comment exists to prevent.
484                    let seq = ber_find(data, TAG_SEQUENCE).unwrap_or(data);
485                    let obj = ber_find(seq, TAG_NESTED_OBJECT)
486                        .ok_or_else(|| bad("volume_key_missing_0xa3", data.len() as u64, 0xa3))?;
487                    let inner = ber_find(obj, TAG_WRAPPED_KEY)
488                        .ok_or_else(|| bad("volume_key_missing_0x83", obj.len() as u64, 0x83))?;
489                    if inner.len() != WRAPPED_VEK_LEN {
490                        return Err(bad(
491                            "wrapped_vek_len",
492                            inner.len() as u64,
493                            WRAPPED_VEK_LEN as u64,
494                        ));
495                    }
496                    wrapped_vek = Some(inner.to_vec());
497                }
498                KeybagTag::VolumeUnlockRecords => {
499                    if data.len() < PRANGE_LEN {
500                        return Err(bad(
501                            "unlock_records_len",
502                            data.len() as u64,
503                            PRANGE_LEN as u64,
504                        ));
505                    }
506                    extent = Some((crate::bytes::le_u64(data, 0), crate::bytes::le_u64(data, 8)));
507                }
508                _ => {}
509            }
510        }
511        off += ((KE_HEADER_LEN + keylen + 15) & !15).max(16);
512    }
513
514    let (blk, cnt) = extent.ok_or_else(|| bad("volume_unlock_records_absent", 0, 1))?;
515    Ok(VolumeRecords {
516        wrapped_vek: wrapped_vek.ok_or_else(|| bad("volume_key_absent", 0, 1))?,
517        volume_keybag_block: blk,
518        volume_keybag_blocks: cnt,
519    })
520}
521
522/// A volume's unwrapped encryption key, recovered from a password.
523#[derive(Debug, Clone)]
524#[non_exhaustive]
525pub struct UnlockedVolume {
526    /// The volume encryption key: two AES-128-XTS keys, 32 bytes total.
527    pub vek: Vec<u8>,
528}
529
530/// Unlock a volume: password -> KEK -> VEK.
531///
532/// 1. decrypt the container keybag (AES-128-XTS, container UUID as both keys)
533/// 2. find this volume's wrapped VEK and where its own keybag lives
534/// 3. decrypt the volume keybag (same scheme, VOLUME UUID as both keys)
535/// 4. read the wrapped-KEK object: wrapped KEK, iterations, salt
536/// 5. PBKDF2-HMAC-SHA256 the password with that salt and count
537/// 6. AES-KW unwrap the KEK, then the VEK with it
538///
539/// Only step 5 uses the password. Everything before is keyed on UUIDs stored in
540/// plaintext, which is why container structure reads while a volume stays locked.
541///
542/// # Errors
543/// [`crate::ApfsError::FieldOutOfRange`] if a keybag is malformed, or if the
544/// password is wrong — AES-KW's integrity check refuses it rather than handing
545/// back a key that would decrypt to garbage.
546pub fn unlock_volume(
547    image: &[u8],
548    volume_uuid: &[u8; 16],
549    password: &str,
550) -> crate::Result<UnlockedVolume> {
551    let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
552        structure: "unlock_volume",
553        field,
554        value,
555        cap,
556    };
557
558    let container_kb = decrypt_container_keybag(image)?;
559    let rec = volume_records(&container_kb, volume_uuid)?;
560
561    let block_size = crate::bytes::le_u32(image, NX_BLOCK_SIZE) as usize;
562    let start = (rec.volume_keybag_block as usize)
563        .checked_mul(block_size)
564        .ok_or_else(|| bad("vkb_offset", rec.volume_keybag_block, block_size as u64))?;
565    let len = (rec.volume_keybag_blocks as usize)
566        .checked_mul(block_size)
567        .ok_or_else(|| bad("vkb_length", rec.volume_keybag_blocks, block_size as u64))?;
568    let end = start
569        .checked_add(len)
570        .ok_or_else(|| bad("vkb_end", start as u64, len as u64))?;
571    let ct = image
572        .get(start..end)
573        .ok_or_else(|| bad("vkb_past_image", end as u64, image.len() as u64))?;
574
575    // The volume keybag uses the same scheme keyed on the VOLUME UUID.
576    let volume_kb = decrypt_keybag_area(ct, volume_uuid, start / KEYBAG_SECTOR);
577
578    unlock_with_volume_keybag(&volume_kb, &rec, password)
579}
580
581/// Recover a volume key from an already-decrypted volume keybag.
582///
583/// The last link of the chain, taking only small buffers: the decrypted volume
584/// keybag and the records naming this volume's wrapped key. A caller that can
585/// seek reads two 4 KB areas, decrypts them with [`decrypt_keybag_area`], and
586/// finishes here — never holding the image.
587///
588/// # Errors
589/// When the keybag carries no KEK object, when a length is not what the format
590/// fixes it at, or when AES key unwrap rejects the integrity check — which is
591/// what a wrong password produces, and it is an error rather than wrong bytes.
592pub fn unlock_with_volume_keybag(
593    volume_keybag: &[u8],
594    records: &VolumeRecords,
595    password: &str,
596) -> crate::Result<UnlockedVolume> {
597    let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
598        structure: "unlock_with_volume_keybag",
599        field,
600        value,
601        cap,
602    };
603
604    let obj = find_kek_object(volume_keybag)
605        .ok_or_else(|| bad("kek_object_absent", volume_keybag.len() as u64, 1))?;
606    let kek_info = parse_wrapped_kek(obj)?;
607
608    let derived_v =
609        derive_key_from_password(password.as_bytes(), &kek_info.salt, kek_info.iterations, 32);
610    let derived: [u8; 32] = derived_v
611        .as_slice()
612        .try_into()
613        .map_err(|_| bad("derived_key_len", derived_v.len() as u64, 32))?;
614    let wrapped_kek: [u8; 40] = kek_info
615        .wrapped_key
616        .as_slice()
617        .try_into()
618        .map_err(|_| bad("wrapped_kek_len", kek_info.wrapped_key.len() as u64, 40))?;
619
620    // AES-KW's integrity check is what refuses a wrong password: it fails here
621    // rather than handing back a plausible-looking key that breaks later.
622    let kek_v = aes_key_unwrap(&derived, &wrapped_kek)?;
623    let kek: [u8; 32] = kek_v
624        .as_slice()
625        .try_into()
626        .map_err(|_| bad("kek_len", kek_v.len() as u64, 32))?;
627    let wrapped_vek: [u8; 40] = records
628        .wrapped_vek
629        .as_slice()
630        .try_into()
631        .map_err(|_| bad("wrapped_vek_len", records.wrapped_vek.len() as u64, 40))?;
632
633    Ok(UnlockedVolume {
634        vek: aes_key_unwrap(&kek, &wrapped_vek)?,
635    })
636}
637
638/// Number of `OMAP_VAL_ENCRYPTED` in `omap_val_t.ov_flags`: the object this
639/// mapping points at is stored encrypted under the volume key.
640pub const OMAP_VAL_ENCRYPTED: u32 = 0x0000_0004;
641
642/// The AES-XTS sector size APFS encrypts in, 512 bytes even on 4 KB-block media.
643pub const APFS_CRYPTO_SECTOR: usize = 0x200;
644
645/// Decrypt volume data (a B-tree node or file extent) in place with the VEK.
646///
647/// `tweak_block` is in BLOCK units and is what the format stores, not a byte
648/// offset: `ov_paddr` for a B-tree node, `crypto_id + block_index` for a file
649/// extent. It is scaled to 512-byte sectors here and incremented per sector, so
650/// a caller never does that arithmetic and cannot get it half-right.
651///
652/// Why the tweak is *stored* for extents rather than derived from position:
653/// APFS may relocate an extent without re-encrypting it, so its position stops
654/// predicting its tweak. That is the entire reason `crypto_id` exists.
655///
656/// A `tweak_block` of 0 means the data is not encrypted and is left untouched,
657/// matching the reference implementation's sentinel.
658///
659/// The 32-byte VEK is two AES-128 keys: the first 16 bytes encrypt the data,
660/// the last 16 encrypt the tweak.
661pub fn decrypt_volume_area(data: &mut [u8], vek: &[u8; 32], tweak_block: u64, block_size: usize) {
662    if tweak_block == 0 || block_size < APFS_CRYPTO_SECTOR {
663        return;
664    }
665    use aes::cipher::KeyInit;
666    use xts_mode::{get_tweak_default, Xts128};
667
668    let (k1, k2) = vek.split_at(16);
669    let (Ok(k1), Ok(k2)) = (<&[u8; 16]>::try_from(k1), <&[u8; 16]>::try_from(k2)) else {
670        return; // cov:unreachable: a 32-byte array always splits into two 16s
671    };
672    let xts = Xts128::new(aes::Aes128::new(k1.into()), aes::Aes128::new(k2.into()));
673
674    // The stored tweak is in BLOCK units; XTS works in 512-byte sectors even on
675    // 4 KB-block media, so scale once here rather than at every call site.
676    let sectors_per_block = (block_size / APFS_CRYPTO_SECTOR) as u64;
677    let first_sector = tweak_block.saturating_mul(sectors_per_block);
678    xts.decrypt_area(
679        data,
680        APFS_CRYPTO_SECTOR,
681        u128::from(first_sector),
682        get_tweak_default,
683    );
684}
685
686/// Decrypt a keybag area read from an arbitrary offset, without the image.
687///
688/// The rest of this module takes the whole image as one `&[u8]`, which is fine
689/// for a 128 MB fixture and impossible for a 2 TB disk. The unwrap chain only
690/// ever reads about 12 KB of an image — the superblock and two keybag blocks —
691/// so the whole-image parameter expressed an assumption, not a requirement.
692///
693/// This is the entry point a caller uses when it can seek: hand it the
694/// ciphertext it read, the UUID keying it (container UUID for the container
695/// keybag, volume UUID for a volume keybag), and the absolute sector index the
696/// area starts at. The sector index is not derivable from `ct` and is part of
697/// the XTS tweak, so an offset error yields noise rather than a wrong-looking
698/// success.
699pub fn decrypt_keybag_area(ct: &[u8], uuid: &[u8; 16], first_sector: usize) -> Vec<u8> {
700    xts_decrypt_area(ct, uuid, first_sector)
701}
702
703/// AES-128-XTS decrypt an area keyed by `uuid` (both XTS keys).
704fn xts_decrypt_area(ct: &[u8], uuid: &[u8; 16], first_sector: usize) -> Vec<u8> {
705    use aes::cipher::KeyInit;
706    use xts_mode::{get_tweak_default, Xts128};
707    let xts = Xts128::new(aes::Aes128::new(uuid.into()), aes::Aes128::new(uuid.into()));
708    let mut buf = ct.to_vec();
709    xts.decrypt_area(
710        &mut buf,
711        KEYBAG_SECTOR,
712        first_sector as u128,
713        get_tweak_default,
714    );
715    buf
716}
717
718/// Find the KEK object in a decrypted VOLUME keybag.
719///
720/// Tag semantics are CONTEXT-DEPENDENT and that is the trap: 0x03 means
721/// "Keybag Ref" in the CONTAINER bag but "KEK" in the VOLUME bag. Matching only
722/// `WrappingKey` (0x01) finds nothing in a real recs bag.
723fn find_kek_object(keybag: &[u8]) -> Option<&[u8]> {
724    let nkeys = (crate::bytes::le_u16(keybag, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
725    let mut off = KL_ENTRIES_OFF;
726    for _ in 0..nkeys {
727        if off + KE_HEADER_LEN > keybag.len() {
728            break;
729        }
730        let raw_tag = crate::bytes::le_u16(keybag, off + KE_TAG);
731        let keylen = crate::bytes::le_u16(keybag, off + KE_KEYLEN) as usize;
732        let data_off = off + KE_HEADER_LEN;
733        let tag = KeybagTag::from_u16(raw_tag);
734        if tag == KeybagTag::WrappingKey || tag == KeybagTag::VolumeUnlockRecords {
735            if let Some(data) = keybag.get(data_off..data_off + keylen) {
736                let seq = ber_find(data, 0x30).unwrap_or(data);
737                if let Some(obj) = ber_find(seq, 0xa3) {
738                    return Some(obj);
739                }
740            }
741        }
742        off += ((KE_HEADER_LEN + keylen + 15) & !15).max(16);
743    }
744    None
745}
746
747#[cfg(test)]
748mod tests {
749    use super::*;
750
751    /// Build a `kb_locker` keybag blob: 16-byte header (`kl_version`@0,
752    /// `kl_nkeys`@2, `kl_nbytes`@4, pad), then `keybag_entry_t` entries
753    /// (`ke_uuid`@0[16], `ke_tag`@16, `ke_keylen`@18, pad[4], `ke_keydata`@24),
754    /// each 16-byte aligned (libfsapfs layout).
755    fn keybag(entries: &[(u16, usize)]) -> Vec<u8> {
756        // A real keybag block is an APFS object: a 32-byte obj_phys header
757        // precedes the kb_locker, with the magic in o_type at +24. Building
758        // without it produced a fixture that only this parser understood --
759        // the test and the code shared one wrong assumption, so both passed.
760        let mut data = vec![0u8; OBJ_PHYS_LEN + 16];
761        data[24..28].copy_from_slice(b"keys"); // o_type
762        data[OBJ_PHYS_LEN..OBJ_PHYS_LEN + 2].copy_from_slice(&1u16.to_le_bytes()); // kl_version
763        data[OBJ_PHYS_LEN + 2..OBJ_PHYS_LEN + 4]
764            .copy_from_slice(&(entries.len() as u16).to_le_bytes()); // kl_nkeys
765        for &(tag, keylen) in entries {
766            let mut e = vec![0u8; 24 + keylen];
767            e[16..18].copy_from_slice(&tag.to_le_bytes()); // ke_tag
768            e[18..20].copy_from_slice(&(keylen as u16).to_le_bytes()); // ke_keylen
769            let padded = (e.len() + 15) & !15; // 16-byte align
770            e.resize(padded, 0);
771            data.extend_from_slice(&e);
772        }
773        let nbytes = data.len() as u32;
774        data[4..8].copy_from_slice(&nbytes.to_le_bytes()); // kl_nbytes
775        data
776    }
777
778    #[test]
779    fn reads_volume_key_and_hint_tags() {
780        // A volume keybag with a wrapped volume key and a passphrase hint.
781        let kb = keybag(&[(0x02, 32), (0x04, 8)]);
782        let st = read_keybag(&kb).expect("parse keybag");
783        assert!(st.encrypted, "a keybag with a volume key is encrypted");
784        assert!(st.tags_present.contains(&KeybagTag::VolumeKey));
785        assert!(st.tags_present.contains(&KeybagTag::VolumePassphraseHint));
786        assert!(st.has_passphrase_hint);
787    }
788
789    #[test]
790    fn from_u16_maps_every_known_tag() {
791        // Each documented KB_TAG_* value decodes to its named variant; a wrapping
792        // key, unlock records, or user payload all round-trip through from_u16.
793        assert_eq!(KeybagTag::from_u16(0x01), KeybagTag::WrappingKey);
794        assert_eq!(KeybagTag::from_u16(0x02), KeybagTag::VolumeKey);
795        assert_eq!(KeybagTag::from_u16(0x03), KeybagTag::VolumeUnlockRecords);
796        assert_eq!(KeybagTag::from_u16(0x04), KeybagTag::VolumePassphraseHint);
797        assert_eq!(KeybagTag::from_u16(0xf8), KeybagTag::UserPayload);
798        assert_eq!(KeybagTag::from_u16(0x99), KeybagTag::Unknown);
799    }
800
801    #[test]
802    fn wrapping_key_and_unlock_records_are_encrypted() {
803        // A volume keybag with a wrapping key (0x01) and unlock records (0x03) is
804        // encrypted, and both named tags are surfaced.
805        let kb = keybag(&[(0x01, 32), (0x03, 16)]);
806        let st = read_keybag(&kb).expect("parse keybag");
807        assert!(st.encrypted, "wrapping-key / unlock-records ⇒ encrypted");
808        assert!(st.tags_present.contains(&KeybagTag::WrappingKey));
809        assert!(st.tags_present.contains(&KeybagTag::VolumeUnlockRecords));
810    }
811
812    #[test]
813    fn empty_keybag_reports_not_encrypted() {
814        let kb = keybag(&[]);
815        let st = read_keybag(&kb).expect("parse empty keybag");
816        assert!(!st.encrypted);
817        assert!(st.tags_present.is_empty());
818        assert!(!st.has_passphrase_hint);
819    }
820
821    #[test]
822    fn unknown_tag_maps_to_unknown_and_records_raw_value() {
823        // A reserved/unexpected tag must decode as Unknown (never panic) and its
824        // raw value + offset must be retained for the show-the-value rule.
825        let kb = keybag(&[(0x55, 4)]);
826        let st = read_keybag(&kb).expect("parse keybag");
827        assert!(st.tags_present.contains(&KeybagTag::Unknown));
828        // Offset is derived, not hardcoded: entries begin after obj_phys +
829        // kb_locker, so a header-size change moves this test with the format
830        // instead of silently asserting a stale constant.
831        assert_eq!(st.unknown_tags, vec![(0x55u16, KL_ENTRIES_OFF as u64)]);
832    }
833
834    #[test]
835    fn header_claiming_more_entries_than_the_blob_stops_early() {
836        // kl_nkeys says 4 entries but the blob is only the 16-byte header + 8
837        // bytes: the walk must break at the first entry that would over-read,
838        // never panic or over-read (bounds-safe against a lying count).
839        let mut data = vec![0u8; 24];
840        data[0..2].copy_from_slice(&1u16.to_le_bytes()); // kl_version
841        data[2..4].copy_from_slice(&4u16.to_le_bytes()); // kl_nkeys (lies)
842        let st = read_keybag(&data).expect("parse truncated keybag");
843        assert!(st.tags_present.is_empty(), "no entry fits → nothing parsed");
844    }
845
846    /// RED: a wrapped-KEK object is a BER TLV blob carrying the three inputs the
847    /// unwrap chain needs — the 40-byte wrapped KEK (`0x83`), the PBKDF2
848    /// iteration count (`0x84`) and the 16-byte salt (`0x85`). Sizes per the
849    /// libfsapfs reference: 40 is exactly RFC 3394 output for a 256-bit key.
850    ///
851    /// Asserts the extracted VALUES, not merely that parsing returned Ok — a
852    /// parser that yielded zeros would otherwise pass.
853    #[test]
854    fn wrapped_kek_object_yields_salt_iterations_and_wrapped_key() {
855        let mut blob = Vec::new();
856        // 0x82 metadata (8 bytes) — present in real objects, must be skipped
857        blob.push(0x82u8);
858        blob.push(8u8);
859        blob.extend_from_slice(&[2, 0, 0, 0, 0, 0, 0, 0]);
860        // 0x83 wrapped KEK, exactly 40 bytes, long-form length (0x81 + len)
861        blob.push(0x83u8);
862        blob.push(0x81u8);
863        blob.push(40u8);
864        let wrapped: Vec<u8> = (0..40u8)
865            .map(|i| i.wrapping_mul(7).wrapping_add(3))
866            .collect();
867        blob.extend_from_slice(&wrapped);
868        // 0x84 iteration count, big-endian, minimal width
869        blob.push(0x84u8);
870        blob.push(3u8);
871        blob.extend_from_slice(&[0x01, 0xE8, 0x48]); // 125_000
872                                                     // 0x85 salt, exactly 16 bytes
873        blob.push(0x85u8);
874        blob.push(16u8);
875        let salt: Vec<u8> = (0..16u8).map(|i| i ^ 0x5A).collect();
876        blob.extend_from_slice(&salt);
877
878        let kek = parse_wrapped_kek(&blob).expect("a well-formed wrapped-KEK object must parse");
879        assert_eq!(
880            kek.wrapped_key.as_slice(),
881            wrapped.as_slice(),
882            "wrapped KEK bytes"
883        );
884        assert_eq!(kek.iterations, 125_000, "PBKDF2 iteration count");
885        assert_eq!(kek.salt.as_slice(), salt.as_slice(), "PBKDF2 salt");
886    }
887
888    /// Sizes are invariants, not suggestions. A hostile blob declaring a short
889    /// wrapped key must be REFUSED: a 32-byte value cannot be RFC 3394 output,
890    /// and unwrapping it would yield plausible garbage rather than an error.
891    ///
892    /// The blob is otherwise COMPLETE — valid iterations and salt — so the wrong
893    /// size is the only defect. An earlier version omitted those fields and
894    /// passed even with the size check deleted, because it was erroring on the
895    /// missing iteration count instead. A rejection test must fail for the one
896    /// reason it names.
897    #[test]
898    fn wrapped_kek_object_rejects_a_wrong_sized_wrapped_key() {
899        let mut blob = Vec::new();
900        blob.push(0x83u8);
901        blob.push(32u8); // WRONG: must be 40
902        blob.extend_from_slice(&[0u8; 32]);
903        blob.push(0x84u8);
904        blob.push(2u8);
905        blob.extend_from_slice(&[0x27, 0x10]); // 10_000 — valid
906        blob.push(0x85u8);
907        blob.push(16u8);
908        blob.extend_from_slice(&[0u8; 16]); // valid
909
910        let err = parse_wrapped_kek(&blob)
911            .expect_err("a 32-byte wrapped key must be rejected; only 40 is valid");
912        // Assert on the FIELD, so passing for some unrelated reason is not enough.
913        let msg = format!("{err}");
914        assert!(
915            msg.contains("wrapped_kek_len"),
916            "must be rejected for the wrapped-key SIZE, got: {msg}"
917        );
918    }
919
920    /// RED (Tier-1): AES key unwrap must match RFC 3394's own published test
921    /// vector. Third-party artifact AND answer key, so this is not
922    /// self-validating — section 4.6, "Wrap 256 bits of Key Data with a 256-bit
923    /// KEK".
924    #[test]
925    fn aes_key_unwrap_matches_rfc3394_vector() {
926        let kek: [u8; 32] = [
927            0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
928            0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B,
929            0x1C, 0x1D, 0x1E, 0x1F,
930        ];
931        let wrapped: [u8; 40] = [
932            0x28, 0xC9, 0xF4, 0x04, 0xC4, 0xB8, 0x10, 0xF4, 0xCB, 0xCC, 0xB3, 0x5C, 0xFB, 0x87,
933            0xF8, 0x26, 0x3F, 0x57, 0x86, 0xE2, 0xD8, 0x0E, 0xD3, 0x26, 0xCB, 0xC7, 0xF0, 0xE7,
934            0x1A, 0x99, 0xF4, 0x3B, 0xFB, 0x98, 0x8B, 0x9B, 0x7A, 0x02, 0xDD, 0x21,
935        ];
936        let expected: [u8; 32] = [
937            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
938            0xEE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
939            0x0C, 0x0D, 0x0E, 0x0F,
940        ];
941        let got = aes_key_unwrap(&kek, &wrapped).expect("RFC 3394 vector must unwrap");
942        assert_eq!(
943            got.as_slice(),
944            expected.as_slice(),
945            "RFC 3394 s4.6 unwrapped key"
946        );
947    }
948
949    /// RED: the integrity check is the whole point of AES-KW. A wrong KEK must be
950    /// REFUSED, not returned as garbage — otherwise a wrong password silently
951    /// yields a wrong key and every later failure is misattributed.
952    #[test]
953    fn aes_key_unwrap_rejects_a_wrong_kek() {
954        let wrong = [0xAAu8; 32];
955        let wrapped: [u8; 40] = [
956            0x28, 0xC9, 0xF4, 0x04, 0xC4, 0xB8, 0x10, 0xF4, 0xCB, 0xCC, 0xB3, 0x5C, 0xFB, 0x87,
957            0xF8, 0x26, 0x3F, 0x57, 0x86, 0xE2, 0xD8, 0x0E, 0xD3, 0x26, 0xCB, 0xC7, 0xF0, 0xE7,
958            0x1A, 0x99, 0xF4, 0x3B, 0xFB, 0x98, 0x8B, 0x9B, 0x7A, 0x02, 0xDD, 0x21,
959        ];
960        assert!(
961            aes_key_unwrap(&wrong, &wrapped).is_err(),
962            "a wrong KEK must fail the AES-KW integrity check, never return bytes"
963        );
964    }
965
966    /// Tier-1: the production derivation must match RFC 7914 section 11's
967    /// published PBKDF2-HMAC-SHA256 vector (c=80000) — third-party artifact and
968    /// answer key, and a high iteration count so the loop is exercised rather
969    /// than a single pass.
970    ///
971    /// Tests `derive_key_from_password` itself, not a test-only variant: a
972    /// vector that validates a function production never calls proves nothing.
973    #[test]
974    fn pbkdf2_sha256_matches_rfc7914_vector() {
975        let got = derive_key_from_password(b"Password", b"NaCl", 80_000, 64);
976        let expected: [u8; 64] = [
977            0x4D, 0xDC, 0xD8, 0xF6, 0x0B, 0x98, 0xBE, 0x21, 0x83, 0x0C, 0xEE, 0x5E, 0xF2, 0x27,
978            0x01, 0xF9, 0x64, 0x1A, 0x44, 0x18, 0xD0, 0x4C, 0x04, 0x14, 0xAE, 0xFF, 0x08, 0x87,
979            0x6B, 0x34, 0xAB, 0x56, 0xA1, 0xD4, 0x25, 0xA1, 0x22, 0x58, 0x33, 0x54, 0x9A, 0xDB,
980            0x84, 0x1B, 0x51, 0xC9, 0xB3, 0x17, 0x6A, 0x27, 0x2B, 0xDE, 0xBB, 0xA1, 0xD0, 0x78,
981            0x47, 0x8F, 0x62, 0xB3, 0x97, 0xF3, 0x3C, 0x8D,
982        ];
983        assert_eq!(
984            got.as_slice(),
985            expected.as_slice(),
986            "RFC 7914 s11 PBKDF2-SHA256 c=80000"
987        );
988    }
989
990    /// Load the committed fixture: a real macOS-minted APFS-native encrypted
991    /// volume. Gzipped so CI validates from committed bytes with no download.
992    #[cfg(test)]
993    fn fixture_image() -> Vec<u8> {
994        use std::io::Read as _;
995        let gz = include_bytes!("../../tests/data/filevault/apfs-native-filevault.raw.gz");
996        let mut out = Vec::new();
997        flate2::read::GzDecoder::new(&gz[..])
998            .read_to_end(&mut out)
999            .expect("fixture must decompress");
1000        out
1001    }
1002
1003    /// RED: the container keybag is stored AES-128-XTS encrypted, keyed by the
1004    /// container UUID (both XTS keys), tweaked by sector number. Decrypting it
1005    /// must yield a PARSEABLE keybag — not noise.
1006    ///
1007    /// Asserts on structure recovered from real Apple-written bytes, so a
1008    /// wrong key or tweak cannot pass: garbage does not parse into known tags.
1009    #[test]
1010    fn container_keybag_decrypts_to_a_parseable_keybag() {
1011        let img = fixture_image();
1012        let sb = &img[..4096];
1013        assert_eq!(
1014            &sb[32..36],
1015            b"NXSB",
1016            "fixture must start with a container superblock"
1017        );
1018
1019        let kb = decrypt_container_keybag(&img).expect("container keybag must decrypt");
1020        let state = read_keybag(&kb).expect("decrypted keybag must parse");
1021
1022        // A real container keybag names the per-volume key and unlock records.
1023        assert!(
1024            state.tags_present.contains(&KeybagTag::VolumeKey)
1025                || state.tags_present.contains(&KeybagTag::VolumeUnlockRecords),
1026            "decrypted container keybag must carry VolumeKey or VolumeUnlockRecords, got {:?}",
1027            state.tags_present
1028        );
1029    }
1030
1031    /// RED: a wrong key must NOT yield something that parses as a keybag.
1032    /// Without this, "it parsed" could be an artifact of a permissive parser
1033    /// rather than evidence the decryption was correct.
1034    #[test]
1035    fn container_keybag_with_a_wrong_uuid_does_not_parse_as_a_keybag() {
1036        let img = fixture_image();
1037        let kb = decrypt_container_keybag_with_uuid(&img, &[0xAA; 16])
1038            .expect("decryption itself should run; only the RESULT must be wrong");
1039        let parsed = read_keybag(&kb);
1040        let bogus = match parsed {
1041            Err(_) => true,
1042            Ok(s) => {
1043                !s.tags_present.contains(&KeybagTag::VolumeKey)
1044                    && !s.tags_present.contains(&KeybagTag::VolumeUnlockRecords)
1045            }
1046        };
1047        assert!(
1048            bogus,
1049            "a wrong UUID must not produce a keybag carrying real volume tags"
1050        );
1051    }
1052
1053    /// The volume UUID of the committed fixture, as reported independently by
1054    /// the libfsapfs oracle: 75aaf7f9-bf5a-4a4f-86c0-3ef90936bd7c.
1055    #[cfg(test)]
1056    const FIXTURE_VOLUME_UUID: [u8; 16] = [
1057        0x75, 0xAA, 0xF7, 0xF9, 0xBF, 0x5A, 0x4A, 0x4F, 0x86, 0xC0, 0x3E, 0xF9, 0x09, 0x36, 0xBD,
1058        0x7C,
1059    ];
1060
1061    /// RED: the container keybag names, per volume, the wrapped VEK
1062    /// (`KB_TAG_VOLUME_KEY`) and where that volume's own keybag lives
1063    /// (`KB_TAG_VOLUME_UNLOCK_RECORDS`). Both are needed before a password can
1064    /// be applied.
1065    ///
1066    /// Asserts sizes and plausibility, not just presence: a 40-byte wrapped VEK
1067    /// is RFC 3394 output for a 256-bit key, and a keybag extent must land
1068    /// inside the image.
1069    #[test]
1070    fn container_keybag_yields_this_volumes_wrapped_vek_and_keybag_extent() {
1071        let img = fixture_image();
1072        let kb = decrypt_container_keybag(&img).expect("container keybag must decrypt");
1073        let rec = volume_records(&kb, &FIXTURE_VOLUME_UUID)
1074            .expect("the fixture's volume must be present in its container keybag");
1075
1076        assert_eq!(
1077            rec.wrapped_vek.len(),
1078            40,
1079            "wrapped VEK must be RFC 3394 output for a 256-bit key"
1080        );
1081        assert!(
1082            rec.volume_keybag_block > 0,
1083            "volume keybag block must be set"
1084        );
1085        assert!(
1086            rec.volume_keybag_blocks > 0,
1087            "volume keybag length must be non-zero"
1088        );
1089        let end = (rec.volume_keybag_block as usize + rec.volume_keybag_blocks as usize) * 4096;
1090        assert!(
1091            end <= img.len(),
1092            "volume keybag extent must lie inside the container"
1093        );
1094    }
1095
1096    /// RED: an unknown volume UUID must be REFUSED, not answered with the first
1097    /// volume that happens to be present. Returning the wrong volume's key
1098    /// material would decrypt to garbage and be misread as a corrupt volume.
1099    #[test]
1100    fn container_keybag_refuses_an_unknown_volume_uuid() {
1101        let img = fixture_image();
1102        let kb = decrypt_container_keybag(&img).expect("container keybag must decrypt");
1103        assert!(
1104            volume_records(&kb, &[0x11; 16]).is_err(),
1105            "an absent volume UUID must be an error, never another volume's keys"
1106        );
1107    }
1108
1109    /// RED: the whole chain, end to end, on the real fixture with the real
1110    /// password — container keybag -> volume keybag -> KEK -> VEK.
1111    ///
1112    /// This is the test that decides whether volume unlock actually works.
1113    #[test]
1114    fn unlock_volume_recovers_the_vek_with_the_correct_password() {
1115        let img = fixture_image();
1116        let u = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
1117            .expect("the fixture must unlock with its recorded password");
1118        assert_eq!(u.vek.len(), 32, "VEK is two AES-128-XTS keys, 32 bytes");
1119        assert!(u.vek.iter().any(|&b| b != 0), "VEK must not be all zeros");
1120    }
1121
1122    /// RED: a WRONG password must be refused by AES-KW's integrity check, never
1123    /// answered with bytes. Without this a wrong password yields a wrong key and
1124    /// every later failure is misattributed to a corrupt volume.
1125    #[test]
1126    fn unlock_volume_refuses_a_wrong_password() {
1127        let img = fixture_image();
1128        assert!(
1129            unlock_volume(&img, &FIXTURE_VOLUME_UUID, "not-the-password").is_err(),
1130            "a wrong password must fail the integrity check, never return a key"
1131        );
1132    }
1133
1134    /// RED: the ranged entry point must recover EXACTLY what the whole-image
1135    /// path recovers.
1136    ///
1137    /// Why this matters beyond tidiness: every other entry point here demands
1138    /// the entire image as one slice. A 2 TB acquisition cannot be handed to
1139    /// one, so the code is untestable against real evidence — the defect a
1140    /// fixture-only suite structurally cannot see, because the fixture fits in
1141    /// RAM. Asserting byte equality against the path it replaces is what makes
1142    /// the ranged read trustworthy rather than merely convenient.
1143    #[test]
1144    fn keybag_area_decrypts_identically_from_a_ranged_read() {
1145        let img = fixture_image();
1146
1147        // Exactly the fields a seeking caller reads out of the superblock.
1148        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1149        let paddr = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCK) as usize;
1150        let blocks = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCKS) as usize;
1151        let uuid: [u8; 16] = img[NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16]
1152            .try_into()
1153            .expect("16 bytes");
1154
1155        let start = paddr * block_size;
1156        let end = start + blocks * block_size;
1157
1158        // Only the keybag blocks — what a ranged file read would return.
1159        let ranged = decrypt_keybag_area(&img[start..end], &uuid, start / KEYBAG_SECTOR);
1160        let whole = decrypt_container_keybag(&img).expect("whole-image path must decrypt");
1161
1162        assert_eq!(
1163            ranged, whole,
1164            "a ranged read must decrypt to the same bytes as the whole-image path"
1165        );
1166        // `o_type` is a u32 CONSTANT, not a char array: the value spells "keys"
1167        // read big-endian, so on disk it lands byte-reversed as "syek". Taking
1168        // the module comment literally would assert b"keys" and never pass.
1169        assert_eq!(
1170            ranged.get(24..28),
1171            Some(&b"syek"[..]),
1172            "and those bytes must be a container keybag, not noise"
1173        );
1174    }
1175
1176    /// Walk the whole unlock chain using ONLY ranged reads, and report how many
1177    /// bytes of the image were touched.
1178    ///
1179    /// This is the shape a caller with a 2 TB image must use, exercised here
1180    /// against the fixture so it is covered by the committed suite.
1181    #[cfg(test)]
1182    fn ranged_unlock(img: &[u8], volume_uuid: &[u8; 16], password: &str) -> (Vec<u8>, usize) {
1183        let mut bytes_read = 0usize;
1184
1185        // (1) the superblock — the only fixed-offset read.
1186        let sb = &img[..4096];
1187        bytes_read += sb.len();
1188        let block_size = crate::bytes::le_u32(sb, NX_BLOCK_SIZE) as usize;
1189        let uuid: [u8; 16] = sb[NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16]
1190            .try_into()
1191            .expect("16 bytes");
1192        let cs = crate::bytes::le_u64(sb, NX_KEYBAG_BLOCK) as usize * block_size;
1193        let ce = cs + crate::bytes::le_u64(sb, NX_KEYBAG_BLOCKS) as usize * block_size;
1194
1195        // (2) the container keybag.
1196        bytes_read += ce - cs;
1197        let ckb = decrypt_keybag_area(&img[cs..ce], &uuid, cs / KEYBAG_SECTOR);
1198        let rec = volume_records(&ckb, volume_uuid).expect("volume must be listed");
1199
1200        // (3) the volume keybag, keyed on the VOLUME uuid.
1201        let vs = rec.volume_keybag_block as usize * block_size;
1202        let ve = vs + rec.volume_keybag_blocks as usize * block_size;
1203        bytes_read += ve - vs;
1204        let vkb = decrypt_keybag_area(&img[vs..ve], volume_uuid, vs / KEYBAG_SECTOR);
1205
1206        let vek = unlock_with_volume_keybag(&vkb, &rec, password)
1207            .map(|u| u.vek)
1208            .unwrap_or_default();
1209        (vek, bytes_read)
1210    }
1211
1212    /// RED: the full chain must recover the SAME VEK from ranged reads as from
1213    /// the whole-image path, touching a trivial fraction of the image.
1214    ///
1215    /// The byte-count assertion is the one that matters for real evidence: it
1216    /// is the difference between an API that works on a 2 TB acquisition and
1217    /// one that only ever ran against a fixture small enough to hide the
1218    /// problem. Asserting VEK equality stops the cheap path from drifting into
1219    /// a different answer than the path it replaces.
1220    #[test]
1221    fn the_full_unlock_chain_works_from_ranged_reads_alone() {
1222        let img = fixture_image();
1223        let (got, bytes_read) = ranged_unlock(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026");
1224        let want = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
1225            .expect("whole-image path must unlock");
1226
1227        assert_eq!(
1228            got, want.vek,
1229            "the ranged chain must recover the same VEK as the whole-image path"
1230        );
1231        assert!(
1232            bytes_read <= 64 * 1024,
1233            "the unwrap chain must touch a trivial slice of the image, read {bytes_read} bytes"
1234        );
1235
1236        // Equality alone is a DIVERGENCE guard, not a correctness proof: both
1237        // paths now share an implementation, so a bug in the shared code moves
1238        // both sides together and the comparison stays true. Demonstrated by
1239        // mutation -- stubbing the unwrap to a constant left this test green.
1240        //
1241        // Pinning the value catches that case. It is a REGRESSION PIN against
1242        // the current implementation, not independent validation: it proves the
1243        // answer stopped changing, never that it was right to begin with. What
1244        // would make it Tier-1 is decrypting the fixture's plaintext marker
1245        // with this VEK, which is not yet built.
1246        use sha2::{Digest as _, Sha256};
1247        assert_eq!(
1248            Sha256::digest(&got)[..],
1249            hex_literal_vek_fingerprint()[..],
1250            "the fixture's VEK changed; a shared-path bug moves both sides at once"
1251        );
1252    }
1253
1254    /// SHA-256 of the VEK the committed fixture yields for its recorded
1255    /// password. Stored hashed so the key itself is not in the repository.
1256    #[cfg(test)]
1257    fn hex_literal_vek_fingerprint() -> [u8; 32] {
1258        [
1259            0x12, 0xed, 0x22, 0xee, 0x47, 0xf9, 0x27, 0x28, 0x8c, 0x6e, 0xb2, 0x44, 0xf6, 0xa6,
1260            0xd3, 0x54, 0xa2, 0xb5, 0xed, 0x14, 0x18, 0xa6, 0x34, 0x11, 0x64, 0x9d, 0x4e, 0x4a,
1261            0xbc, 0xa5, 0xb1, 0x43,
1262        ]
1263    }
1264
1265    /// RED: a wrong password must be refused on the ranged path too. A cheaper
1266    /// route that quietly answers where the original refuses would be a
1267    /// security defect, not an optimisation.
1268    #[test]
1269    fn the_ranged_chain_refuses_a_wrong_password() {
1270        let img = fixture_image();
1271        let (vek, _) = ranged_unlock(&img, &FIXTURE_VOLUME_UUID, "not-the-password");
1272        assert!(
1273            vek.is_empty(),
1274            "a wrong password must fail the ranged chain, never return a key"
1275        );
1276    }
1277
1278    /// RED: the absolute sector index is part of the XTS tweak and cannot be
1279    /// derived from the ciphertext. A caller that computes it wrongly must get
1280    /// noise, not a plausible-looking keybag — otherwise an offset bug reads as
1281    /// a decryption failure and gets misattributed to the password.
1282    #[test]
1283    fn keybag_area_with_a_wrong_sector_index_yields_noise() {
1284        let img = fixture_image();
1285        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1286        let paddr = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCK) as usize;
1287        let blocks = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCKS) as usize;
1288        let uuid: [u8; 16] = img[NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16]
1289            .try_into()
1290            .expect("16 bytes");
1291        let start = paddr * block_size;
1292        let end = start + blocks * block_size;
1293
1294        let wrong = decrypt_keybag_area(&img[start..end], &uuid, start / KEYBAG_SECTOR + 1);
1295        assert_eq!(
1296            wrong.len(),
1297            end - start,
1298            "a decrypted area must be the same length as its ciphertext"
1299        );
1300        assert_ne!(
1301            wrong.get(24..28),
1302            Some(&b"syek"[..]),
1303            "a wrong sector index must not still produce a valid keybag magic"
1304        );
1305    }
1306
1307    /// RED: the filesystem tree of an encrypted volume is itself ciphertext.
1308    /// Decrypting its root node with the VEK must produce a node whose stored
1309    /// Fletcher-64 checksum verifies.
1310    ///
1311    /// The checksum is the oracle and it is why this test cannot pass by
1312    /// accident: it is computed over PLAINTEXT and stored inside the block, so
1313    /// a wrong key, wrong key split, wrong tweak, or wrong sector size yields
1314    /// bytes whose checksum cannot match. Apple wrote the expected value into
1315    /// the block; nothing here compares against our own expectation.
1316    #[test]
1317    fn the_encrypted_fs_tree_root_decrypts_to_a_checksum_valid_node() {
1318        let img = fixture_image();
1319        let vek_v = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
1320            .expect("fixture must unlock")
1321            .vek;
1322        let vek: [u8; 32] = vek_v.as_slice().try_into().expect("VEK is 32 bytes");
1323
1324        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1325        let mut cur = std::io::Cursor::new(&img[..]);
1326
1327        // Locate the volume superblock (APSB is plaintext) and its object map.
1328        let (entry, _vol) = fs_tree_root(&img, &mut cur, block_size);
1329
1330        assert_ne!(
1331            entry.flags & OMAP_VAL_ENCRYPTED,
1332            0,
1333            "the fixture's fs-tree root must be flagged encrypted, else this test proves nothing"
1334        );
1335
1336        let mut node = read_block_at(&img, entry.paddr, block_size);
1337        assert_ne!(
1338            crate::object::fletcher64_checksum(&node),
1339            crate::object::fletcher64_stored(&node),
1340            "ciphertext must NOT already checksum, or the block was never encrypted"
1341        );
1342
1343        decrypt_volume_area(&mut node, &vek, entry.paddr, block_size);
1344
1345        assert_eq!(
1346            crate::object::fletcher64_checksum(&node),
1347            crate::object::fletcher64_stored(&node),
1348            "the decrypted fs-tree root must pass its own Fletcher-64 checksum"
1349        );
1350        assert!(
1351            crate::btree::parse_node_header(&node).is_some(),
1352            "and must parse as a B-tree node"
1353        );
1354    }
1355
1356    /// RED: a WRONG key must not produce a checksum-valid node. Without this,
1357    /// "it checksummed" could be an artifact of a permissive check rather than
1358    /// evidence the decryption was right.
1359    #[test]
1360    fn a_wrong_vek_does_not_produce_a_checksum_valid_node() {
1361        let img = fixture_image();
1362        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1363        let mut cur = std::io::Cursor::new(&img[..]);
1364        let (entry, _vol) = fs_tree_root(&img, &mut cur, block_size);
1365
1366        let mut node = read_block_at(&img, entry.paddr, block_size);
1367        decrypt_volume_area(&mut node, &[0xAB; 32], entry.paddr, block_size);
1368        assert_ne!(
1369            crate::object::fletcher64_checksum(&node),
1370            crate::object::fletcher64_stored(&node),
1371            "a wrong VEK must not yield a checksum-valid node"
1372        );
1373    }
1374
1375    /// Resolve the fixture's volume and its filesystem-tree root omap entry.
1376    ///
1377    /// The container superblock, the object maps and the APSB are all plaintext
1378    /// -- APFS encrypts volume CONTENTS, not the container metadata that finds
1379    /// them -- so this whole descent works without the key.
1380    #[cfg(test)]
1381    fn fs_tree_root(
1382        img: &[u8],
1383        cur: &mut std::io::Cursor<&[u8]>,
1384        block_size: usize,
1385    ) -> (crate::omap::OmapEntry, crate::volume::ApfsVolume) {
1386        let nx = crate::container::NxSuperblock::parse(&img[..block_size])
1387            .expect("container superblock parses");
1388        let nx_omap = crate::omap::ObjectMap::parse(&read_block_at(img, nx.omap_oid, block_size))
1389            .expect("container omap parses");
1390        let fs_oid = *nx.fs_oids.first().expect("at least one volume");
1391        let vol_entry = nx_omap
1392            .resolve(cur, fs_oid, u64::MAX, block_size)
1393            .expect("volume oid resolves");
1394        let vol =
1395            crate::volume::ApfsVolume::parse(&read_block_at(img, vol_entry.paddr, block_size))
1396                .expect("APSB parses (it is plaintext)");
1397        let vol_omap =
1398            crate::omap::ObjectMap::parse(&read_block_at(img, vol.omap_oid(), block_size))
1399                .expect("volume omap parses");
1400        let entry = vol_omap
1401            .resolve(cur, vol.root_tree_oid(), u64::MAX, block_size)
1402            .expect("fs-tree root resolves");
1403        (entry, vol)
1404    }
1405
1406    /// Read one block by physical address.
1407    #[cfg(test)]
1408    fn read_block_at(img: &[u8], paddr: u64, block_size: usize) -> Vec<u8> {
1409        let start = paddr as usize * block_size;
1410        img[start..start + block_size].to_vec()
1411    }
1412
1413    /// The plaintext written into the fixture BEFORE it was encrypted. Its
1414    /// absence from the raw image is what proves the fixture is genuinely
1415    /// encrypted; its recovery here is what proves we can decrypt it.
1416    #[cfg(test)]
1417    const MARKER: &str = "APFS-FILEVAULT-GROUND-TRUTH-MARKER-0123456789";
1418
1419    /// RED: read a real file off an encrypted volume and get its known bytes.
1420    ///
1421    /// This is the claim every earlier test stopped short of. A recovered VEK
1422    /// that passes AES-KW proves the key is AUTHENTIC; a checksum-valid node
1423    /// proves it is CORRECT; only this proves the whole stack -- node
1424    /// decryption, extent `crypto_id` tweaks, and assembly -- actually yields the
1425    /// file a user wrote.
1426    ///
1427    /// Ground truth is independent of the reader: the marker string was written
1428    /// by macOS before encryption and is absent from the raw image (asserted
1429    /// below), so it cannot be produced by anything except correct decryption.
1430    #[test]
1431    fn a_file_on_an_encrypted_volume_reads_back_as_its_known_plaintext() {
1432        let img = fixture_image();
1433
1434        assert!(
1435            !img.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
1436            "the marker must NOT appear in the raw image, or the volume was never encrypted \
1437             and this test would pass without decrypting anything"
1438        );
1439
1440        let vek_v = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
1441            .expect("fixture must unlock")
1442            .vek;
1443        let vek: [u8; 32] = vek_v.as_slice().try_into().expect("VEK is 32 bytes");
1444
1445        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1446        let mut cur = std::io::Cursor::new(&img[..]);
1447        let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
1448        vol.set_vek(vek);
1449
1450        let inode = crate::dir::open_path(&mut cur, &vol, "marker.txt", block_size)
1451            .expect("marker.txt must be found on the decrypted fs-tree");
1452        let data = crate::extent::read_data(&mut cur, &vol, &inode, block_size)
1453            .expect("its contents must decrypt");
1454
1455        assert_eq!(
1456            String::from_utf8_lossy(&data).trim_end(),
1457            MARKER,
1458            "the decrypted file must be byte-for-byte what macOS wrote"
1459        );
1460    }
1461
1462    /// RED: without the key the same read must FAIL, not return garbage.
1463    /// Returning plausible bytes for a locked volume would be evidence
1464    /// fabrication -- the worst outcome available to a forensic reader.
1465    #[test]
1466    fn the_same_file_is_unreadable_without_the_key() {
1467        let img = fixture_image();
1468        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1469        let mut cur = std::io::Cursor::new(&img[..]);
1470        let (_, vol) = fs_tree_root(&img, &mut cur, block_size);
1471
1472        let r = crate::dir::open_path(&mut cur, &vol, "marker.txt", block_size)
1473            .and_then(|i| crate::extent::read_data(&mut cur, &vol, &i, block_size));
1474        match r {
1475            Err(_) => {}
1476            Ok(data) => panic!(
1477                "a locked volume must refuse, not answer; got {} bytes",
1478                data.len()
1479            ),
1480        }
1481    }
1482
1483    // ── TIER 1: third-party artifact, third-party password, third-party answer key ──
1484
1485    /// The dfVFS test image: a GPT-partitioned raw image whose APFS container
1486    /// begins at byte 20480 (partition 1).
1487    #[cfg(test)]
1488    const DFVFS_CONTAINER_OFFSET: usize = 20480;
1489
1490    /// Load the dfVFS encrypted APFS image and return its CONTAINER bytes.
1491    #[cfg(test)]
1492    fn dfvfs_container() -> Vec<u8> {
1493        use std::io::Read as _;
1494        let gz = include_bytes!("../../tests/data/filevault/dfvfs-apfs-encrypted.dmg.gz");
1495        let mut out = Vec::new();
1496        flate2::read::GzDecoder::new(&gz[..])
1497            .read_to_end(&mut out)
1498            .expect("dfVFS fixture must decompress");
1499        out.split_off(DFVFS_CONTAINER_OFFSET)
1500    }
1501
1502    /// TIER 1: read files from an image WE DID NOT MAKE, with a password we did
1503    /// not choose, and check them against expectations we did not write.
1504    ///
1505    /// Everything self-minted is Tier 2 by construction: a marker we wrote, on a
1506    /// volume we encrypted, with a password we picked, is ground truth we
1507    /// authored, and confirming it confirms us. This test removes our authorship
1508    /// from every input.
1509    ///
1510    /// | input | who authored it |
1511    /// |---|---|
1512    /// | `apfs_encrypted.dmg` | dfVFS (log2timeline), Apache-2.0 |
1513    /// | password `apfs-TEST` | dfVFS `tests/lib/apfs_helper.py` |
1514    /// | expected tree + sizes | dfVFS `tests/vfs/apfs_file_entry.py` |
1515    ///
1516    /// Expectations are transcribed from `APFSFileEntryTestEncrypted` in that
1517    /// file: the root holds `.fseventsd`, `a_directory`, `a_link` and
1518    /// `passwords.txt`; `/a_directory/another_file` is 22 bytes with inode 21.
1519    ///
1520    /// Read them from the ENCRYPTED class, not the top of the file. The
1521    /// unencrypted `apfs.raw` class declares its own `_IDENTIFIER_*` constants
1522    /// (`another_file` is 19 there, 21 here). Copying the first set that appears
1523    /// produces a failure that looks like a decoder bug and is not one -- it
1524    /// happened while writing this test, and our reader was right.
1525    #[test]
1526    fn tier1_third_party_encrypted_image_reads_against_a_third_party_answer_key() {
1527        let img = dfvfs_container();
1528
1529        // Guard the test itself: none of the answer key may sit in plaintext,
1530        // or the read could succeed without decrypting anything.
1531        for name in [b"passwords.txt".as_slice(), b"another_file".as_slice()] {
1532            assert!(
1533                !img.windows(name.len()).any(|w| w == name),
1534                "{} appears in plaintext; this image is not really encrypted",
1535                String::from_utf8_lossy(name)
1536            );
1537        }
1538
1539        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1540
1541        // The volume UUID comes from the image, not from us.
1542        let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
1543        let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
1544
1545        let vek_v = unlock_volume(&img, &volume_uuid, "apfs-TEST")
1546            .expect("dfVFS's published password must unlock dfVFS's image")
1547            .vek;
1548        let vek: [u8; 32] = vek_v.as_slice().try_into().expect("VEK is 32 bytes");
1549
1550        let mut cur = std::io::Cursor::new(&img[..]);
1551        let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
1552        vol.set_vek(vek);
1553
1554        // (1) the root listing matches dfVFS's expected_sub_file_entry_names
1555        let mut names: Vec<String> = crate::dir::list_dir(&mut cur, &vol, 2, block_size)
1556            .expect("root directory must list")
1557            .into_iter()
1558            .map(|e| e.name)
1559            .collect();
1560        names.sort();
1561        assert_eq!(
1562            names,
1563            vec![".fseventsd", "a_directory", "a_link", "passwords.txt"],
1564            "root listing must match dfVFS's own expectations"
1565        );
1566
1567        // (2) the file dfVFS pins at 22 bytes, read through the full stack
1568        let inode = crate::dir::open_path(&mut cur, &vol, "a_directory/another_file", block_size)
1569            .expect("/a_directory/another_file must resolve");
1570        assert_eq!(
1571            inode.oid, 21,
1572            "dfVFS's APFSFileEntryTestEncrypted pins this file's inode at 21"
1573        );
1574        let data = crate::extent::read_data(&mut cur, &vol, &inode, block_size)
1575            .expect("its contents must decrypt");
1576
1577        // Exact BYTES, not length. Asserting the size alone let a corrupted
1578        // extent tweak pass a mutation run -- decryption cannot change how many
1579        // bytes a file has, so a length check tests the extent map and nothing
1580        // about the crypto.
1581        //
1582        // Content comes from dfVFS's own generator,
1583        // utils/generate_test_data_macos.sh:
1584        //     cat >${MOUNT_POINT}/a_directory/another_file <<EOT
1585        //     This is another file.
1586        //     EOT
1587        // and matches their committed test_data/another_file
1588        // (sha256 c7fbc0e821c0871805a99584c6a384533909f68a6bbe9a2a687d28d9f3b10c16).
1589        assert_eq!(
1590            data.as_slice(),
1591            b"This is another file.\n",
1592            "the decrypted bytes must equal what dfVFS's generator wrote"
1593        );
1594
1595        // A second file, written by the same generator, so one lucky extent
1596        // cannot carry the claim. Its first line is pinned rather than the
1597        // whole body -- the generator is the authority on both.
1598        let pw = crate::dir::open_path(&mut cur, &vol, "passwords.txt", block_size)
1599            .expect("/passwords.txt must resolve");
1600        let pw_data = crate::extent::read_data(&mut cur, &vol, &pw, block_size)
1601            .expect("passwords.txt must decrypt");
1602        assert!(
1603            pw_data.starts_with(b"place,user,password\n"),
1604            "passwords.txt must decrypt to the generator's heredoc, got {:?}",
1605            String::from_utf8_lossy(&pw_data[..pw_data.len().min(40)])
1606        );
1607        assert!(
1608            pw_data.windows(23).any(|w| w == b"bank,joesmith,superrich"),
1609            "and must contain the generator's second line"
1610        );
1611    }
1612
1613    /// TIER 1 control: the wrong password must be refused on the third-party
1614    /// image too, so the unlock above is a result rather than a formality.
1615    #[test]
1616    fn tier1_third_party_image_refuses_a_wrong_password() {
1617        let img = dfvfs_container();
1618        let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
1619        let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
1620        assert!(
1621            unlock_volume(&img, &volume_uuid, "apfs-WRONG").is_err(),
1622            "a wrong password must be refused, never answered with a key"
1623        );
1624    }
1625
1626    // ── Malformed-input rejection ──
1627    //
1628    // These crates parse attacker-controllable images, so every "this cannot
1629    // happen" branch is a robustness guarantee and gets a test. They also pull
1630    // the module back over the coverage floor, but the reason they exist is
1631    // that an untested refusal path is a refusal nobody has seen work.
1632
1633    /// A BER 2-byte length (`0x82`) must be honoured, not guessed at.
1634    #[test]
1635    fn wrapped_kek_accepts_a_two_byte_ber_length() {
1636        // 0x83 with an 0x82-form length of 40, then iterations and salt.
1637        let mut blob = vec![0x83, 0x82, 0x00, 40];
1638        blob.extend_from_slice(&[0xAA; 40]);
1639        blob.extend_from_slice(&[0x84, 0x01, 0x0A]); // iterations = 10
1640        blob.push(0x85);
1641        blob.push(16);
1642        blob.extend_from_slice(&[0xBB; 16]);
1643
1644        let k = parse_wrapped_kek(&blob).expect("a 2-byte length is legal BER");
1645        assert_eq!(k.wrapped_key.len(), 40);
1646        assert_eq!(k.iterations, 10);
1647        assert_eq!(k.salt.len(), 16);
1648    }
1649
1650    /// An unsupported BER length form must be REFUSED, never guessed at: a
1651    /// wrong length silently reinterprets every field after it.
1652    #[test]
1653    fn wrapped_kek_rejects_an_unsupported_ber_length_form() {
1654        // 0x84 as a length form (4-byte) is not supported by this reader.
1655        let blob = vec![0x83, 0x84, 0, 0, 0, 40];
1656        let e = parse_wrapped_kek(&blob).expect_err("unsupported length form must fail");
1657        assert!(
1658            format!("{e:?}").contains("ber_length_form"),
1659            "the error must name the offending form, got {e:?}"
1660        );
1661    }
1662
1663    /// A 2-byte length whose bytes run off the end must fail, not read past it.
1664    #[test]
1665    fn wrapped_kek_rejects_a_truncated_two_byte_length() {
1666        let blob = vec![0x83, 0x82, 0x00]; // second length byte missing
1667        assert!(
1668            parse_wrapped_kek(&blob).is_err(),
1669            "a truncated length must be refused, never over-read"
1670        );
1671    }
1672
1673    /// PBKDF2 with a zero iteration count must not silently produce a key.
1674    #[test]
1675    fn key_derivation_with_zero_iterations_is_floored_not_zero() {
1676        // Iterations are floored at 1: a zero would make the KDF a no-op and
1677        // hand back something that still looks like a key.
1678        let k = derive_key_from_password(b"pw", &[0u8; 16], 0, 32);
1679        assert_eq!(k.len(), 32);
1680        assert!(k.iter().any(|&b| b != 0), "must still derive real material");
1681    }
1682
1683    /// A container with no NXSB magic is not an APFS image.
1684    #[test]
1685    fn container_keybag_rejects_a_non_apfs_image() {
1686        let img = vec![0u8; 4096];
1687        let e = decrypt_container_keybag(&img).expect_err("no NXSB must fail");
1688        assert!(format!("{e:?}").contains("magic"), "got {e:?}");
1689    }
1690
1691    /// A block size that is not a whole number of 512-byte sectors cannot be
1692    /// XTS-addressed, so it is refused rather than rounded.
1693    #[test]
1694    fn container_keybag_rejects_an_unusable_block_size() {
1695        let mut img = vec![0u8; 8192];
1696        img[32..36].copy_from_slice(b"NXSB");
1697        img[NX_BLOCK_SIZE..NX_BLOCK_SIZE + 4].copy_from_slice(&777u32.to_le_bytes());
1698        let e = decrypt_container_keybag(&img).expect_err("odd block size must fail");
1699        assert!(format!("{e:?}").contains("block_size"), "got {e:?}");
1700    }
1701
1702    /// A container declaring no keybag is not encrypted; say so rather than
1703    /// decrypting whatever happens to sit at block zero.
1704    #[test]
1705    fn container_keybag_rejects_an_absent_keylocker() {
1706        let mut img = vec![0u8; 8192];
1707        img[32..36].copy_from_slice(b"NXSB");
1708        img[NX_BLOCK_SIZE..NX_BLOCK_SIZE + 4].copy_from_slice(&4096u32.to_le_bytes());
1709        // nx_keylocker left zero.
1710        let e = decrypt_container_keybag(&img).expect_err("absent keylocker must fail");
1711        assert!(format!("{e:?}").contains("keylocker_extent"), "got {e:?}");
1712    }
1713
1714    /// A keybag extent pointing past the image must fail, not read past it.
1715    #[test]
1716    fn container_keybag_rejects_an_extent_past_the_image() {
1717        let mut img = vec![0u8; 8192];
1718        img[32..36].copy_from_slice(b"NXSB");
1719        img[NX_BLOCK_SIZE..NX_BLOCK_SIZE + 4].copy_from_slice(&4096u32.to_le_bytes());
1720        img[NX_KEYBAG_BLOCK..NX_KEYBAG_BLOCK + 8].copy_from_slice(&9999u64.to_le_bytes());
1721        img[NX_KEYBAG_BLOCKS..NX_KEYBAG_BLOCKS + 8].copy_from_slice(&1u64.to_le_bytes());
1722        assert!(
1723            decrypt_container_keybag(&img).is_err(),
1724            "an extent past the image must be refused"
1725        );
1726    }
1727
1728    /// `decrypt_volume_area` leaves data alone when the tweak is the
1729    /// not-encrypted sentinel, or when the block size cannot hold a sector.
1730    #[test]
1731    fn volume_area_decryption_is_a_no_op_for_the_sentinel_and_short_blocks() {
1732        let original = vec![0x5Au8; 4096];
1733
1734        let mut a = original.clone();
1735        decrypt_volume_area(&mut a, &[7u8; 32], 0, 4096);
1736        assert_eq!(a, original, "tweak 0 means not encrypted: leave it alone");
1737
1738        let mut b = original.clone();
1739        decrypt_volume_area(&mut b, &[7u8; 32], 5, 256);
1740        assert_eq!(
1741            b, original,
1742            "a block smaller than a sector cannot be XTS-addressed"
1743        );
1744    }
1745
1746    /// A keybag whose entry count overruns the blob stops at the boundary.
1747    #[test]
1748    fn volume_records_stops_at_the_end_of_a_short_keybag() {
1749        let mut kb = vec![0u8; OBJ_PHYS_LEN + 16];
1750        kb[KL_NKEYS..KL_NKEYS + 2].copy_from_slice(&50u16.to_le_bytes());
1751        assert!(
1752            volume_records(&kb, &[0x11; 16]).is_err(),
1753            "no matching volume in a truncated keybag must be an error, not a guess"
1754        );
1755    }
1756
1757    /// A volume keybag with no KEK object must be reported as such.
1758    #[test]
1759    fn unlock_with_volume_keybag_reports_a_missing_kek_object() {
1760        let records = VolumeRecords {
1761            wrapped_vek: vec![0u8; 40],
1762            volume_keybag_block: 1,
1763            volume_keybag_blocks: 1,
1764        };
1765        let empty = vec![0u8; OBJ_PHYS_LEN + 16];
1766        let e =
1767            unlock_with_volume_keybag(&empty, &records, "pw").expect_err("no KEK object must fail");
1768        assert!(format!("{e:?}").contains("kek_object_absent"), "got {e:?}");
1769    }
1770
1771    /// A wrapped VEK of the wrong size cannot yield a key; refuse rather than
1772    /// unwrap something that will produce plausible garbage.
1773    #[test]
1774    fn unlock_with_volume_keybag_rejects_a_wrong_sized_wrapped_vek() {
1775        let img = fixture_image();
1776        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1777        let container_kb = decrypt_container_keybag(&img).expect("keybag decrypts");
1778        let mut rec = volume_records(&container_kb, &FIXTURE_VOLUME_UUID).expect("records");
1779        rec.wrapped_vek.truncate(8); // no longer 40 bytes
1780
1781        let vs = rec.volume_keybag_block as usize * block_size;
1782        let ve = vs + rec.volume_keybag_blocks as usize * block_size;
1783        let vkb = decrypt_keybag_area(&img[vs..ve], &FIXTURE_VOLUME_UUID, vs / KEYBAG_SECTOR);
1784
1785        let e = unlock_with_volume_keybag(&vkb, &rec, "apfs-FV-TEST-2026")
1786            .expect_err("a short wrapped VEK must fail");
1787        assert!(format!("{e:?}").contains("wrapped_vek_len"), "got {e:?}");
1788    }
1789
1790    /// `unlock_volume` must refuse an image it cannot even parse, rather than
1791    /// reporting a password problem.
1792    #[test]
1793    fn unlock_volume_rejects_an_unparseable_image() {
1794        assert!(
1795            unlock_volume(&[0u8; 4096], &[0x22; 16], "pw").is_err(),
1796            "a non-APFS image is an image error, not a password error"
1797        );
1798    }
1799
1800    /// Build a container keybag naming one volume, with caller-chosen entry
1801    /// payloads, so the malformed shapes below are exercised through the real
1802    /// parser rather than around it.
1803    #[cfg(test)]
1804    fn container_keybag_for(uuid: &[u8; 16], entries: &[(u16, Vec<u8>)]) -> Vec<u8> {
1805        let mut data = vec![0u8; OBJ_PHYS_LEN + 16];
1806        data[24..28].copy_from_slice(b"syek");
1807        data[OBJ_PHYS_LEN..OBJ_PHYS_LEN + 2].copy_from_slice(&1u16.to_le_bytes());
1808        data[KL_NKEYS..KL_NKEYS + 2].copy_from_slice(&(entries.len() as u16).to_le_bytes());
1809        for (tag, payload) in entries {
1810            let mut e = vec![0u8; KE_HEADER_LEN + payload.len()];
1811            e[..16].copy_from_slice(uuid);
1812            e[KE_TAG..KE_TAG + 2].copy_from_slice(&tag.to_le_bytes());
1813            e[KE_KEYLEN..KE_KEYLEN + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
1814            e[KE_HEADER_LEN..].copy_from_slice(payload);
1815            let padded = (e.len() + 15) & !15;
1816            e.resize(padded, 0);
1817            data.extend_from_slice(&e);
1818        }
1819        data
1820    }
1821
1822    /// A wrapped VEK that is not RFC 3394 output for a 256-bit key cannot yield
1823    /// a key. Refuse, rather than unwrap it into plausible garbage.
1824    #[test]
1825    fn volume_records_rejects_a_wrong_sized_wrapped_vek() {
1826        let uuid = [0x33u8; 16];
1827        // 0x30 SEQUENCE > 0xa3 > 0x83 wrapped key, but only 8 bytes not 40.
1828        let inner = vec![0x83, 8, 0, 0, 0, 0, 0, 0, 0, 0];
1829        let a3 = {
1830            let mut v = vec![0xa3, inner.len() as u8];
1831            v.extend_from_slice(&inner);
1832            v
1833        };
1834        let seq = {
1835            let mut v = vec![0x30, a3.len() as u8];
1836            v.extend_from_slice(&a3);
1837            v
1838        };
1839        let kb = container_keybag_for(&uuid, &[(0x02, seq)]);
1840        let e = volume_records(&kb, &uuid).expect_err("a short wrapped VEK must be refused");
1841        assert!(format!("{e:?}").contains("wrapped_vek_len"), "got {e:?}");
1842    }
1843
1844    /// The unlock-records entry carries a `prange_t` (block, count). Anything
1845    /// shorter is not one, and reading it would invent an extent.
1846    #[test]
1847    fn volume_records_rejects_short_unlock_records() {
1848        let uuid = [0x44u8; 16];
1849        let kb = container_keybag_for(&uuid, &[(0x03, vec![0u8; 4])]);
1850        let e = volume_records(&kb, &uuid).expect_err("a short prange must be refused");
1851        assert!(format!("{e:?}").contains("unlock_records_len"), "got {e:?}");
1852    }
1853
1854    /// A volume entry carrying no wrapped key at all is incomplete, not empty.
1855    #[test]
1856    fn volume_records_rejects_a_volume_key_entry_with_no_wrapped_key() {
1857        let uuid = [0x55u8; 16];
1858        // A SEQUENCE with an 0xa3 that holds no 0x83.
1859        let a3 = vec![0xa3, 2, 0x99, 0];
1860        let seq = {
1861            let mut v = vec![0x30, a3.len() as u8];
1862            v.extend_from_slice(&a3);
1863            v
1864        };
1865        let kb = container_keybag_for(&uuid, &[(0x02, seq)]);
1866        assert!(
1867            volume_records(&kb, &uuid).is_err(),
1868            "a volume key entry with no 0x83 must be refused"
1869        );
1870    }
1871
1872    /// `find_kek_object` walks BER with the same definite-length rules; an
1873    /// unsupported length form must abandon the entry rather than misread it.
1874    #[test]
1875    fn a_volume_keybag_with_an_unsupported_ber_length_yields_no_kek() {
1876        let uuid = [0x66u8; 16];
1877        // 0x30 SEQUENCE whose length uses the unsupported 0x84 form.
1878        let seq = vec![0x30, 0x84, 0, 0, 0, 4, 0xa3, 2, 0x83, 0];
1879        let kb = container_keybag_for(&uuid, &[(0x01, seq)]);
1880        let records = VolumeRecords {
1881            wrapped_vek: vec![0u8; 40],
1882            volume_keybag_block: 1,
1883            volume_keybag_blocks: 1,
1884        };
1885        let e = unlock_with_volume_keybag(&kb, &records, "pw")
1886            .expect_err("an unreadable KEK object must fail");
1887        assert!(format!("{e:?}").contains("kek_object_absent"), "got {e:?}");
1888    }
1889
1890    /// A two-byte BER length inside a volume keybag must be honoured, so a
1891    /// legitimately large KEK object is found rather than skipped.
1892    #[test]
1893    fn a_volume_keybag_with_a_two_byte_ber_length_is_walked() {
1894        let uuid = [0x77u8; 16];
1895        let mut a3 = vec![0xa3, 0x81, 44];
1896        a3.extend_from_slice(&[0x83, 40]);
1897        a3.extend_from_slice(&[0xCC; 40]);
1898        a3.extend_from_slice(&[0x84, 0x01, 0x05]); // iterations
1899        let mut seq = vec![0x30, 0x82, 0x00, a3.len() as u8];
1900        seq.extend_from_slice(&a3);
1901        let kb = container_keybag_for(&uuid, &[(0x01, seq)]);
1902        let records = VolumeRecords {
1903            wrapped_vek: vec![0u8; 40],
1904            volume_keybag_block: 1,
1905            volume_keybag_blocks: 1,
1906        };
1907        // The object is FOUND (so the walk honoured 0x82); it then fails later
1908        // for a missing salt, which is the point: not "kek_object_absent".
1909        let e = unlock_with_volume_keybag(&kb, &records, "pw")
1910            .expect_err("a KEK object without a salt cannot derive a key");
1911        assert!(
1912            !format!("{e:?}").contains("kek_object_absent"),
1913            "the 0x82 length must have been walked, got {e:?}"
1914        );
1915    }
1916
1917    /// TIER 1: a symlink target reads through DECRYPTION.
1918    ///
1919    /// APFS stores a symlink's target in the embedded `com.apple.fs.symlink`
1920    /// xattr, so this exercises the embedded-xattr path on a volume where every
1921    /// tree node had to be decrypted first — different code from reading a file
1922    /// extent, and not covered by the plaintext suite.
1923    ///
1924    /// dfVFS pins the linked entry's name as `a_file` in
1925    /// `APFSFileEntryTestEncrypted.testGetLinkedFileEntry`.
1926    ///
1927    /// The two dfVFS images are NOT built alike, and assuming they were cost a
1928    /// red here. Measured contents of the encrypted one:
1929    ///
1930    /// ```text
1931    /// /              .fseventsd(16) a_directory(18) passwords.txt(19) a_link(22)
1932    /// /a_directory   a_file(20) another_file(21)
1933    /// xattrs         none on either file
1934    /// ```
1935    ///
1936    /// So it carries **no `myxattr` and no `a_resourcefork`** — the plaintext
1937    /// image has both, and `a_link` points at `another_file` there but `a_file`
1938    /// here. Those two read paths are therefore Tier 1 on plaintext only
1939    /// (`core/tests/dfvfs_tier1.rs`); claiming them here would assert against a
1940    /// corpus that does not contain them.
1941    #[test]
1942    fn tier1_symlink_target_reads_through_decryption() {
1943        let img = dfvfs_container();
1944        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1945        let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
1946        let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
1947        let vek: [u8; 32] = unlock_volume(&img, &volume_uuid, "apfs-TEST")
1948            .expect("dfVFS's password unlocks dfVFS's image")
1949            .vek
1950            .as_slice()
1951            .try_into()
1952            .expect("VEK is 32 bytes");
1953
1954        let mut cur = std::io::Cursor::new(&img[..]);
1955        let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
1956        vol.set_vek(vek);
1957
1958        let link =
1959            crate::dir::open_path(&mut cur, &vol, "a_link", block_size).expect("a_link resolves");
1960        assert_eq!(
1961            link.oid, 22,
1962            "dfVFS pins a_link's inode at 22 on this image"
1963        );
1964
1965        let target = crate::xattr::symlink_target(&mut cur, &vol, link.oid, block_size)
1966            .expect("symlink target reads")
1967            .expect("a_link is a symlink and must have a target");
1968        assert_eq!(
1969            target, "a_directory/a_file",
1970            "dfVFS pins the linked entry's name as a_file on the ENCRYPTED image \
1971             (it is another_file on the plaintext one)"
1972        );
1973    }
1974
1975    /// The encrypted corpus is smaller than the plaintext one, and this pins
1976    /// that fact so a future test cannot quietly assume parity again.
1977    ///
1978    /// Guarding the corpus rather than the code: if dfVFS ever ships an
1979    /// encrypted image carrying xattrs or a resource fork, this fails and says
1980    /// to go and claim those paths at Tier 1 here too.
1981    #[test]
1982    fn the_encrypted_corpus_carries_no_xattrs_or_resource_fork() {
1983        let img = dfvfs_container();
1984        let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
1985        let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
1986        let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
1987        let vek: [u8; 32] = unlock_volume(&img, &volume_uuid, "apfs-TEST")
1988            .expect("unlocks")
1989            .vek
1990            .as_slice()
1991            .try_into()
1992            .expect("VEK is 32 bytes");
1993
1994        let mut cur = std::io::Cursor::new(&img[..]);
1995        let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
1996        vol.set_vek(vek);
1997
1998        let dir = crate::dir::open_path(&mut cur, &vol, "a_directory", block_size)
1999            .expect("a_directory resolves");
2000        let mut names: Vec<String> = crate::dir::list_dir(&mut cur, &vol, dir.oid, block_size)
2001            .expect("lists")
2002            .into_iter()
2003            .map(|e| e.name)
2004            .collect();
2005        names.sort();
2006        assert_eq!(
2007            names,
2008            vec!["a_file", "another_file"],
2009            "no a_resourcefork here, unlike the plaintext image"
2010        );
2011    }
2012}