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.
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 from_u16_maps_every_known_tag() {
155        // Each documented KB_TAG_* value decodes to its named variant; a wrapping
156        // key, unlock records, or user payload all round-trip through from_u16.
157        assert_eq!(KeybagTag::from_u16(0x01), KeybagTag::WrappingKey);
158        assert_eq!(KeybagTag::from_u16(0x02), KeybagTag::VolumeKey);
159        assert_eq!(KeybagTag::from_u16(0x03), KeybagTag::VolumeUnlockRecords);
160        assert_eq!(KeybagTag::from_u16(0x04), KeybagTag::VolumePassphraseHint);
161        assert_eq!(KeybagTag::from_u16(0xf8), KeybagTag::UserPayload);
162        assert_eq!(KeybagTag::from_u16(0x99), KeybagTag::Unknown);
163    }
164
165    #[test]
166    fn wrapping_key_and_unlock_records_are_encrypted() {
167        // A volume keybag with a wrapping key (0x01) and unlock records (0x03) is
168        // encrypted, and both named tags are surfaced.
169        let kb = keybag(&[(0x01, 32), (0x03, 16)]);
170        let st = read_keybag(&kb).expect("parse keybag");
171        assert!(st.encrypted, "wrapping-key / unlock-records ⇒ encrypted");
172        assert!(st.tags_present.contains(&KeybagTag::WrappingKey));
173        assert!(st.tags_present.contains(&KeybagTag::VolumeUnlockRecords));
174    }
175
176    #[test]
177    fn empty_keybag_reports_not_encrypted() {
178        let kb = keybag(&[]);
179        let st = read_keybag(&kb).expect("parse empty keybag");
180        assert!(!st.encrypted);
181        assert!(st.tags_present.is_empty());
182        assert!(!st.has_passphrase_hint);
183    }
184
185    #[test]
186    fn unknown_tag_maps_to_unknown_and_records_raw_value() {
187        // A reserved/unexpected tag must decode as Unknown (never panic) and its
188        // raw value + offset must be retained for the show-the-value rule.
189        let kb = keybag(&[(0x55, 4)]);
190        let st = read_keybag(&kb).expect("parse keybag");
191        assert!(st.tags_present.contains(&KeybagTag::Unknown));
192        assert_eq!(st.unknown_tags, vec![(0x55u16, 16u64)]);
193    }
194
195    #[test]
196    fn header_claiming_more_entries_than_the_blob_stops_early() {
197        // kl_nkeys says 4 entries but the blob is only the 16-byte header + 8
198        // bytes: the walk must break at the first entry that would over-read,
199        // never panic or over-read (bounds-safe against a lying count).
200        let mut data = vec![0u8; 24];
201        data[0..2].copy_from_slice(&1u16.to_le_bytes()); // kl_version
202        data[2..4].copy_from_slice(&4u16.to_le_bytes()); // kl_nkeys (lies)
203        let st = read_keybag(&data).expect("parse truncated keybag");
204        assert!(st.tags_present.is_empty(), "no entry fits → nothing parsed");
205    }
206}