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 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}