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