Skip to main content

filevault/
lib.rs

1//! # filevault — Apple CoreStorage / FileVault 2 (FVDE) reader & decryptor
2//!
3//! A panic-free, bounds-checked decryptor for CoreStorage / FileVault 2 volumes
4//! (macOS 10.7–10.15, AES-XTS-128, password protector). Parses the physical
5//! volume header, decrypts the CoreStorage metadata, derives the volume key
6//! hierarchy from a password (PBKDF2-HMAC-SHA256 → RFC 3394 AES-KW → SHA-256
7//! tweak key, all RustCrypto — never hand-rolled), and exposes the plaintext
8//! logical volume as a `Read + Seek` stream.
9//!
10//! APFS-native encryption (10.13+) is a separate format and is out of scope.
11//!
12//! ```no_run
13//! use std::fs::File;
14//! use filevault::FileVaultVolume;
15//!
16//! let image = File::open("cs_partition.raw")?;
17//! let mut volume = FileVaultVolume::unlock_with_password(image, "fvde-TEST")?;
18//! let mut sector = [0u8; 512];
19//! volume.read_at(0, &mut sector)?;
20//! # Ok::<(), filevault::FileVaultError>(())
21//! ```
22
23#![forbid(unsafe_code)]
24// `doc_markdown` false-positives on the domain proper nouns that pervade these
25// docs (CoreStorage, FileVault, FVDE, PBKDF2, RustCrypto, UserType, …).
26#![allow(clippy::doc_markdown)]
27#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
28
29use std::io::{Read, Seek, SeekFrom};
30
31pub mod context;
32pub mod error;
33pub mod metadata;
34pub mod read;
35#[cfg(test)]
36mod test_support;
37pub mod unlock;
38#[cfg(feature = "vfs")]
39pub mod vfs;
40pub mod volume;
41pub mod volume_header;
42pub mod xts;
43
44pub use context::{EncryptionContext, LogicalVolumeInfo, Protector, ProtectorKind};
45pub use error::FileVaultError;
46pub use volume::DecryptedVolume;
47pub use volume_header::VolumeHeader;
48
49/// Physical volume header length.
50const VOLUME_HEADER_LEN: usize = 512;
51
52/// Password-independent metadata parsed from a CoreStorage volume.
53///
54/// Everything here is derivable WITHOUT the password, so `filevault-forensic`
55/// can audit protector inventory, encryption state, and KDF strength on a locked
56/// volume. Producing it never unwraps any key.
57#[derive(Debug, Clone)]
58pub struct FileVaultInfo {
59    /// Physical volume identifier (UUID string).
60    pub physical_volume_identifier: String,
61    /// PBKDF2 iteration count of the password protector.
62    pub pbkdf2_iterations: u32,
63    /// PBKDF2 salt (16 bytes).
64    pub pbkdf2_salt: [u8; 16],
65    /// Logical volume family UUID (string).
66    pub family_uuid: String,
67    /// Logical volume identifier UUID (string), if present.
68    pub lv_identifier: Option<String>,
69    /// Logical volume name, if present.
70    pub lv_name: Option<String>,
71    /// Logical volume size in bytes.
72    pub lv_size: u64,
73    /// Encryption method label (always AES-XTS-128 for a supported volume).
74    pub encryption_method: &'static str,
75    /// LV conversion status (`Complete` / `Converting` / …), if present.
76    pub conversion_status: Option<String>,
77    /// Protectors (crypto users) present.
78    pub protectors: Vec<Protector>,
79}
80
81/// An unlocked CoreStorage / FileVault volume presenting the decrypted logical
82/// volume as a `Read + Seek` stream.
83#[derive(Debug)]
84pub struct FileVaultVolume<R: Read + Seek> {
85    decrypted: DecryptedVolume<R>,
86    info: FileVaultInfo,
87}
88
89impl<R: Read + Seek> FileVaultVolume<R> {
90    /// Parse, decrypt the metadata, derive the key hierarchy from `password`,
91    /// and return an unlocked volume.
92    ///
93    /// # Errors
94    /// Any [`FileVaultError`] from parsing (not CoreStorage, unsupported method,
95    /// missing metadata) or key derivation (wrong password → `KeyUnwrap`).
96    pub fn unlock_with_password(mut reader: R, password: &str) -> Result<Self, FileVaultError> {
97        let mut header_bytes = [0u8; VOLUME_HEADER_LEN];
98        reader.seek(SeekFrom::Start(0))?;
99        read_exact_or_err(&mut reader, &mut header_bytes)?;
100        let header = VolumeHeader::parse(&header_bytes)?;
101
102        let plaintext = decrypt_metadata_region(&mut reader, &header)?;
103        let context = EncryptionContext::extract(&plaintext)?;
104        let lv = LogicalVolumeInfo::extract(&plaintext)?;
105
106        let family_uuid_bytes =
107            parse_uuid_bytes(&lv.family_uuid).ok_or(FileVaultError::MetadataStructureMissing {
108                what: "parseable family UUID",
109            })?;
110        let keys = unlock::derive_volume_keys(password, &context, &family_uuid_bytes)?;
111
112        let (physical_base, size) = resolve_lv_extent(&plaintext, &header, &lv);
113
114        let info = build_info(&header, &context, &lv, size);
115        let decrypted = DecryptedVolume::new(reader, keys, physical_base, size);
116        Ok(FileVaultVolume { decrypted, info })
117    }
118
119    /// Read and decrypt `buf.len()` bytes at logical `offset` (see
120    /// [`DecryptedVolume::read_at`]).
121    ///
122    /// # Errors
123    /// [`FileVaultError::Io`] if the underlying read fails.
124    pub fn read_at(&mut self, offset: u64, buf: &mut [u8]) -> Result<usize, FileVaultError> {
125        self.decrypted.read_at(offset, buf)
126    }
127
128    /// The logical volume size in bytes.
129    #[must_use]
130    pub fn size(&self) -> u64 {
131        self.decrypted.size()
132    }
133
134    /// The password-independent parsed metadata.
135    #[must_use]
136    pub fn info(&self) -> &FileVaultInfo {
137        &self.info
138    }
139
140    /// Consume the volume, returning the inner decrypted `Read + Seek` stream.
141    #[must_use]
142    pub fn into_decrypted(self) -> DecryptedVolume<R> {
143        self.decrypted
144    }
145}
146
147impl<R: Read + Seek> Read for FileVaultVolume<R> {
148    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
149        self.decrypted.read(buf)
150    }
151}
152
153impl<R: Read + Seek> Seek for FileVaultVolume<R> {
154    fn seek(&mut self, pos: SeekFrom) -> std::io::Result<u64> {
155        self.decrypted.seek(pos)
156    }
157}
158
159/// Parse only the password-independent metadata from a reader, WITHOUT the
160/// password — the entry point `filevault-forensic` uses to audit a locked volume.
161///
162/// # Errors
163/// Any [`FileVaultError`] from header/metadata parsing.
164pub fn parse_info<R: Read + Seek>(mut reader: R) -> Result<FileVaultInfo, FileVaultError> {
165    let mut header_bytes = [0u8; VOLUME_HEADER_LEN];
166    reader.seek(SeekFrom::Start(0))?;
167    read_exact_or_err(&mut reader, &mut header_bytes)?;
168    let header = VolumeHeader::parse(&header_bytes)?;
169
170    let plaintext = decrypt_metadata_region(&mut reader, &header)?;
171    let context = EncryptionContext::extract(&plaintext)?;
172    let lv = LogicalVolumeInfo::extract(&plaintext)?;
173    let (_, size) = resolve_lv_extent(&plaintext, &header, &lv);
174    Ok(build_info(&header, &context, &lv, size))
175}
176
177/// Read the plaintext metadata region, then read and decrypt the encrypted
178/// metadata region, returning the decrypted metadata bytes.
179fn decrypt_metadata_region<R: Read + Seek>(
180    reader: &mut R,
181    header: &VolumeHeader,
182) -> Result<Vec<u8>, FileVaultError> {
183    let (region_offset, _) = metadata::plaintext_metadata_region(header);
184
185    // Read the first plaintext block to learn the region size.
186    let block_size = header.block_size as usize;
187    if block_size == 0 {
188        return Err(FileVaultError::OutOfRange {
189            what: "block size is zero",
190        });
191    }
192    let mut first_block = vec![0u8; block_size];
193    reader.seek(SeekFrom::Start(region_offset))?;
194    read_exact_or_err(reader, &mut first_block)?;
195
196    let region_size = metadata::plaintext_metadata_size(header, &first_block)?;
197    let mut region = vec![0u8; region_size as usize];
198    reader.seek(SeekFrom::Start(region_offset))?;
199    read_exact_or_err(reader, &mut region)?;
200
201    let location = metadata::locate_encrypted_metadata(header, &region)?;
202    let mut ciphertext = vec![0u8; location.length as usize];
203    reader.seek(SeekFrom::Start(location.primary_offset))?;
204    read_exact_or_err(reader, &mut ciphertext)?;
205
206    metadata::decrypt_metadata(header, &mut ciphertext);
207    Ok(ciphertext)
208}
209
210/// Derive the LV physical base and size. Prefer the parsed `0x0305` segment
211/// descriptor (a single contiguous segment); fall back to the plist LV size for
212/// the size when the segment map is absent.
213fn resolve_lv_extent(
214    metadata_bytes: &[u8],
215    header: &VolumeHeader,
216    lv: &LogicalVolumeInfo,
217) -> (u64, u64) {
218    let block_size = u64::from(header.block_size);
219    let segments = metadata::parse_segments(metadata_bytes, header.block_size as usize);
220    if let Some(first) = segments.first() {
221        let base = first.physical_block.saturating_mul(block_size);
222        let size = if lv.size != 0 {
223            lv.size
224        } else {
225            u64::from(first.number_of_blocks).saturating_mul(block_size)
226        };
227        (base, size)
228    } else {
229        (0, lv.size)
230    }
231}
232
233/// Assemble the password-independent [`FileVaultInfo`].
234fn build_info(
235    header: &VolumeHeader,
236    context: &EncryptionContext,
237    lv: &LogicalVolumeInfo,
238    size: u64,
239) -> FileVaultInfo {
240    FileVaultInfo {
241        physical_volume_identifier: format_uuid(&header.physical_volume_identifier),
242        pbkdf2_iterations: context.iterations,
243        pbkdf2_salt: context.salt,
244        family_uuid: lv.family_uuid.clone(),
245        lv_identifier: lv.lv_identifier.clone(),
246        lv_name: lv.name.clone(),
247        lv_size: size,
248        encryption_method: "AES-XTS-128",
249        conversion_status: context.conversion_status.clone(),
250        protectors: context.protectors.clone(),
251    }
252}
253
254/// Read exactly `buf.len()` bytes, erroring loudly on a short read (a truncated
255/// image is a bootstrap failure, never a silent empty result).
256fn read_exact_or_err<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<(), FileVaultError> {
257    reader.read_exact(buf).map_err(FileVaultError::Io)
258}
259
260/// Format 16 on-disk UUID bytes as the canonical `8-4-4-4-12` string.
261fn format_uuid(bytes: &[u8; 16]) -> String {
262    uuid::Uuid::from_bytes(*bytes).hyphenated().to_string()
263}
264
265/// Parse a UUID string to its 16 canonical bytes (as used for the tweak key).
266fn parse_uuid_bytes(text: &str) -> Option<[u8; 16]> {
267    uuid::Uuid::parse_str(text).ok().map(|u| *u.as_bytes())
268}
269
270#[cfg(test)]
271mod tests {
272    use super::test_support::{build_image, BLOCK, FAMILY, ITERS, KEY1, PASSWORD, PV_ID};
273    use super::*;
274    use std::io::Cursor;
275
276    #[test]
277    fn unlock_synthetic_volume_end_to_end() {
278        let image = build_image();
279        let mut vol = FileVaultVolume::unlock_with_password(Cursor::new(image), PASSWORD).unwrap();
280        assert_eq!(vol.size(), 0x4000);
281        assert_eq!(vol.info().pbkdf2_iterations, ITERS);
282        assert_eq!(vol.info().lv_name.as_deref(), Some("SynthLV"));
283        assert_eq!(vol.info().encryption_method, "AES-XTS-128");
284        assert_eq!(vol.info().conversion_status.as_deref(), Some("Complete"));
285        assert_eq!(vol.info().protectors.len(), 1);
286
287        // Decrypt LV offset 0 -> known plaintext (0,1,2,...).
288        let mut buf = [0u8; 512];
289        assert_eq!(vol.read_at(0, &mut buf).unwrap(), 512);
290        let expected: Vec<u8> = (0..512).map(|i| (i & 0xff) as u8).collect();
291        assert_eq!(&buf[..], &expected[..]);
292
293        // Read + Seek passthrough.
294        assert_eq!(vol.seek(SeekFrom::Start(512)).unwrap(), 512);
295        let mut b2 = [0u8; 16];
296        assert!(std::io::Read::read(&mut vol, &mut b2).unwrap() > 0);
297
298        // parse_info (no password) matches.
299        let image2 = build_image();
300        let info = parse_info(Cursor::new(image2)).unwrap();
301        assert_eq!(info.family_uuid, vol.info().family_uuid);
302        assert_eq!(info.lv_size, 0x4000);
303        assert_eq!(
304            info.physical_volume_identifier,
305            uuid::Uuid::from_bytes(PV_ID).hyphenated().to_string()
306        );
307
308        // into_decrypted exposes the stream.
309        let mut dec = vol.into_decrypted();
310        assert_eq!(dec.size(), 0x4000);
311        let mut b3 = [0u8; 16];
312        assert_eq!(dec.read_at(0, &mut b3).unwrap(), 16);
313    }
314
315    #[test]
316    fn unlock_wrong_password_fails() {
317        let image = build_image();
318        assert!(matches!(
319            FileVaultVolume::unlock_with_password(Cursor::new(image), "nope"),
320            Err(FileVaultError::KeyUnwrap { .. })
321        ));
322    }
323
324    #[test]
325    fn unlock_non_corestorage_fails() {
326        let image = vec![0u8; 4096];
327        assert!(matches!(
328            FileVaultVolume::unlock_with_password(Cursor::new(image), PASSWORD),
329            Err(FileVaultError::NotCoreStorage { .. })
330        ));
331    }
332
333    #[test]
334    fn truncated_image_errors_loudly() {
335        // A header-only image: metadata read runs past EOF -> Io error, not empty.
336        let mut image = vec![0u8; 600];
337        image[88] = b'C';
338        image[89] = b'S';
339        image[96..100].copy_from_slice(&(BLOCK as u32).to_le_bytes());
340        image[172..176].copy_from_slice(&2u32.to_le_bytes());
341        image[104..112].copy_from_slice(&1u64.to_le_bytes());
342        let err = parse_info(Cursor::new(image)).unwrap_err();
343        assert!(matches!(err, FileVaultError::Io(_)));
344    }
345
346    #[test]
347    fn zero_block_size_header_errors() {
348        // block_size 0 in the header -> decrypt_metadata_region OutOfRange.
349        let mut image = vec![0u8; 4096];
350        image[88] = b'C';
351        image[89] = b'S';
352        image[96..100].copy_from_slice(&0u32.to_le_bytes());
353        image[172..176].copy_from_slice(&2u32.to_le_bytes());
354        let err = parse_info(Cursor::new(image)).unwrap_err();
355        assert!(matches!(err, FileVaultError::OutOfRange { .. }));
356    }
357
358    #[test]
359    fn format_and_parse_uuid_roundtrip() {
360        let s = format_uuid(&FAMILY);
361        assert_eq!(parse_uuid_bytes(&s), Some(FAMILY));
362        assert_eq!(parse_uuid_bytes("not-a-uuid"), None);
363    }
364
365    #[test]
366    fn resolve_extent_uses_segment_blocks_when_lv_size_zero() {
367        // Segment present, but lv.size == 0 -> size derived from segment blocks.
368        let header = VolumeHeader {
369            block_size: BLOCK as u32,
370            bytes_per_sector: 512,
371            physical_volume_size: 0,
372            metadata_block_numbers: [1, 0, 0, 0],
373            key_data: KEY1,
374            physical_volume_identifier: PV_ID,
375        };
376        // Metadata with a single 0x0305 segment (16 blocks) and no lv.size key.
377        let mut meta = vec![0u8; BLOCK];
378        meta[10..12].copy_from_slice(&0x0305u16.to_le_bytes());
379        let payload = 64;
380        meta[payload..payload + 4].copy_from_slice(&1u32.to_le_bytes());
381        let entry = payload + 8;
382        meta[entry + 16..entry + 20].copy_from_slice(&2u32.to_le_bytes());
383        meta[entry + 32..entry + 40].copy_from_slice(&5u64.to_le_bytes());
384        let lv = LogicalVolumeInfo {
385            size: 0,
386            family_uuid: "x".to_string(),
387            lv_identifier: None,
388            name: None,
389        };
390        let (base, size) = resolve_lv_extent(&meta, &header, &lv);
391        assert_eq!(base, 5 * BLOCK as u64);
392        assert_eq!(size, 2 * BLOCK as u64);
393    }
394
395    #[test]
396    fn resolve_extent_no_segment_falls_back_to_lv_size() {
397        let header = VolumeHeader {
398            block_size: BLOCK as u32,
399            bytes_per_sector: 512,
400            physical_volume_size: 0,
401            metadata_block_numbers: [1, 0, 0, 0],
402            key_data: KEY1,
403            physical_volume_identifier: PV_ID,
404        };
405        let meta = vec![0u8; BLOCK]; // no 0x0305 block
406        let lv = LogicalVolumeInfo {
407            size: 12345,
408            family_uuid: "x".to_string(),
409            lv_identifier: None,
410            name: None,
411        };
412        let (base, size) = resolve_lv_extent(&meta, &header, &lv);
413        assert_eq!(base, 0);
414        assert_eq!(size, 12345);
415    }
416}