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.
61const KL_NKEYS: usize = 2; // u16
62const KL_ENTRIES_OFF: usize = 16; // entries begin after the 16-byte header
63const KE_TAG: usize = 16; // u16 within an entry
64const KE_KEYLEN: usize = 18; // u16 within an entry
65const KE_HEADER_LEN: usize = 24; // uuid(16) + tag(2) + keylen(2) + pad(4)
66/// Cap on `kl_nkeys` (a hostile blob must not drive an unbounded loop).
67const MAX_KEYBAG_ENTRIES: usize = 4096;
68
69/// Parse a container/volume keybag (`kb_locker`) into observed state — which
70/// tags are present, whether a passphrase hint exists, and whether key material
71/// is present — **without** unwrapping any key.
72///
73/// # Errors
74/// [`crate::ApfsError::Io`] never (in-memory); returns `Ok` with whatever the
75/// blob structurally yields. A malformed entry stops the walk early rather than
76/// over-reading.
77pub fn read_keybag(data: &[u8]) -> crate::Result<EncryptionState> {
78 let nkeys = (crate::bytes::le_u16(data, KL_NKEYS) as usize).min(MAX_KEYBAG_ENTRIES);
79 let mut tags_present = Vec::new();
80 let mut unknown_tags = Vec::new();
81 let mut off = KL_ENTRIES_OFF;
82 for _ in 0..nkeys {
83 // Stop if the entry header would run past the blob (never over-read).
84 if off + KE_HEADER_LEN > data.len() {
85 break;
86 }
87 let raw_tag = crate::bytes::le_u16(data, off + KE_TAG);
88 let tag = KeybagTag::from_u16(raw_tag);
89 let keylen = crate::bytes::le_u16(data, off + KE_KEYLEN) as usize;
90 if tag == KeybagTag::Unknown {
91 unknown_tags.push((raw_tag, off as u64));
92 }
93 if !tags_present.contains(&tag) {
94 tags_present.push(tag);
95 }
96 // Advance by the 16-byte-aligned entry size.
97 let entry_len = (KE_HEADER_LEN + keylen + 15) & !15;
98 off += entry_len.max(16);
99 }
100 let has_passphrase_hint = tags_present.contains(&KeybagTag::VolumePassphraseHint);
101 // "Encrypted" = actual key material is present (a wrapping key, a wrapped
102 // volume key, or the volume-keybag unlock records).
103 let encrypted = tags_present.iter().any(|t| {
104 matches!(
105 t,
106 KeybagTag::WrappingKey | KeybagTag::VolumeKey | KeybagTag::VolumeUnlockRecords
107 )
108 });
109 Ok(EncryptionState {
110 encrypted,
111 tags_present,
112 has_passphrase_hint,
113 unknown_tags,
114 })
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120
121 /// Build a `kb_locker` keybag blob: 16-byte header (`kl_version`@0,
122 /// `kl_nkeys`@2, `kl_nbytes`@4, pad), then `keybag_entry_t` entries
123 /// (`ke_uuid`@0[16], `ke_tag`@16, `ke_keylen`@18, pad[4], `ke_keydata`@24),
124 /// each 16-byte aligned (libfsapfs layout).
125 fn keybag(entries: &[(u16, usize)]) -> Vec<u8> {
126 let mut data = vec![0u8; 16];
127 data[0..2].copy_from_slice(&1u16.to_le_bytes()); // kl_version
128 data[2..4].copy_from_slice(&(entries.len() as u16).to_le_bytes()); // kl_nkeys
129 for &(tag, keylen) in entries {
130 let mut e = vec![0u8; 24 + keylen];
131 e[16..18].copy_from_slice(&tag.to_le_bytes()); // ke_tag
132 e[18..20].copy_from_slice(&(keylen as u16).to_le_bytes()); // ke_keylen
133 let padded = (e.len() + 15) & !15; // 16-byte align
134 e.resize(padded, 0);
135 data.extend_from_slice(&e);
136 }
137 let nbytes = data.len() as u32;
138 data[4..8].copy_from_slice(&nbytes.to_le_bytes()); // kl_nbytes
139 data
140 }
141
142 #[test]
143 fn reads_volume_key_and_hint_tags() {
144 // A volume keybag with a wrapped volume key and a passphrase hint.
145 let kb = keybag(&[(0x02, 32), (0x04, 8)]);
146 let st = read_keybag(&kb).expect("parse keybag");
147 assert!(st.encrypted, "a keybag with a volume key is encrypted");
148 assert!(st.tags_present.contains(&KeybagTag::VolumeKey));
149 assert!(st.tags_present.contains(&KeybagTag::VolumePassphraseHint));
150 assert!(st.has_passphrase_hint);
151 }
152
153 #[test]
154 fn empty_keybag_reports_not_encrypted() {
155 let kb = keybag(&[]);
156 let st = read_keybag(&kb).expect("parse empty keybag");
157 assert!(!st.encrypted);
158 assert!(st.tags_present.is_empty());
159 assert!(!st.has_passphrase_hint);
160 }
161
162 #[test]
163 fn unknown_tag_maps_to_unknown_and_records_raw_value() {
164 // A reserved/unexpected tag must decode as Unknown (never panic) and its
165 // raw value + offset must be retained for the show-the-value rule.
166 let kb = keybag(&[(0x55, 4)]);
167 let st = read_keybag(&kb).expect("parse keybag");
168 assert!(st.tags_present.contains(&KeybagTag::Unknown));
169 assert_eq!(st.unknown_tags, vec![(0x55u16, 16u64)]);
170 }
171}