From a2ce82315ea689481be2bea3ab9d3ce02e669428 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 18 Oct 2026 23:15:25 -0300 Subject: [PATCH] =?UTF-8?q?FileVault=202=20(Core=20Storage):=20confere=20c?= =?UTF-8?q?om=20o=20hash=20SHA-256=20de=20refer=C3=AAncia=20do=20cryptsetu?= =?UTF-8?q?p?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/guara-core/src/crypt/filevault.rs | 233 +++++++++++++++++++++++ crates/guara-core/tests/filevault.rs | 35 ++++ 2 files changed, 268 insertions(+) create mode 100644 crates/guara-core/tests/filevault.rs diff --git a/crates/guara-core/src/crypt/filevault.rs b/crates/guara-core/src/crypt/filevault.rs index e69de29..e0f5792 100644 --- a/crates/guara-core/src/crypt/filevault.rs +++ b/crates/guara-core/src/crypt/filevault.rs @@ -0,0 +1,233 @@ +//! FileVault 2 sobre Core Storage (volumes HFS+ cifrados do macOS 10.7 a 10.12 e Fusion Drives). +//! Senha ou chave do volume; chave de recuperação institucional não é suportada. + +use super::*; +use crate::source::{read_vec, SubSource}; +use sha2::{Digest, Sha256}; + +const MD_BLOCK: usize = 8192; + +fn le32(b: &[u8], o: usize) -> u32 { + u32::from_le_bytes(b[o..o + 4].try_into().unwrap()) +} +fn le64(b: &[u8], o: usize) -> u64 { + u64::from_le_bytes(b[o..o + 8].try_into().unwrap()) +} + +pub fn detect(head: &[u8]) -> bool { + head.len() >= 512 && head[88] == 0x43 && head[89] == 0x53 && head[10] == 0x10 +} + +/// Desembrulha uma chave AES-128 de 24 bytes (RFC 3394). +fn unwrap_key(kek: &[u8], wrapped: &[u8]) -> Option<[u8; 16]> { + let aes = AesAny::new(kek)?; + if wrapped.len() != 24 { + return None; + } + let mut a: [u8; 8] = wrapped[..8].try_into().unwrap(); + let mut r2: [u8; 8] = wrapped[8..16].try_into().unwrap(); + let mut r3: [u8; 8] = wrapped[16..24].try_into().unwrap(); + for t in (1..=12u64).rev() { + let prev_r2 = r2; + let tb = t.to_be_bytes(); + let mut blk = [0u8; 16]; + for i in 0..8 { + blk[i] = a[i] ^ tb[i]; + } + blk[8..].copy_from_slice(&r3); + aes.decrypt(&mut blk); + a.copy_from_slice(&blk[..8]); + r2.copy_from_slice(&blk[8..]); + r3 = prev_r2; + } + if a != [0xA6; 8] { + return None; + } + let mut out = [0u8; 16]; + out[..8].copy_from_slice(&r2); + out[8..].copy_from_slice(&r3); + Some(out) +} + +fn b64(s: &str) -> Option> { + let mut out = vec![]; + let (mut acc, mut bits) = (0u32, 0); + for c in s.bytes() { + let v = match c { + b'A'..=b'Z' => c - b'A', + b'a'..=b'z' => c - b'a' + 26, + b'0'..=b'9' => c - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' | b'\n' | b'\r' | b' ' | b'\t' => continue, + _ => return None, + } as u32; + acc = (acc << 6) | v; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +/// Valor de `NOMEVALOR` num plist simples (primeira ocorrência com valor). +fn plist_value(xml: &str, key: &str) -> Option { + let k = format!("{key}"); + let mut from = 0; + while let Some(p) = xml[from..].find(&k) { + let rest = &xml[from + p + k.len()..]; + from += p + k.len(); + let Some(open) = rest.find('<').filter(|&o| o == 0) else { continue }; + let Some(gt) = rest[open..].find('>') else { continue }; + if rest[open..open + gt].ends_with('/') { + continue; // + } + let Some(te) = rest[open + 1..].find(|c: char| c == ' ' || c == '>') else { continue }; + let tag = &rest[open + 1..open + 1 + te]; + let vstart = open + gt + 1; + if let Some(e) = rest[vstart..].find(&format!("")) { + let v = rest[vstart..vstart + e].trim(); + if !v.is_empty() { + return Some(v.to_string()); + } + } + } + None +} + +fn xml_of(blk: &[u8], off_pos: usize) -> Option { + let off = le32(blk, off_pos) as usize; + let size = le32(blk, off_pos + 4) as usize; + if off >= MD_BLOCK || size > MD_BLOCK - off { + return None; + } + Some(String::from_utf8_lossy(&blk[off..off + size]).to_string()) +} + +pub fn unlock(src: Arc, secrets: &[Secret]) -> UnlockResult { + let corrupt = |s: &str| UnlockError::Corrupt(s.to_string()); + let h = read_vec(src.as_ref(), 0, 512).map_err(|e| corrupt(&e.to_string()))?; + if !detect(&h) { + return Err(corrupt("não é um volume Core Storage")); + } + let block_size = le32(&h, 96) as u64; + let disklbl = le64(&h, 104); + if block_size == 0 || block_size > 1 << 20 || le32(&h, 168) != 16 { + return Err(UnlockError::Unsupported("Core Storage com chave de metadados que não é AES-128".into())); + } + let mut md_key = [0u8; 32]; + md_key[..16].copy_from_slice(&h[176..192]); + md_key[16..].copy_from_slice(&h[304..320]); + let (k1, k2) = (AesAny::new(&md_key[..16]).unwrap(), AesAny::new(&md_key[16..]).unwrap()); + let md_cipher = SectorCipher::Xts { k1, k2, iv: Iv::Plain64 }; + + let label = read_vec(src.as_ref(), disklbl * block_size, MD_BLOCK).map_err(|e| corrupt(&e.to_string()))?; + if label.len() < MD_BLOCK { + return Err(corrupt("rótulo do disco truncado")); + } + let vgd_off = le32(&label, 220) as u64; + let vgd = read_vec(src.as_ref(), disklbl * block_size + vgd_off, 40).map_err(|e| corrupt(&e.to_string()))?; + if vgd.len() < 40 { + return Err(corrupt("descritor de grupos truncado")); + } + let md_blocks = le64(&vgd, 8); + let md_start = le64(&vgd, 32) * block_size; + + let (mut iters, mut salt, mut wkek, mut wvk) = (0u32, vec![], vec![], vec![]); + let (mut lv_size, mut lv_off) = (0u64, 0u64); + let mut family = [0u8; 16]; + let mut got = 0u8; + for i in 0..md_blocks.min(64) { + let mut b = read_vec(src.as_ref(), md_start + i * MD_BLOCK as u64, MD_BLOCK).map_err(|e| corrupt(&e.to_string()))?; + if b.len() < MD_BLOCK || b.iter().all(|&x| x == 0) { + break; + } + // XTS sobre o bloco inteiro, com o número do bloco como ajuste inicial. + md_cipher.decrypt_sector(&mut b, i, 0); + match u16::from_le_bytes([b[10], b[11]]) { + 0x0019 => { + if let Some(xml) = xml_of(&b, 112) { + let pwk = plist_value(&xml, "PassphraseWrappedKEKStruct").and_then(|s| b64(&s)); + let kw = plist_value(&xml, "KEKWrappedVolumeKeyStruct").and_then(|s| b64(&s)); + if let (Some(p), Some(k)) = (pwk, kw) { + if p.len() >= 172 && k.len() >= 32 { + salt = p[8..24].to_vec(); + wkek = p[32..56].to_vec(); + iters = le32(&p, 168); + wvk = k[8..32].to_vec(); + got |= 1; + } + } + } + } + 0x001a => { + if let Some(xml) = xml_of(&b, 128) { + if let Some(v) = plist_value(&xml, "com.apple.corestorage.lv.size") { + lv_size = u64::from_str_radix(v.trim_start_matches("0x"), 16).unwrap_or(0); + got |= 8; + if let Some(u) = plist_value(&xml, "com.apple.corestorage.lv.familyUUID") { + let hex: String = u.chars().filter(|c| c.is_ascii_hexdigit()).collect(); + if let Some(b) = super::from_hex(&hex).filter(|b| b.len() == 16) { + family.copy_from_slice(&b); + got |= 2; + } + } + } + } + } + 0x0305 => { + lv_off = le32(&b, 104) as u64 * block_size; + got |= 4; + } + _ => {} + } + } + if got != 7 | 8 { + return Err(corrupt("metadados Core Storage incompletos")); + } + + let finish = |vk16: [u8; 16]| -> UnlockResult { + let mut full = [0u8; 32]; + full[..16].copy_from_slice(&vk16); + full[16..].copy_from_slice(&Sha256::new().chain_update(vk16).chain_update(family).finalize()[..16]); + let cipher = SectorCipher::Xts { + k1: AesAny::new(&full[..16]).unwrap(), + k2: AesAny::new(&full[16..]).unwrap(), + iv: Iv::Plain64, + }; + let cs = CryptSource::new(src.clone(), cipher, lv_off, lv_size, 512); + Ok(Unlocked { + source: Arc::new(cs), + description: "FileVault 2 (Core Storage, AES-128-XTS)".into(), + volume_key: Some(vk16.to_vec()), + }) + }; + for s in secrets { + if let Secret::VolumeKey(k) = s { + if k.len() >= 16 { + let mut a = [0u8; 16]; + a.copy_from_slice(&k[..16]); + return finish(a); + } + } + } + for s in secrets { + if let Secret::Password(p) = s { + let mut pk = [0u8; 16]; + pbkdf2::pbkdf2_hmac::(p.as_bytes(), &salt, iters.max(1), &mut pk); + if let Some(kek) = unwrap_key(&pk, &wkek) { + if let Some(vk) = unwrap_key(&kek, &wvk) { + return finish(vk); + } + } + } + } + Err(UnlockError::WrongSecret) +} + +pub fn unlock_window(base: Arc, start: u64, len: u64, secrets: &[Secret]) -> UnlockResult { + unlock(Arc::new(SubSource::new(base, start, len)), secrets) +} diff --git a/crates/guara-core/tests/filevault.rs b/crates/guara-core/tests/filevault.rs new file mode 100644 index 0000000..7bb4d46 --- /dev/null +++ b/crates/guara-core/tests/filevault.rs @@ -0,0 +1,35 @@ +//! FileVault 2 (Core Storage): imagem de referência do cryptsetup (senha heslo123). + +use guara_core::crypt::{filevault, Secret, UnlockError}; +use guara_core::hashing::MultiHasher; +use guara_core::source::{FileSource, Source}; +use std::sync::Arc; + +#[test] +fn opens_reference_image_and_matches_known_hash() { + let p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/fvault2-images/small"); + if !p.exists() { + eprintln!("SKIP: execute scripts/fetch-testdata.sh"); + return; + } + let src: Arc = Arc::new(FileSource::open(&p).unwrap()); + assert!(matches!( + filevault::unlock(src.clone(), &[Secret::Password("errada".into())]), + Err(UnlockError::WrongSecret) + )); + let u = filevault::unlock(src, &[Secret::Password("heslo123".into())]).unwrap(); + assert_eq!(u.source.size(), 167_772_160); + assert_eq!( + guara_core::crypt::to_hex(u.volume_key.as_ref().unwrap()), + "20734d3389212774d7610c29d732880 9".replace(' ', "") + ); + let mut h = MultiHasher::new(); + let mut off = 0; + let mut buf = vec![0u8; 1 << 20]; + while off < u.source.size() { + let n = u.source.read_at(off, &mut buf).unwrap(); + h.update(&buf[..n]); + off += n as u64; + } + assert_eq!(h.finalize().sha256, "2c662e36c0f7e2f5583e6a939bbcbdc660805692d0fccaa45ad4052beb3b8e18"); +}