#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum KeybagTag {
Unknown = 0x00,
WrappingKey = 0x01,
VolumeKey = 0x02,
VolumeUnlockRecords = 0x03,
VolumePassphraseHint = 0x04,
UserPayload = 0xf8,
}
impl KeybagTag {
#[must_use]
pub fn from_u16(tag: u16) -> Self {
match tag {
0x01 => Self::WrappingKey,
0x02 => Self::VolumeKey,
0x03 => Self::VolumeUnlockRecords,
0x04 => Self::VolumePassphraseHint,
0xf8 => Self::UserPayload,
_ => Self::Unknown,
}
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct EncryptionState {
pub encrypted: bool,
pub tags_present: Vec<KeybagTag>,
pub has_passphrase_hint: bool,
pub unknown_tags: Vec<(u16, u64)>,
}
const OBJ_PHYS_LEN: usize = 32;
const KL_NKEYS: usize = OBJ_PHYS_LEN + 2; const KL_ENTRIES_OFF: usize = OBJ_PHYS_LEN + 16; const KE_TAG: usize = 16; const KE_KEYLEN: usize = 18; const KE_HEADER_LEN: usize = 24; const MAX_KEYBAG_ENTRIES: usize = 4096;
pub fn read_keybag(data: &[u8]) -> crate::Result<EncryptionState> {
let nkeys = (crate::bytes::le_u16(data, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
let mut tags_present = Vec::new();
let mut unknown_tags = Vec::new();
let mut off = KL_ENTRIES_OFF;
for _ in 0..nkeys {
if off + KE_HEADER_LEN > data.len() {
break;
}
let raw_tag = crate::bytes::le_u16(data, off + KE_TAG);
let tag = KeybagTag::from_u16(raw_tag);
let keylen = crate::bytes::le_u16(data, off + KE_KEYLEN) as usize;
if tag == KeybagTag::Unknown {
unknown_tags.push((raw_tag, off as u64));
}
if !tags_present.contains(&tag) {
tags_present.push(tag);
}
let entry_len = (KE_HEADER_LEN + keylen + 15) & !15;
off += entry_len.max(16);
}
let has_passphrase_hint = tags_present.contains(&KeybagTag::VolumePassphraseHint);
let encrypted = tags_present.iter().any(|t| {
matches!(
t,
KeybagTag::WrappingKey | KeybagTag::VolumeKey | KeybagTag::VolumeUnlockRecords
)
});
Ok(EncryptionState {
encrypted,
tags_present,
has_passphrase_hint,
unknown_tags,
})
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct WrappedKek {
pub wrapped_key: Vec<u8>,
pub iterations: u32,
pub salt: Vec<u8>,
}
pub fn parse_wrapped_kek(data: &[u8]) -> crate::Result<WrappedKek> {
const TAG_WRAPPED_KEK: u8 = 0x83;
const TAG_ITERATIONS: u8 = 0x84;
const TAG_SALT: u8 = 0x85;
const WRAPPED_KEK_LEN: usize = 40;
const SALT_LEN: usize = 16;
let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
structure: "wrapped_kek_object",
field,
value,
cap,
};
let mut wrapped_key: Option<Vec<u8>> = None;
let mut iterations: Option<u32> = None;
let mut salt: Option<Vec<u8>> = None;
let mut off = 0usize;
while off < data.len() {
let tag = *data
.get(off)
.ok_or_else(|| bad("tag", off as u64, data.len() as u64))?;
off += 1;
let first = *data
.get(off)
.ok_or_else(|| bad("length", off as u64, data.len() as u64))?;
off += 1;
let len = if first & 0x80 == 0 {
first as usize
} else if first == 0x81 {
let b = *data
.get(off)
.ok_or_else(|| bad("length_1byte", off as u64, data.len() as u64))?;
off += 1;
b as usize
} else if first == 0x82 {
let hi = *data
.get(off)
.ok_or_else(|| bad("length_2byte", off as u64, data.len() as u64))?;
let lo = *data
.get(off + 1)
.ok_or_else(|| bad("length_2byte", off as u64, data.len() as u64))?;
off += 2;
((hi as usize) << 8) | lo as usize
} else {
return Err(bad("ber_length_form", u64::from(first), 0x82));
};
let end = off
.checked_add(len)
.ok_or_else(|| bad("value_length", len as u64, data.len() as u64))?;
let value = data
.get(off..end)
.ok_or_else(|| bad("value_end", end as u64, data.len() as u64))?;
off = end;
match tag {
TAG_WRAPPED_KEK => {
if value.len() != WRAPPED_KEK_LEN {
return Err(bad(
"wrapped_kek_len",
value.len() as u64,
WRAPPED_KEK_LEN as u64,
));
}
wrapped_key = Some(value.to_vec());
}
TAG_ITERATIONS => {
if value.is_empty() || value.len() > 8 {
return Err(bad("iterations_len", value.len() as u64, 8));
}
let mut n: u64 = 0;
for b in value {
n = (n << 8) | u64::from(*b);
}
iterations = Some(u32::try_from(n).unwrap_or(u32::MAX));
}
TAG_SALT => {
if value.len() != SALT_LEN {
return Err(bad("salt_len", value.len() as u64, SALT_LEN as u64));
}
salt = Some(value.to_vec());
}
_ => {}
}
}
Ok(WrappedKek {
wrapped_key: wrapped_key.ok_or_else(|| bad("wrapped_kek_tag_0x83", 0, 1))?,
iterations: iterations.ok_or_else(|| bad("iterations_tag_0x84", 0, 1))?,
salt: salt.ok_or_else(|| bad("salt_tag_0x85", 0, 1))?,
})
}
pub fn aes_key_unwrap(kek: &[u8; 32], wrapped: &[u8; 40]) -> crate::Result<Vec<u8>> {
use aes_kw::KekAes256;
let kek = KekAes256::from(*kek);
kek.unwrap_vec(wrapped)
.map_err(|_| crate::ApfsError::FieldOutOfRange {
structure: "aes_key_wrap",
field: "integrity_check",
value: 0,
cap: 1,
})
}
#[must_use]
pub fn derive_key_from_password(
password: &[u8],
salt: &[u8],
iterations: u32,
out_len: usize,
) -> Vec<u8> {
use hmac::Hmac;
use sha2::Sha256;
let mut out = vec![0u8; out_len];
let iters = iterations.max(1);
if pbkdf2::pbkdf2::<Hmac<Sha256>>(password, salt, iters, &mut out).is_err() {
return Vec::new();
}
out
}
fn ber_find(data: &[u8], want_tag: u8) -> Option<&[u8]> {
let mut off = 0usize;
while off + 2 <= data.len() {
let tag = *data.get(off)?;
let first = *data.get(off + 1)?;
off += 2;
let len = if first & 0x80 == 0 {
first as usize
} else if first == 0x81 {
let b = *data.get(off)?;
off += 1;
b as usize
} else if first == 0x82 {
let hi = *data.get(off)?;
let lo = *data.get(off + 1)?;
off += 2;
((hi as usize) << 8) | lo as usize
} else {
return None;
};
let end = off.checked_add(len)?;
let value = data.get(off..end)?;
if tag == want_tag {
return Some(value);
}
off = end;
}
None
}
const NX_BLOCK_SIZE: usize = 0x024;
const NX_CONTAINER_UUID: usize = 0x048;
const NX_KEYBAG_BLOCK: usize = 0x510;
const NX_KEYBAG_BLOCKS: usize = 0x518;
const KEYBAG_SECTOR: usize = 512;
pub fn decrypt_container_keybag(image: &[u8]) -> crate::Result<Vec<u8>> {
let uuid: [u8; 16] = image
.get(NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16)
.and_then(|s| s.try_into().ok())
.ok_or(crate::ApfsError::FieldOutOfRange {
structure: "nx_superblock",
field: "container_uuid",
value: image.len() as u64,
cap: (NX_CONTAINER_UUID + 16) as u64,
})?;
decrypt_container_keybag_with_uuid(image, &uuid)
}
pub fn decrypt_container_keybag_with_uuid(image: &[u8], uuid: &[u8; 16]) -> crate::Result<Vec<u8>> {
let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
structure: "nx_keylocker",
field,
value,
cap,
};
if image.get(32..36) != Some(b"NXSB") {
return Err(bad("magic", 0, 1));
}
let block_size = crate::bytes::le_u32(image, NX_BLOCK_SIZE) as usize;
if block_size == 0 || block_size % KEYBAG_SECTOR != 0 {
return Err(bad("block_size", block_size as u64, KEYBAG_SECTOR as u64));
}
let paddr = crate::bytes::le_u64(image, NX_KEYBAG_BLOCK) as usize;
let blocks = crate::bytes::le_u64(image, NX_KEYBAG_BLOCKS) as usize;
if paddr == 0 || blocks == 0 {
return Err(bad("keylocker_extent", paddr as u64, blocks as u64));
}
let start = paddr
.checked_mul(block_size)
.ok_or_else(|| bad("keybag_offset", paddr as u64, block_size as u64))?;
let len = blocks
.checked_mul(block_size)
.ok_or_else(|| bad("keybag_length", blocks as u64, block_size as u64))?;
let end = start
.checked_add(len)
.ok_or_else(|| bad("keybag_end", start as u64, len as u64))?;
let ct = image
.get(start..end)
.ok_or_else(|| bad("keybag_past_image", end as u64, image.len() as u64))?;
Ok(decrypt_keybag_area(ct, uuid, start / KEYBAG_SECTOR))
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct VolumeRecords {
pub wrapped_vek: Vec<u8>,
pub volume_keybag_block: u64,
pub volume_keybag_blocks: u64,
}
pub fn volume_records(keybag: &[u8], volume_uuid: &[u8; 16]) -> crate::Result<VolumeRecords> {
const WRAPPED_VEK_LEN: usize = 40;
const PRANGE_LEN: usize = 16;
const TAG_SEQUENCE: u8 = 0x30;
const TAG_NESTED_OBJECT: u8 = 0xa3;
const TAG_WRAPPED_KEY: u8 = 0x83;
let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
structure: "container_keybag",
field,
value,
cap,
};
let nkeys = (crate::bytes::le_u16(keybag, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
let mut wrapped_vek: Option<Vec<u8>> = None;
let mut extent: Option<(u64, u64)> = None;
let mut off = KL_ENTRIES_OFF;
for _ in 0..nkeys {
if off + KE_HEADER_LEN > keybag.len() {
break;
}
let entry_uuid = keybag.get(off..off + 16).unwrap_or(&[]);
let raw_tag = crate::bytes::le_u16(keybag, off + KE_TAG);
let keylen = crate::bytes::le_u16(keybag, off + KE_KEYLEN) as usize;
let data_off = off + KE_HEADER_LEN;
let data = keybag.get(data_off..data_off + keylen).unwrap_or(&[]);
if entry_uuid == volume_uuid.as_slice() {
match KeybagTag::from_u16(raw_tag) {
KeybagTag::VolumeKey => {
let seq = ber_find(data, TAG_SEQUENCE).unwrap_or(data);
let obj = ber_find(seq, TAG_NESTED_OBJECT)
.ok_or_else(|| bad("volume_key_missing_0xa3", data.len() as u64, 0xa3))?;
let inner = ber_find(obj, TAG_WRAPPED_KEY)
.ok_or_else(|| bad("volume_key_missing_0x83", obj.len() as u64, 0x83))?;
if inner.len() != WRAPPED_VEK_LEN {
return Err(bad(
"wrapped_vek_len",
inner.len() as u64,
WRAPPED_VEK_LEN as u64,
));
}
wrapped_vek = Some(inner.to_vec());
}
KeybagTag::VolumeUnlockRecords => {
if data.len() < PRANGE_LEN {
return Err(bad(
"unlock_records_len",
data.len() as u64,
PRANGE_LEN as u64,
));
}
extent = Some((crate::bytes::le_u64(data, 0), crate::bytes::le_u64(data, 8)));
}
_ => {}
}
}
off += ((KE_HEADER_LEN + keylen + 15) & !15).max(16);
}
let (blk, cnt) = extent.ok_or_else(|| bad("volume_unlock_records_absent", 0, 1))?;
Ok(VolumeRecords {
wrapped_vek: wrapped_vek.ok_or_else(|| bad("volume_key_absent", 0, 1))?,
volume_keybag_block: blk,
volume_keybag_blocks: cnt,
})
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct UnlockedVolume {
pub vek: Vec<u8>,
}
pub fn unlock_volume(
image: &[u8],
volume_uuid: &[u8; 16],
password: &str,
) -> crate::Result<UnlockedVolume> {
let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
structure: "unlock_volume",
field,
value,
cap,
};
let container_kb = decrypt_container_keybag(image)?;
let rec = volume_records(&container_kb, volume_uuid)?;
let block_size = crate::bytes::le_u32(image, NX_BLOCK_SIZE) as usize;
let start = (rec.volume_keybag_block as usize)
.checked_mul(block_size)
.ok_or_else(|| bad("vkb_offset", rec.volume_keybag_block, block_size as u64))?;
let len = (rec.volume_keybag_blocks as usize)
.checked_mul(block_size)
.ok_or_else(|| bad("vkb_length", rec.volume_keybag_blocks, block_size as u64))?;
let end = start
.checked_add(len)
.ok_or_else(|| bad("vkb_end", start as u64, len as u64))?;
let ct = image
.get(start..end)
.ok_or_else(|| bad("vkb_past_image", end as u64, image.len() as u64))?;
let volume_kb = decrypt_keybag_area(ct, volume_uuid, start / KEYBAG_SECTOR);
unlock_with_volume_keybag(&volume_kb, &rec, password)
}
pub fn unlock_with_volume_keybag(
volume_keybag: &[u8],
records: &VolumeRecords,
password: &str,
) -> crate::Result<UnlockedVolume> {
let bad = |field: &'static str, value: u64, cap: u64| crate::ApfsError::FieldOutOfRange {
structure: "unlock_with_volume_keybag",
field,
value,
cap,
};
let obj = find_kek_object(volume_keybag)
.ok_or_else(|| bad("kek_object_absent", volume_keybag.len() as u64, 1))?;
let kek_info = parse_wrapped_kek(obj)?;
let derived_v =
derive_key_from_password(password.as_bytes(), &kek_info.salt, kek_info.iterations, 32);
let derived: [u8; 32] = derived_v
.as_slice()
.try_into()
.map_err(|_| bad("derived_key_len", derived_v.len() as u64, 32))?;
let wrapped_kek: [u8; 40] = kek_info
.wrapped_key
.as_slice()
.try_into()
.map_err(|_| bad("wrapped_kek_len", kek_info.wrapped_key.len() as u64, 40))?;
let kek_v = aes_key_unwrap(&derived, &wrapped_kek)?;
let kek: [u8; 32] = kek_v
.as_slice()
.try_into()
.map_err(|_| bad("kek_len", kek_v.len() as u64, 32))?;
let wrapped_vek: [u8; 40] = records
.wrapped_vek
.as_slice()
.try_into()
.map_err(|_| bad("wrapped_vek_len", records.wrapped_vek.len() as u64, 40))?;
Ok(UnlockedVolume {
vek: aes_key_unwrap(&kek, &wrapped_vek)?,
})
}
pub const OMAP_VAL_ENCRYPTED: u32 = 0x0000_0004;
pub const APFS_CRYPTO_SECTOR: usize = 0x200;
pub fn decrypt_volume_area(data: &mut [u8], vek: &[u8; 32], tweak_block: u64, block_size: usize) {
if tweak_block == 0 || block_size < APFS_CRYPTO_SECTOR {
return;
}
use aes::cipher::KeyInit;
use xts_mode::{get_tweak_default, Xts128};
let (k1, k2) = vek.split_at(16);
let (Ok(k1), Ok(k2)) = (<&[u8; 16]>::try_from(k1), <&[u8; 16]>::try_from(k2)) else {
return; };
let xts = Xts128::new(aes::Aes128::new(k1.into()), aes::Aes128::new(k2.into()));
let sectors_per_block = (block_size / APFS_CRYPTO_SECTOR) as u64;
let first_sector = tweak_block.saturating_mul(sectors_per_block);
xts.decrypt_area(
data,
APFS_CRYPTO_SECTOR,
u128::from(first_sector),
get_tweak_default,
);
}
pub fn decrypt_keybag_area(ct: &[u8], uuid: &[u8; 16], first_sector: usize) -> Vec<u8> {
xts_decrypt_area(ct, uuid, first_sector)
}
fn xts_decrypt_area(ct: &[u8], uuid: &[u8; 16], first_sector: usize) -> Vec<u8> {
use aes::cipher::KeyInit;
use xts_mode::{get_tweak_default, Xts128};
let xts = Xts128::new(aes::Aes128::new(uuid.into()), aes::Aes128::new(uuid.into()));
let mut buf = ct.to_vec();
xts.decrypt_area(
&mut buf,
KEYBAG_SECTOR,
first_sector as u128,
get_tweak_default,
);
buf
}
fn find_kek_object(keybag: &[u8]) -> Option<&[u8]> {
let nkeys = (crate::bytes::le_u16(keybag, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
let mut off = KL_ENTRIES_OFF;
for _ in 0..nkeys {
if off + KE_HEADER_LEN > keybag.len() {
break;
}
let raw_tag = crate::bytes::le_u16(keybag, off + KE_TAG);
let keylen = crate::bytes::le_u16(keybag, off + KE_KEYLEN) as usize;
let data_off = off + KE_HEADER_LEN;
let tag = KeybagTag::from_u16(raw_tag);
if tag == KeybagTag::WrappingKey || tag == KeybagTag::VolumeUnlockRecords {
if let Some(data) = keybag.get(data_off..data_off + keylen) {
let seq = ber_find(data, 0x30).unwrap_or(data);
if let Some(obj) = ber_find(seq, 0xa3) {
return Some(obj);
}
}
}
off += ((KE_HEADER_LEN + keylen + 15) & !15).max(16);
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn keybag(entries: &[(u16, usize)]) -> Vec<u8> {
let mut data = vec![0u8; OBJ_PHYS_LEN + 16];
data[24..28].copy_from_slice(b"keys"); data[OBJ_PHYS_LEN..OBJ_PHYS_LEN + 2].copy_from_slice(&1u16.to_le_bytes()); data[OBJ_PHYS_LEN + 2..OBJ_PHYS_LEN + 4]
.copy_from_slice(&(entries.len() as u16).to_le_bytes()); for &(tag, keylen) in entries {
let mut e = vec![0u8; 24 + keylen];
e[16..18].copy_from_slice(&tag.to_le_bytes()); e[18..20].copy_from_slice(&(keylen as u16).to_le_bytes()); let padded = (e.len() + 15) & !15; e.resize(padded, 0);
data.extend_from_slice(&e);
}
let nbytes = data.len() as u32;
data[4..8].copy_from_slice(&nbytes.to_le_bytes()); data
}
#[test]
fn reads_volume_key_and_hint_tags() {
let kb = keybag(&[(0x02, 32), (0x04, 8)]);
let st = read_keybag(&kb).expect("parse keybag");
assert!(st.encrypted, "a keybag with a volume key is encrypted");
assert!(st.tags_present.contains(&KeybagTag::VolumeKey));
assert!(st.tags_present.contains(&KeybagTag::VolumePassphraseHint));
assert!(st.has_passphrase_hint);
}
#[test]
fn from_u16_maps_every_known_tag() {
assert_eq!(KeybagTag::from_u16(0x01), KeybagTag::WrappingKey);
assert_eq!(KeybagTag::from_u16(0x02), KeybagTag::VolumeKey);
assert_eq!(KeybagTag::from_u16(0x03), KeybagTag::VolumeUnlockRecords);
assert_eq!(KeybagTag::from_u16(0x04), KeybagTag::VolumePassphraseHint);
assert_eq!(KeybagTag::from_u16(0xf8), KeybagTag::UserPayload);
assert_eq!(KeybagTag::from_u16(0x99), KeybagTag::Unknown);
}
#[test]
fn wrapping_key_and_unlock_records_are_encrypted() {
let kb = keybag(&[(0x01, 32), (0x03, 16)]);
let st = read_keybag(&kb).expect("parse keybag");
assert!(st.encrypted, "wrapping-key / unlock-records ⇒ encrypted");
assert!(st.tags_present.contains(&KeybagTag::WrappingKey));
assert!(st.tags_present.contains(&KeybagTag::VolumeUnlockRecords));
}
#[test]
fn empty_keybag_reports_not_encrypted() {
let kb = keybag(&[]);
let st = read_keybag(&kb).expect("parse empty keybag");
assert!(!st.encrypted);
assert!(st.tags_present.is_empty());
assert!(!st.has_passphrase_hint);
}
#[test]
fn unknown_tag_maps_to_unknown_and_records_raw_value() {
let kb = keybag(&[(0x55, 4)]);
let st = read_keybag(&kb).expect("parse keybag");
assert!(st.tags_present.contains(&KeybagTag::Unknown));
assert_eq!(st.unknown_tags, vec![(0x55u16, KL_ENTRIES_OFF as u64)]);
}
#[test]
fn header_claiming_more_entries_than_the_blob_stops_early() {
let mut data = vec![0u8; 24];
data[0..2].copy_from_slice(&1u16.to_le_bytes()); data[2..4].copy_from_slice(&4u16.to_le_bytes()); let st = read_keybag(&data).expect("parse truncated keybag");
assert!(st.tags_present.is_empty(), "no entry fits → nothing parsed");
}
#[test]
fn wrapped_kek_object_yields_salt_iterations_and_wrapped_key() {
let mut blob = Vec::new();
blob.push(0x82u8);
blob.push(8u8);
blob.extend_from_slice(&[2, 0, 0, 0, 0, 0, 0, 0]);
blob.push(0x83u8);
blob.push(0x81u8);
blob.push(40u8);
let wrapped: Vec<u8> = (0..40u8)
.map(|i| i.wrapping_mul(7).wrapping_add(3))
.collect();
blob.extend_from_slice(&wrapped);
blob.push(0x84u8);
blob.push(3u8);
blob.extend_from_slice(&[0x01, 0xE8, 0x48]); blob.push(0x85u8);
blob.push(16u8);
let salt: Vec<u8> = (0..16u8).map(|i| i ^ 0x5A).collect();
blob.extend_from_slice(&salt);
let kek = parse_wrapped_kek(&blob).expect("a well-formed wrapped-KEK object must parse");
assert_eq!(
kek.wrapped_key.as_slice(),
wrapped.as_slice(),
"wrapped KEK bytes"
);
assert_eq!(kek.iterations, 125_000, "PBKDF2 iteration count");
assert_eq!(kek.salt.as_slice(), salt.as_slice(), "PBKDF2 salt");
}
#[test]
fn wrapped_kek_object_rejects_a_wrong_sized_wrapped_key() {
let mut blob = Vec::new();
blob.push(0x83u8);
blob.push(32u8); blob.extend_from_slice(&[0u8; 32]);
blob.push(0x84u8);
blob.push(2u8);
blob.extend_from_slice(&[0x27, 0x10]); blob.push(0x85u8);
blob.push(16u8);
blob.extend_from_slice(&[0u8; 16]);
let err = parse_wrapped_kek(&blob)
.expect_err("a 32-byte wrapped key must be rejected; only 40 is valid");
let msg = format!("{err}");
assert!(
msg.contains("wrapped_kek_len"),
"must be rejected for the wrapped-key SIZE, got: {msg}"
);
}
#[test]
fn aes_key_unwrap_matches_rfc3394_vector() {
let kek: [u8; 32] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D,
0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B,
0x1C, 0x1D, 0x1E, 0x1F,
];
let wrapped: [u8; 40] = [
0x28, 0xC9, 0xF4, 0x04, 0xC4, 0xB8, 0x10, 0xF4, 0xCB, 0xCC, 0xB3, 0x5C, 0xFB, 0x87,
0xF8, 0x26, 0x3F, 0x57, 0x86, 0xE2, 0xD8, 0x0E, 0xD3, 0x26, 0xCB, 0xC7, 0xF0, 0xE7,
0x1A, 0x99, 0xF4, 0x3B, 0xFB, 0x98, 0x8B, 0x9B, 0x7A, 0x02, 0xDD, 0x21,
];
let expected: [u8; 32] = [
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD,
0xEE, 0xFF, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B,
0x0C, 0x0D, 0x0E, 0x0F,
];
let got = aes_key_unwrap(&kek, &wrapped).expect("RFC 3394 vector must unwrap");
assert_eq!(
got.as_slice(),
expected.as_slice(),
"RFC 3394 s4.6 unwrapped key"
);
}
#[test]
fn aes_key_unwrap_rejects_a_wrong_kek() {
let wrong = [0xAAu8; 32];
let wrapped: [u8; 40] = [
0x28, 0xC9, 0xF4, 0x04, 0xC4, 0xB8, 0x10, 0xF4, 0xCB, 0xCC, 0xB3, 0x5C, 0xFB, 0x87,
0xF8, 0x26, 0x3F, 0x57, 0x86, 0xE2, 0xD8, 0x0E, 0xD3, 0x26, 0xCB, 0xC7, 0xF0, 0xE7,
0x1A, 0x99, 0xF4, 0x3B, 0xFB, 0x98, 0x8B, 0x9B, 0x7A, 0x02, 0xDD, 0x21,
];
assert!(
aes_key_unwrap(&wrong, &wrapped).is_err(),
"a wrong KEK must fail the AES-KW integrity check, never return bytes"
);
}
#[test]
fn pbkdf2_sha256_matches_rfc7914_vector() {
let got = derive_key_from_password(b"Password", b"NaCl", 80_000, 64);
let expected: [u8; 64] = [
0x4D, 0xDC, 0xD8, 0xF6, 0x0B, 0x98, 0xBE, 0x21, 0x83, 0x0C, 0xEE, 0x5E, 0xF2, 0x27,
0x01, 0xF9, 0x64, 0x1A, 0x44, 0x18, 0xD0, 0x4C, 0x04, 0x14, 0xAE, 0xFF, 0x08, 0x87,
0x6B, 0x34, 0xAB, 0x56, 0xA1, 0xD4, 0x25, 0xA1, 0x22, 0x58, 0x33, 0x54, 0x9A, 0xDB,
0x84, 0x1B, 0x51, 0xC9, 0xB3, 0x17, 0x6A, 0x27, 0x2B, 0xDE, 0xBB, 0xA1, 0xD0, 0x78,
0x47, 0x8F, 0x62, 0xB3, 0x97, 0xF3, 0x3C, 0x8D,
];
assert_eq!(
got.as_slice(),
expected.as_slice(),
"RFC 7914 s11 PBKDF2-SHA256 c=80000"
);
}
#[cfg(test)]
fn fixture_image() -> Vec<u8> {
use std::io::Read as _;
let gz = include_bytes!("../../tests/data/filevault/apfs-native-filevault.raw.gz");
let mut out = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut out)
.expect("fixture must decompress");
out
}
#[test]
fn container_keybag_decrypts_to_a_parseable_keybag() {
let img = fixture_image();
let sb = &img[..4096];
assert_eq!(
&sb[32..36],
b"NXSB",
"fixture must start with a container superblock"
);
let kb = decrypt_container_keybag(&img).expect("container keybag must decrypt");
let state = read_keybag(&kb).expect("decrypted keybag must parse");
assert!(
state.tags_present.contains(&KeybagTag::VolumeKey)
|| state.tags_present.contains(&KeybagTag::VolumeUnlockRecords),
"decrypted container keybag must carry VolumeKey or VolumeUnlockRecords, got {:?}",
state.tags_present
);
}
#[test]
fn container_keybag_with_a_wrong_uuid_does_not_parse_as_a_keybag() {
let img = fixture_image();
let kb = decrypt_container_keybag_with_uuid(&img, &[0xAA; 16])
.expect("decryption itself should run; only the RESULT must be wrong");
let parsed = read_keybag(&kb);
let bogus = match parsed {
Err(_) => true,
Ok(s) => {
!s.tags_present.contains(&KeybagTag::VolumeKey)
&& !s.tags_present.contains(&KeybagTag::VolumeUnlockRecords)
}
};
assert!(
bogus,
"a wrong UUID must not produce a keybag carrying real volume tags"
);
}
#[cfg(test)]
const FIXTURE_VOLUME_UUID: [u8; 16] = [
0x75, 0xAA, 0xF7, 0xF9, 0xBF, 0x5A, 0x4A, 0x4F, 0x86, 0xC0, 0x3E, 0xF9, 0x09, 0x36, 0xBD,
0x7C,
];
#[test]
fn container_keybag_yields_this_volumes_wrapped_vek_and_keybag_extent() {
let img = fixture_image();
let kb = decrypt_container_keybag(&img).expect("container keybag must decrypt");
let rec = volume_records(&kb, &FIXTURE_VOLUME_UUID)
.expect("the fixture's volume must be present in its container keybag");
assert_eq!(
rec.wrapped_vek.len(),
40,
"wrapped VEK must be RFC 3394 output for a 256-bit key"
);
assert!(
rec.volume_keybag_block > 0,
"volume keybag block must be set"
);
assert!(
rec.volume_keybag_blocks > 0,
"volume keybag length must be non-zero"
);
let end = (rec.volume_keybag_block as usize + rec.volume_keybag_blocks as usize) * 4096;
assert!(
end <= img.len(),
"volume keybag extent must lie inside the container"
);
}
#[test]
fn container_keybag_refuses_an_unknown_volume_uuid() {
let img = fixture_image();
let kb = decrypt_container_keybag(&img).expect("container keybag must decrypt");
assert!(
volume_records(&kb, &[0x11; 16]).is_err(),
"an absent volume UUID must be an error, never another volume's keys"
);
}
#[test]
fn unlock_volume_recovers_the_vek_with_the_correct_password() {
let img = fixture_image();
let u = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
.expect("the fixture must unlock with its recorded password");
assert_eq!(u.vek.len(), 32, "VEK is two AES-128-XTS keys, 32 bytes");
assert!(u.vek.iter().any(|&b| b != 0), "VEK must not be all zeros");
}
#[test]
fn unlock_volume_refuses_a_wrong_password() {
let img = fixture_image();
assert!(
unlock_volume(&img, &FIXTURE_VOLUME_UUID, "not-the-password").is_err(),
"a wrong password must fail the integrity check, never return a key"
);
}
#[test]
fn keybag_area_decrypts_identically_from_a_ranged_read() {
let img = fixture_image();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let paddr = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCK) as usize;
let blocks = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCKS) as usize;
let uuid: [u8; 16] = img[NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16]
.try_into()
.expect("16 bytes");
let start = paddr * block_size;
let end = start + blocks * block_size;
let ranged = decrypt_keybag_area(&img[start..end], &uuid, start / KEYBAG_SECTOR);
let whole = decrypt_container_keybag(&img).expect("whole-image path must decrypt");
assert_eq!(
ranged, whole,
"a ranged read must decrypt to the same bytes as the whole-image path"
);
assert_eq!(
ranged.get(24..28),
Some(&b"syek"[..]),
"and those bytes must be a container keybag, not noise"
);
}
#[cfg(test)]
fn ranged_unlock(img: &[u8], volume_uuid: &[u8; 16], password: &str) -> (Vec<u8>, usize) {
let mut bytes_read = 0usize;
let sb = &img[..4096];
bytes_read += sb.len();
let block_size = crate::bytes::le_u32(sb, NX_BLOCK_SIZE) as usize;
let uuid: [u8; 16] = sb[NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16]
.try_into()
.expect("16 bytes");
let cs = crate::bytes::le_u64(sb, NX_KEYBAG_BLOCK) as usize * block_size;
let ce = cs + crate::bytes::le_u64(sb, NX_KEYBAG_BLOCKS) as usize * block_size;
bytes_read += ce - cs;
let ckb = decrypt_keybag_area(&img[cs..ce], &uuid, cs / KEYBAG_SECTOR);
let rec = volume_records(&ckb, volume_uuid).expect("volume must be listed");
let vs = rec.volume_keybag_block as usize * block_size;
let ve = vs + rec.volume_keybag_blocks as usize * block_size;
bytes_read += ve - vs;
let vkb = decrypt_keybag_area(&img[vs..ve], volume_uuid, vs / KEYBAG_SECTOR);
let vek = unlock_with_volume_keybag(&vkb, &rec, password)
.map(|u| u.vek)
.unwrap_or_default();
(vek, bytes_read)
}
#[test]
fn the_full_unlock_chain_works_from_ranged_reads_alone() {
let img = fixture_image();
let (got, bytes_read) = ranged_unlock(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026");
let want = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
.expect("whole-image path must unlock");
assert_eq!(
got, want.vek,
"the ranged chain must recover the same VEK as the whole-image path"
);
assert!(
bytes_read <= 64 * 1024,
"the unwrap chain must touch a trivial slice of the image, read {bytes_read} bytes"
);
use sha2::{Digest as _, Sha256};
assert_eq!(
Sha256::digest(&got)[..],
hex_literal_vek_fingerprint()[..],
"the fixture's VEK changed; a shared-path bug moves both sides at once"
);
}
#[cfg(test)]
fn hex_literal_vek_fingerprint() -> [u8; 32] {
[
0x12, 0xed, 0x22, 0xee, 0x47, 0xf9, 0x27, 0x28, 0x8c, 0x6e, 0xb2, 0x44, 0xf6, 0xa6,
0xd3, 0x54, 0xa2, 0xb5, 0xed, 0x14, 0x18, 0xa6, 0x34, 0x11, 0x64, 0x9d, 0x4e, 0x4a,
0xbc, 0xa5, 0xb1, 0x43,
]
}
#[test]
fn the_ranged_chain_refuses_a_wrong_password() {
let img = fixture_image();
let (vek, _) = ranged_unlock(&img, &FIXTURE_VOLUME_UUID, "not-the-password");
assert!(
vek.is_empty(),
"a wrong password must fail the ranged chain, never return a key"
);
}
#[test]
fn keybag_area_with_a_wrong_sector_index_yields_noise() {
let img = fixture_image();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let paddr = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCK) as usize;
let blocks = crate::bytes::le_u64(&img, NX_KEYBAG_BLOCKS) as usize;
let uuid: [u8; 16] = img[NX_CONTAINER_UUID..NX_CONTAINER_UUID + 16]
.try_into()
.expect("16 bytes");
let start = paddr * block_size;
let end = start + blocks * block_size;
let wrong = decrypt_keybag_area(&img[start..end], &uuid, start / KEYBAG_SECTOR + 1);
assert_eq!(
wrong.len(),
end - start,
"a decrypted area must be the same length as its ciphertext"
);
assert_ne!(
wrong.get(24..28),
Some(&b"syek"[..]),
"a wrong sector index must not still produce a valid keybag magic"
);
}
#[test]
fn the_encrypted_fs_tree_root_decrypts_to_a_checksum_valid_node() {
let img = fixture_image();
let vek_v = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
.expect("fixture must unlock")
.vek;
let vek: [u8; 32] = vek_v.as_slice().try_into().expect("VEK is 32 bytes");
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let mut cur = std::io::Cursor::new(&img[..]);
let (entry, _vol) = fs_tree_root(&img, &mut cur, block_size);
assert_ne!(
entry.flags & OMAP_VAL_ENCRYPTED,
0,
"the fixture's fs-tree root must be flagged encrypted, else this test proves nothing"
);
let mut node = read_block_at(&img, entry.paddr, block_size);
assert_ne!(
crate::object::fletcher64_checksum(&node),
crate::object::fletcher64_stored(&node),
"ciphertext must NOT already checksum, or the block was never encrypted"
);
decrypt_volume_area(&mut node, &vek, entry.paddr, block_size);
assert_eq!(
crate::object::fletcher64_checksum(&node),
crate::object::fletcher64_stored(&node),
"the decrypted fs-tree root must pass its own Fletcher-64 checksum"
);
assert!(
crate::btree::parse_node_header(&node).is_some(),
"and must parse as a B-tree node"
);
}
#[test]
fn a_wrong_vek_does_not_produce_a_checksum_valid_node() {
let img = fixture_image();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let mut cur = std::io::Cursor::new(&img[..]);
let (entry, _vol) = fs_tree_root(&img, &mut cur, block_size);
let mut node = read_block_at(&img, entry.paddr, block_size);
decrypt_volume_area(&mut node, &[0xAB; 32], entry.paddr, block_size);
assert_ne!(
crate::object::fletcher64_checksum(&node),
crate::object::fletcher64_stored(&node),
"a wrong VEK must not yield a checksum-valid node"
);
}
#[cfg(test)]
fn fs_tree_root(
img: &[u8],
cur: &mut std::io::Cursor<&[u8]>,
block_size: usize,
) -> (crate::omap::OmapEntry, crate::volume::ApfsVolume) {
let nx = crate::container::NxSuperblock::parse(&img[..block_size])
.expect("container superblock parses");
let nx_omap = crate::omap::ObjectMap::parse(&read_block_at(img, nx.omap_oid, block_size))
.expect("container omap parses");
let fs_oid = *nx.fs_oids.first().expect("at least one volume");
let vol_entry = nx_omap
.resolve(cur, fs_oid, u64::MAX, block_size)
.expect("volume oid resolves");
let vol =
crate::volume::ApfsVolume::parse(&read_block_at(img, vol_entry.paddr, block_size))
.expect("APSB parses (it is plaintext)");
let vol_omap =
crate::omap::ObjectMap::parse(&read_block_at(img, vol.omap_oid(), block_size))
.expect("volume omap parses");
let entry = vol_omap
.resolve(cur, vol.root_tree_oid(), u64::MAX, block_size)
.expect("fs-tree root resolves");
(entry, vol)
}
#[cfg(test)]
fn read_block_at(img: &[u8], paddr: u64, block_size: usize) -> Vec<u8> {
let start = paddr as usize * block_size;
img[start..start + block_size].to_vec()
}
#[cfg(test)]
const MARKER: &str = "APFS-FILEVAULT-GROUND-TRUTH-MARKER-0123456789";
#[test]
fn a_file_on_an_encrypted_volume_reads_back_as_its_known_plaintext() {
let img = fixture_image();
assert!(
!img.windows(MARKER.len()).any(|w| w == MARKER.as_bytes()),
"the marker must NOT appear in the raw image, or the volume was never encrypted \
and this test would pass without decrypting anything"
);
let vek_v = unlock_volume(&img, &FIXTURE_VOLUME_UUID, "apfs-FV-TEST-2026")
.expect("fixture must unlock")
.vek;
let vek: [u8; 32] = vek_v.as_slice().try_into().expect("VEK is 32 bytes");
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let mut cur = std::io::Cursor::new(&img[..]);
let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
vol.set_vek(vek);
let inode = crate::dir::open_path(&mut cur, &vol, "marker.txt", block_size)
.expect("marker.txt must be found on the decrypted fs-tree");
let data = crate::extent::read_data(&mut cur, &vol, &inode, block_size)
.expect("its contents must decrypt");
assert_eq!(
String::from_utf8_lossy(&data).trim_end(),
MARKER,
"the decrypted file must be byte-for-byte what macOS wrote"
);
}
#[test]
fn the_same_file_is_unreadable_without_the_key() {
let img = fixture_image();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let mut cur = std::io::Cursor::new(&img[..]);
let (_, vol) = fs_tree_root(&img, &mut cur, block_size);
let r = crate::dir::open_path(&mut cur, &vol, "marker.txt", block_size)
.and_then(|i| crate::extent::read_data(&mut cur, &vol, &i, block_size));
match r {
Err(_) => {}
Ok(data) => panic!(
"a locked volume must refuse, not answer; got {} bytes",
data.len()
),
}
}
#[cfg(test)]
const DFVFS_CONTAINER_OFFSET: usize = 20480;
#[cfg(test)]
fn dfvfs_container() -> Vec<u8> {
use std::io::Read as _;
let gz = include_bytes!("../../tests/data/filevault/dfvfs-apfs-encrypted.dmg.gz");
let mut out = Vec::new();
flate2::read::GzDecoder::new(&gz[..])
.read_to_end(&mut out)
.expect("dfVFS fixture must decompress");
out.split_off(DFVFS_CONTAINER_OFFSET)
}
#[test]
fn tier1_third_party_encrypted_image_reads_against_a_third_party_answer_key() {
let img = dfvfs_container();
for name in [b"passwords.txt".as_slice(), b"another_file".as_slice()] {
assert!(
!img.windows(name.len()).any(|w| w == name),
"{} appears in plaintext; this image is not really encrypted",
String::from_utf8_lossy(name)
);
}
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
let vek_v = unlock_volume(&img, &volume_uuid, "apfs-TEST")
.expect("dfVFS's published password must unlock dfVFS's image")
.vek;
let vek: [u8; 32] = vek_v.as_slice().try_into().expect("VEK is 32 bytes");
let mut cur = std::io::Cursor::new(&img[..]);
let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
vol.set_vek(vek);
let mut names: Vec<String> = crate::dir::list_dir(&mut cur, &vol, 2, block_size)
.expect("root directory must list")
.into_iter()
.map(|e| e.name)
.collect();
names.sort();
assert_eq!(
names,
vec![".fseventsd", "a_directory", "a_link", "passwords.txt"],
"root listing must match dfVFS's own expectations"
);
let inode = crate::dir::open_path(&mut cur, &vol, "a_directory/another_file", block_size)
.expect("/a_directory/another_file must resolve");
assert_eq!(
inode.oid, 21,
"dfVFS's APFSFileEntryTestEncrypted pins this file's inode at 21"
);
let data = crate::extent::read_data(&mut cur, &vol, &inode, block_size)
.expect("its contents must decrypt");
assert_eq!(
data.as_slice(),
b"This is another file.\n",
"the decrypted bytes must equal what dfVFS's generator wrote"
);
let pw = crate::dir::open_path(&mut cur, &vol, "passwords.txt", block_size)
.expect("/passwords.txt must resolve");
let pw_data = crate::extent::read_data(&mut cur, &vol, &pw, block_size)
.expect("passwords.txt must decrypt");
assert!(
pw_data.starts_with(b"place,user,password\n"),
"passwords.txt must decrypt to the generator's heredoc, got {:?}",
String::from_utf8_lossy(&pw_data[..pw_data.len().min(40)])
);
assert!(
pw_data.windows(23).any(|w| w == b"bank,joesmith,superrich"),
"and must contain the generator's second line"
);
}
#[test]
fn tier1_third_party_image_refuses_a_wrong_password() {
let img = dfvfs_container();
let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
assert!(
unlock_volume(&img, &volume_uuid, "apfs-WRONG").is_err(),
"a wrong password must be refused, never answered with a key"
);
}
#[test]
fn wrapped_kek_accepts_a_two_byte_ber_length() {
let mut blob = vec![0x83, 0x82, 0x00, 40];
blob.extend_from_slice(&[0xAA; 40]);
blob.extend_from_slice(&[0x84, 0x01, 0x0A]); blob.push(0x85);
blob.push(16);
blob.extend_from_slice(&[0xBB; 16]);
let k = parse_wrapped_kek(&blob).expect("a 2-byte length is legal BER");
assert_eq!(k.wrapped_key.len(), 40);
assert_eq!(k.iterations, 10);
assert_eq!(k.salt.len(), 16);
}
#[test]
fn wrapped_kek_rejects_an_unsupported_ber_length_form() {
let blob = vec![0x83, 0x84, 0, 0, 0, 40];
let e = parse_wrapped_kek(&blob).expect_err("unsupported length form must fail");
assert!(
format!("{e:?}").contains("ber_length_form"),
"the error must name the offending form, got {e:?}"
);
}
#[test]
fn wrapped_kek_rejects_a_truncated_two_byte_length() {
let blob = vec![0x83, 0x82, 0x00]; assert!(
parse_wrapped_kek(&blob).is_err(),
"a truncated length must be refused, never over-read"
);
}
#[test]
fn key_derivation_with_zero_iterations_is_floored_not_zero() {
let k = derive_key_from_password(b"pw", &[0u8; 16], 0, 32);
assert_eq!(k.len(), 32);
assert!(k.iter().any(|&b| b != 0), "must still derive real material");
}
#[test]
fn container_keybag_rejects_a_non_apfs_image() {
let img = vec![0u8; 4096];
let e = decrypt_container_keybag(&img).expect_err("no NXSB must fail");
assert!(format!("{e:?}").contains("magic"), "got {e:?}");
}
#[test]
fn container_keybag_rejects_an_unusable_block_size() {
let mut img = vec![0u8; 8192];
img[32..36].copy_from_slice(b"NXSB");
img[NX_BLOCK_SIZE..NX_BLOCK_SIZE + 4].copy_from_slice(&777u32.to_le_bytes());
let e = decrypt_container_keybag(&img).expect_err("odd block size must fail");
assert!(format!("{e:?}").contains("block_size"), "got {e:?}");
}
#[test]
fn container_keybag_rejects_an_absent_keylocker() {
let mut img = vec![0u8; 8192];
img[32..36].copy_from_slice(b"NXSB");
img[NX_BLOCK_SIZE..NX_BLOCK_SIZE + 4].copy_from_slice(&4096u32.to_le_bytes());
let e = decrypt_container_keybag(&img).expect_err("absent keylocker must fail");
assert!(format!("{e:?}").contains("keylocker_extent"), "got {e:?}");
}
#[test]
fn container_keybag_rejects_an_extent_past_the_image() {
let mut img = vec![0u8; 8192];
img[32..36].copy_from_slice(b"NXSB");
img[NX_BLOCK_SIZE..NX_BLOCK_SIZE + 4].copy_from_slice(&4096u32.to_le_bytes());
img[NX_KEYBAG_BLOCK..NX_KEYBAG_BLOCK + 8].copy_from_slice(&9999u64.to_le_bytes());
img[NX_KEYBAG_BLOCKS..NX_KEYBAG_BLOCKS + 8].copy_from_slice(&1u64.to_le_bytes());
assert!(
decrypt_container_keybag(&img).is_err(),
"an extent past the image must be refused"
);
}
#[test]
fn volume_area_decryption_is_a_no_op_for_the_sentinel_and_short_blocks() {
let original = vec![0x5Au8; 4096];
let mut a = original.clone();
decrypt_volume_area(&mut a, &[7u8; 32], 0, 4096);
assert_eq!(a, original, "tweak 0 means not encrypted: leave it alone");
let mut b = original.clone();
decrypt_volume_area(&mut b, &[7u8; 32], 5, 256);
assert_eq!(
b, original,
"a block smaller than a sector cannot be XTS-addressed"
);
}
#[test]
fn volume_records_stops_at_the_end_of_a_short_keybag() {
let mut kb = vec![0u8; OBJ_PHYS_LEN + 16];
kb[KL_NKEYS..KL_NKEYS + 2].copy_from_slice(&50u16.to_le_bytes());
assert!(
volume_records(&kb, &[0x11; 16]).is_err(),
"no matching volume in a truncated keybag must be an error, not a guess"
);
}
#[test]
fn unlock_with_volume_keybag_reports_a_missing_kek_object() {
let records = VolumeRecords {
wrapped_vek: vec![0u8; 40],
volume_keybag_block: 1,
volume_keybag_blocks: 1,
};
let empty = vec![0u8; OBJ_PHYS_LEN + 16];
let e =
unlock_with_volume_keybag(&empty, &records, "pw").expect_err("no KEK object must fail");
assert!(format!("{e:?}").contains("kek_object_absent"), "got {e:?}");
}
#[test]
fn unlock_with_volume_keybag_rejects_a_wrong_sized_wrapped_vek() {
let img = fixture_image();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let container_kb = decrypt_container_keybag(&img).expect("keybag decrypts");
let mut rec = volume_records(&container_kb, &FIXTURE_VOLUME_UUID).expect("records");
rec.wrapped_vek.truncate(8);
let vs = rec.volume_keybag_block as usize * block_size;
let ve = vs + rec.volume_keybag_blocks as usize * block_size;
let vkb = decrypt_keybag_area(&img[vs..ve], &FIXTURE_VOLUME_UUID, vs / KEYBAG_SECTOR);
let e = unlock_with_volume_keybag(&vkb, &rec, "apfs-FV-TEST-2026")
.expect_err("a short wrapped VEK must fail");
assert!(format!("{e:?}").contains("wrapped_vek_len"), "got {e:?}");
}
#[test]
fn unlock_volume_rejects_an_unparseable_image() {
assert!(
unlock_volume(&[0u8; 4096], &[0x22; 16], "pw").is_err(),
"a non-APFS image is an image error, not a password error"
);
}
#[cfg(test)]
fn container_keybag_for(uuid: &[u8; 16], entries: &[(u16, Vec<u8>)]) -> Vec<u8> {
let mut data = vec![0u8; OBJ_PHYS_LEN + 16];
data[24..28].copy_from_slice(b"syek");
data[OBJ_PHYS_LEN..OBJ_PHYS_LEN + 2].copy_from_slice(&1u16.to_le_bytes());
data[KL_NKEYS..KL_NKEYS + 2].copy_from_slice(&(entries.len() as u16).to_le_bytes());
for (tag, payload) in entries {
let mut e = vec![0u8; KE_HEADER_LEN + payload.len()];
e[..16].copy_from_slice(uuid);
e[KE_TAG..KE_TAG + 2].copy_from_slice(&tag.to_le_bytes());
e[KE_KEYLEN..KE_KEYLEN + 2].copy_from_slice(&(payload.len() as u16).to_le_bytes());
e[KE_HEADER_LEN..].copy_from_slice(payload);
let padded = (e.len() + 15) & !15;
e.resize(padded, 0);
data.extend_from_slice(&e);
}
data
}
#[test]
fn volume_records_rejects_a_wrong_sized_wrapped_vek() {
let uuid = [0x33u8; 16];
let inner = vec![0x83, 8, 0, 0, 0, 0, 0, 0, 0, 0];
let a3 = {
let mut v = vec![0xa3, inner.len() as u8];
v.extend_from_slice(&inner);
v
};
let seq = {
let mut v = vec![0x30, a3.len() as u8];
v.extend_from_slice(&a3);
v
};
let kb = container_keybag_for(&uuid, &[(0x02, seq)]);
let e = volume_records(&kb, &uuid).expect_err("a short wrapped VEK must be refused");
assert!(format!("{e:?}").contains("wrapped_vek_len"), "got {e:?}");
}
#[test]
fn volume_records_rejects_short_unlock_records() {
let uuid = [0x44u8; 16];
let kb = container_keybag_for(&uuid, &[(0x03, vec![0u8; 4])]);
let e = volume_records(&kb, &uuid).expect_err("a short prange must be refused");
assert!(format!("{e:?}").contains("unlock_records_len"), "got {e:?}");
}
#[test]
fn volume_records_rejects_a_volume_key_entry_with_no_wrapped_key() {
let uuid = [0x55u8; 16];
let a3 = vec![0xa3, 2, 0x99, 0];
let seq = {
let mut v = vec![0x30, a3.len() as u8];
v.extend_from_slice(&a3);
v
};
let kb = container_keybag_for(&uuid, &[(0x02, seq)]);
assert!(
volume_records(&kb, &uuid).is_err(),
"a volume key entry with no 0x83 must be refused"
);
}
#[test]
fn a_volume_keybag_with_an_unsupported_ber_length_yields_no_kek() {
let uuid = [0x66u8; 16];
let seq = vec![0x30, 0x84, 0, 0, 0, 4, 0xa3, 2, 0x83, 0];
let kb = container_keybag_for(&uuid, &[(0x01, seq)]);
let records = VolumeRecords {
wrapped_vek: vec![0u8; 40],
volume_keybag_block: 1,
volume_keybag_blocks: 1,
};
let e = unlock_with_volume_keybag(&kb, &records, "pw")
.expect_err("an unreadable KEK object must fail");
assert!(format!("{e:?}").contains("kek_object_absent"), "got {e:?}");
}
#[test]
fn a_volume_keybag_with_a_two_byte_ber_length_is_walked() {
let uuid = [0x77u8; 16];
let mut a3 = vec![0xa3, 0x81, 44];
a3.extend_from_slice(&[0x83, 40]);
a3.extend_from_slice(&[0xCC; 40]);
a3.extend_from_slice(&[0x84, 0x01, 0x05]); let mut seq = vec![0x30, 0x82, 0x00, a3.len() as u8];
seq.extend_from_slice(&a3);
let kb = container_keybag_for(&uuid, &[(0x01, seq)]);
let records = VolumeRecords {
wrapped_vek: vec![0u8; 40],
volume_keybag_block: 1,
volume_keybag_blocks: 1,
};
let e = unlock_with_volume_keybag(&kb, &records, "pw")
.expect_err("a KEK object without a salt cannot derive a key");
assert!(
!format!("{e:?}").contains("kek_object_absent"),
"the 0x82 length must have been walked, got {e:?}"
);
}
#[test]
fn tier1_symlink_target_reads_through_decryption() {
let img = dfvfs_container();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
let vek: [u8; 32] = unlock_volume(&img, &volume_uuid, "apfs-TEST")
.expect("dfVFS's password unlocks dfVFS's image")
.vek
.as_slice()
.try_into()
.expect("VEK is 32 bytes");
let mut cur = std::io::Cursor::new(&img[..]);
let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
vol.set_vek(vek);
let link =
crate::dir::open_path(&mut cur, &vol, "a_link", block_size).expect("a_link resolves");
assert_eq!(
link.oid, 22,
"dfVFS pins a_link's inode at 22 on this image"
);
let target = crate::xattr::symlink_target(&mut cur, &vol, link.oid, block_size)
.expect("symlink target reads")
.expect("a_link is a symlink and must have a target");
assert_eq!(
target, "a_directory/a_file",
"dfVFS pins the linked entry's name as a_file on the ENCRYPTED image \
(it is another_file on the plaintext one)"
);
}
#[test]
fn the_encrypted_corpus_carries_no_xattrs_or_resource_fork() {
let img = dfvfs_container();
let block_size = crate::bytes::le_u32(&img, NX_BLOCK_SIZE) as usize;
let container_kb = decrypt_container_keybag(&img).expect("container keybag decrypts");
let volume_uuid: [u8; 16] = container_kb[48..64].try_into().expect("16 bytes");
let vek: [u8; 32] = unlock_volume(&img, &volume_uuid, "apfs-TEST")
.expect("unlocks")
.vek
.as_slice()
.try_into()
.expect("VEK is 32 bytes");
let mut cur = std::io::Cursor::new(&img[..]);
let (_, mut vol) = fs_tree_root(&img, &mut cur, block_size);
vol.set_vek(vek);
let dir = crate::dir::open_path(&mut cur, &vol, "a_directory", block_size)
.expect("a_directory resolves");
let mut names: Vec<String> = crate::dir::list_dir(&mut cur, &vol, dir.oid, block_size)
.expect("lists")
.into_iter()
.map(|e| e.name)
.collect();
names.sort();
assert_eq!(
names,
vec!["a_file", "another_file"],
"no a_resourcefork here, unlike the plaintext image"
);
}
}