diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index dafdeb4..dccdc29 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -20,6 +20,11 @@ flate2 = { version = "1", default-features = false, features = ["rust_backend"] thiserror = "2" tiny_http = "0.12" bzip2 = "0.6" +aes = "0.8" +hmac = "0.12" +pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } +ripemd = "0.1" +argon2 = "0.5" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/guara-core/src/crypt/bitlocker.rs b/crates/guara-core/src/crypt/bitlocker.rs new file mode 100644 index 0000000..b9beb0a --- /dev/null +++ b/crates/guara-core/src/crypt/bitlocker.rs @@ -0,0 +1,470 @@ +//! BitLocker (Windows 7 em diante, inclusive BitLocker To Go): AES-CBC com ou sem difusor +//! Elephant, e AES-XTS. Protetores: senha, chave de recuperação, chave de inicialização (.BEK), +//! chave limpa (proteção suspensa) e chave do volume já conhecida. TPM não é suportado. + +use super::*; +use crate::source::{read_vec, SubSource}; +use sha2::{Digest, Sha256}; + +fn le16(b: &[u8], o: usize) -> usize { + u16::from_le_bytes([b[o], b[o + 1]]) as usize +} +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()) +} + +const GUID_NORMAL: [u8; 16] = [0x3b, 0xd6, 0x67, 0x49, 0x29, 0x2e, 0xd8, 0x4a, 0x83, 0x99, 0xf6, 0xa3, 0x39, 0xe3, 0xd0, 0x01]; +const GUID_EOW: [u8; 16] = [0x3b, 0x4d, 0xa8, 0x92, 0x80, 0xdd, 0x0e, 0x4d, 0x9e, 0x4e, 0xb1, 0xe3, 0x28, 0x4e, 0xae, 0xd8]; + +/// Posição da tabela de deslocamentos FVE, se for BitLocker (normal ou To Go). +fn offsets_pos(vbr: &[u8]) -> Option { + if vbr.len() >= 11 && &vbr[3..11] == b"-FVE-FS-" { + return Some(176); + } + // BitLocker To Go: parece FAT ("MSWIN4.1"), mas guarda o GUID do BitLocker no setor de boot. + if vbr.len() >= 464 && (vbr[424..440] == GUID_NORMAL || vbr[424..440] == GUID_EOW) { + return Some(440); + } + None +} + +pub fn detect(vbr: &[u8]) -> bool { + offsets_pos(vbr).is_some() +} + +// ------------------------------------------------------------- Elephant + +fn rotl(x: u32, n: u32) -> u32 { + x.rotate_left(n) +} + +const RA: [u32; 4] = [9, 0, 13, 0]; +const RB: [u32; 4] = [0, 10, 0, 25]; + +fn words(buf: &[u8]) -> Vec { + buf.chunks_exact(4).map(|c| u32::from_le_bytes(c.try_into().unwrap())).collect() +} + +// Decifragem (ordem do dm-crypt do Linux): somas em ordem crescente. +fn diffuser_a_decrypt(d: &mut [u32]) { + let n = d.len(); + for _ in 0..5 { + for i in 0..n { + let a = d[(i + n - 2) % n]; + let b = rotl(d[(i + n - 5) % n], RA[i % 4]); + d[i] = d[i].wrapping_add(a ^ b); + } + } +} + +fn diffuser_b_decrypt(d: &mut [u32]) { + let n = d.len(); + for _ in 0..3 { + for i in 0..n { + let a = d[(i + 2) % n]; + let b = rotl(d[(i + 5) % n], RB[i % 4]); + d[i] = d[i].wrapping_add(a ^ b); + } + } +} + +/// Decifra um setor AES-CBC + difusor Elephant (BitLocker Vista/7 sem XTS). +pub fn elephant_decrypt(key: &AesAny, tweak: &AesAny, buf: &mut [u8], byte_off: u64) { + let mut iv = [0u8; 16]; + iv[..8].copy_from_slice(&byte_off.to_le_bytes()); + key.encrypt(&mut iv); + cbc_decrypt(key, &iv, buf); + let mut d = words(buf); + diffuser_b_decrypt(&mut d); + diffuser_a_decrypt(&mut d); + for (i, w) in d.iter().enumerate() { + buf[i * 4..i * 4 + 4].copy_from_slice(&w.to_le_bytes()); + } + // Chave do setor: duas cifragens do deslocamento com a chave de ajuste. + let mut k0 = [0u8; 16]; + k0[..8].copy_from_slice(&byte_off.to_le_bytes()); + let mut k1 = k0; + k1[15] = 0x80; + tweak.encrypt(&mut k0); + tweak.encrypt(&mut k1); + let mut sk = [0u8; 32]; + sk[..16].copy_from_slice(&k0); + sk[16..].copy_from_slice(&k1); + for (i, b) in buf.iter_mut().enumerate() { + *b ^= sk[i % 32]; + } +} + +// ------------------------------------------------------------------ CCM + +/// AES-CCM (nonce de 12 bytes, tag de 16): `blob` = tag cifrada (16) + dados cifrados. +fn ccm_decrypt(key: &AesAny, nonce: &[u8], blob: &[u8]) -> Option> { + if nonce.len() != 12 || blob.len() < 16 { + return None; + } + let ctr = |i: u32| -> [u8; 16] { + let mut a = [0u8; 16]; + a[0] = 2; + a[1..13].copy_from_slice(nonce); + a[13..16].copy_from_slice(&i.to_be_bytes()[1..]); + key.encrypt(&mut a); + a + }; + let s0 = ctr(0); + let mut tag = [0u8; 16]; + for i in 0..16 { + tag[i] = blob[i] ^ s0[i]; + } + let ct = &blob[16..]; + let mut pt = Vec::with_capacity(ct.len()); + for (bi, ch) in ct.chunks(16).enumerate() { + let s = ctr(bi as u32 + 1); + pt.extend(ch.iter().zip(s.iter()).map(|(a, b)| a ^ b)); + } + // CBC-MAC sobre B0 + dados. + let mut b0 = [0u8; 16]; + b0[0] = 0x3A; + b0[1..13].copy_from_slice(nonce); + b0[13..16].copy_from_slice(&(pt.len() as u32).to_be_bytes()[1..]); + key.encrypt(&mut b0); + let mut x = b0; + for ch in pt.chunks(16) { + for i in 0..ch.len() { + x[i] ^= ch[i]; + } + key.encrypt(&mut x); + } + (x == tag).then_some(pt) +} + +// --------------------------------------------------------------- chaves + +fn stretch(initial: &[u8; 32], salt: &[u8]) -> [u8; 32] { + let mut last = [0u8; 32]; + let mut buf = [0u8; 88]; + buf[32..64].copy_from_slice(initial); + buf[64..80].copy_from_slice(salt); + for i in 0u64..0x10_0000 { + buf[..32].copy_from_slice(&last); + buf[80..88].copy_from_slice(&i.to_le_bytes()); + last.copy_from_slice(&Sha256::digest(buf)); + } + last +} + +fn password_key(pw: &str, salt: &[u8]) -> [u8; 32] { + let utf16: Vec = pw.encode_utf16().flat_map(|u| u.to_le_bytes()).collect(); + let h1 = Sha256::digest(&utf16); + let mut init = [0u8; 32]; + init.copy_from_slice(&Sha256::digest(h1)); + stretch(&init, salt) +} + +/// Chave de recuperação: 8 blocos de 6 dígitos, cada um múltiplo de 11. +fn recovery_key(rp: &str, salt: &[u8]) -> Option<[u8; 32]> { + let digits: String = rp.chars().filter(|c| c.is_ascii_digit()).collect(); + if digits.len() != 48 { + return None; + } + let mut raw = [0u8; 16]; + for i in 0..8 { + let n: u32 = digits[i * 6..i * 6 + 6].parse().ok()?; + if n % 11 != 0 || n / 11 > 0xFFFF { + return None; + } + raw[i * 2..i * 2 + 2].copy_from_slice(&((n / 11) as u16).to_le_bytes()); + } + let mut init = [0u8; 32]; + init.copy_from_slice(&Sha256::digest(raw)); + Some(stretch(&init, salt)) +} + +// ----------------------------------------------------------- metadados + +#[derive(Debug, Clone)] +struct Entry { + etype: usize, + vtype: usize, + data: Vec, +} + +fn parse_entries(b: &[u8]) -> Vec { + let mut v = vec![]; + let mut o = 0; + while o + 8 <= b.len() { + let size = le16(b, o); + if size < 8 || o + size > b.len() { + break; + } + v.push(Entry { + etype: le16(b, o + 2), + vtype: le16(b, o + 4), + data: b[o + 8..o + size].to_vec(), + }); + o += size; + } + v +} + +struct Vmk { + protection: usize, + nested: Vec, +} + +impl Vmk { + fn ccm(&self) -> Option<&Entry> { + self.nested.iter().find(|e| e.vtype == 5) + } + fn stretch_salt(&self) -> Option<&[u8]> { + self.nested.iter().find(|e| e.vtype == 3).filter(|e| e.data.len() >= 20).map(|e| &e.data[4..20]) + } +} + +struct Fve { + method: usize, + volume_header_offset: u64, + volume_header_size: u64, + encrypted_size: u64, + meta_size: u64, + vmks: Vec, + fvek: Option, +} + +fn read_fve(src: &dyn Source, offset: u64) -> Option { + let h = read_vec(src, offset, 64 + 48).ok()?; + if h.len() < 112 || &h[..8] != b"-FVE-FS-" { + return None; + } + let version = le16(&h, 10); + if version != 2 { + return None; // Vista (versão 1) não suportado + } + let encrypted_size = le64(&h, 16); + let hdr_sectors = le32(&h, 28) as u64; + let volume_header_offset = le64(&h, 56); + let meta_size = le32(&h, 64) as usize; + if !(48..=1 << 20).contains(&meta_size) { + return None; + } + let method = le16(&h, 64 + 36); + let all = read_vec(src, offset + 64, meta_size).ok()?; + if all.len() < meta_size { + return None; + } + let entries = parse_entries(&all[48..]); + let mut vmks = vec![]; + let mut fvek = None; + let (mut vh_off, mut vh_size) = (volume_header_offset, hdr_sectors * 512); + for e in entries { + match (e.etype, e.vtype) { + (15, 15) if e.data.len() >= 16 => { + vh_off = le64(&e.data, 0); + vh_size = le64(&e.data, 8); + } + (2, 8) if e.data.len() > 28 => vmks.push(Vmk { + protection: le16(&e.data, 26), + nested: parse_entries(&e.data[28..]), + }), + (3, 5) => fvek = Some(e), + _ => {} + } + } + Some(Fve { + method, + volume_header_offset: vh_off, + volume_header_size: vh_size, + encrypted_size, + meta_size: 0x10000, + vmks, + fvek, + }) +} + +/// Chave de um arquivo .BEK (chave de inicialização). +fn bek_key(file: &[u8]) -> Option> { + if file.len() < 48 { + return None; + } + let es = parse_entries(&file[48..]); + for e in &es { + if e.vtype == 9 && e.data.len() > 24 { + for n in parse_entries(&e.data[24..]) { + if n.vtype == 1 && n.data.len() >= 36 { + return Some(n.data[4..36].to_vec()); + } + } + } + } + None +} + +fn candidate_keys(v: &Vmk, secrets: &[Secret]) -> Vec> { + let mut out: Vec> = vec![]; + // Chave limpa: o próprio protetor guarda a chave em claro. + if v.protection == 0 { + for n in &v.nested { + if n.vtype == 1 && n.data.len() >= 36 { + out.push(n.data[4..36].to_vec()); + } + } + } + for s in secrets { + match s { + Secret::Password(p) if v.protection == 0x2000 => { + if let Some(salt) = v.stretch_salt() { + out.push(password_key(p, salt).to_vec()); + } + } + Secret::RecoveryKey(r) if v.protection == 0x0800 => { + if let Some(k) = v.stretch_salt().and_then(|salt| recovery_key(r, salt)) { + out.push(k.to_vec()); + } + } + Secret::KeyFile(f) if v.protection == 0x0200 => { + if let Some(k) = bek_key(f) { + out.push(k); + } + } + _ => {} + } + } + out +} + +fn decrypt_key_entry(key: &[u8], e: &Entry) -> Option> { + let aes = AesAny::new(key)?; + if e.data.len() < 12 + 16 + 8 { + return None; + } + let pt = ccm_decrypt(&aes, &e.data[..12], &e.data[12..])?; + // payload = cabeçalho de entrada (8) + método (4) + chave + (pt.len() > 12).then(|| pt[12..].to_vec()) +} + +pub fn unlock(src: Arc, secrets: &[Secret]) -> UnlockResult { + let vbr = read_vec(src.as_ref(), 0, 512).map_err(|e| UnlockError::Corrupt(e.to_string()))?; + if !detect(&vbr) || vbr.len() < 512 { + return Err(UnlockError::Corrupt("não é BitLocker".into())); + } + let bps = le16(&vbr, 11).max(512) as u64; + let pos = offsets_pos(&vbr).unwrap_or(176); + // Três cópias dos metadados; usa a primeira legível. + let mut fve = None; + let mut meta_offsets = vec![]; + for i in 0..3 { + let off = le64(&vbr, pos + i * 8); + if off != 0 { + meta_offsets.push(off); + } + if off != 0 && off < src.size() { + if let Some(f) = read_fve(src.as_ref(), off) { + fve = Some(f); + break; + } + } + } + let fve = fve.ok_or_else(|| UnlockError::Unsupported("metadados FVE ilegíveis ou do Windows Vista".into()))?; + let fvek_entry = fve.fvek.as_ref().ok_or_else(|| UnlockError::Corrupt("sem FVEK".into()))?; + + let mut volume_key: Option> = None; + for s in secrets { + if let Secret::VolumeKey(k) = s { + volume_key = Some(k.clone()); + } + } + let mut vmk_found: Option> = None; + if volume_key.is_none() { + 'outer: for v in &fve.vmks { + let Some(ccm) = v.ccm() else { continue }; + for k in candidate_keys(v, secrets) { + if let Some(vmk) = decrypt_key_entry(&k, ccm) { + if vmk.len() >= 32 { + vmk_found = Some(vmk[..32].to_vec()); + break 'outer; + } + } + } + } + } + let fvek_bytes = if let Some(k) = volume_key { + k + } else { + let vmk = vmk_found.ok_or(UnlockError::WrongSecret)?; + decrypt_key_entry(&vmk, fvek_entry).ok_or_else(|| UnlockError::Corrupt("FVEK não decifra com a VMK".into()))? + }; + let k = &fvek_bytes; + let bad = || UnlockError::Corrupt("tamanho de chave incompatível com o método".into()); + let (cipher, name) = match fve.method { + 0x8000 | 0x8001 => { + let n = if fve.method == 0x8000 { 16 } else { 32 }; + if k.len() < 32 + n { + return Err(bad()); + } + ( + SectorCipher::Elephant { + key: AesAny::new(&k[..n]).ok_or_else(bad)?, + tweak: AesAny::new(&k[32..32 + n]).ok_or_else(bad)?, + }, + if n == 16 { "AES-128-CBC + difusor" } else { "AES-256-CBC + difusor" }, + ) + } + 0x8002 | 0x8003 => { + let n = if fve.method == 0x8002 { 16 } else { 32 }; + if k.len() < n { + return Err(bad()); + } + let key = AesAny::new(&k[..n]).ok_or_else(bad)?; + (SectorCipher::Cbc { key: key.clone(), iv: Iv::Eboiv(key) }, if n == 16 { "AES-128-CBC" } else { "AES-256-CBC" }) + } + 0x8004 | 0x8005 => { + let n = if fve.method == 0x8004 { 16 } else { 32 }; + if k.len() < 2 * n { + return Err(bad()); + } + let iv = if bps > 512 { Iv::Unit(bps) } else { Iv::Plain64 }; + ( + SectorCipher::Xts { + k1: AesAny::new(&k[..n]).ok_or_else(bad)?, + k2: AesAny::new(&k[n..2 * n]).ok_or_else(bad)?, + iv, + }, + if n == 16 { "AES-128-XTS" } else { "AES-256-XTS" }, + ) + } + m => return Err(UnlockError::Unsupported(format!("método de cifragem BitLocker {m:#x}"))), + }; + let len = src.size(); + let mut cs = CryptSource::new(src.clone(), cipher, 0, len, bps); + let mut patches = vec![]; + // Áreas de metadados FVE: o Windows não as expõe como dados. + for off in meta_offsets { + patches.push(Patch { at: off, len: fve.meta_size, from: 0, decrypt_from: None, zero: true }); + } + if fve.volume_header_size > 0 && fve.volume_header_offset > 0 { + // O início do volume original foi movido para outro lugar e cifrado lá. + patches.push(Patch { at: fve.volume_header_offset, len: fve.volume_header_size, from: 0, decrypt_from: None, zero: true }); + patches.push(Patch { + at: 0, + len: fve.volume_header_size, + from: fve.volume_header_offset, + decrypt_from: Some(fve.volume_header_offset), + zero: false, + }); + } + cs = cs.with_patches(patches); + if fve.encrypted_size > 0 && fve.encrypted_size < len { + cs = cs.with_plain_from(fve.encrypted_size); + } + Ok(Unlocked { + source: Arc::new(cs), + description: format!("BitLocker ({name})"), + volume_key: Some(fvek_bytes), + }) +} + +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/src/crypt/filevault.rs b/crates/guara-core/src/crypt/filevault.rs new file mode 100644 index 0000000..e69de29 diff --git a/crates/guara-core/src/crypt/luks.rs b/crates/guara-core/src/crypt/luks.rs new file mode 100644 index 0000000..7c0753a --- /dev/null +++ b/crates/guara-core/src/crypt/luks.rs @@ -0,0 +1,418 @@ +//! LUKS1 e LUKS2 (cifras AES; PBKDF2 e Argon2; hashes SHA-1/256/512 e RIPEMD-160). + +use super::*; +use crate::source::{read_vec, SubSource}; +use sha1::Sha1; +use sha2::{Digest, Sha256, Sha512}; + +pub const MAGIC: &[u8; 6] = b"LUKS\xBA\xBE"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Hash { + Sha1, + Sha256, + Sha512, + Ripemd160, +} + +impl Hash { + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "sha1" => Hash::Sha1, + "sha256" => Hash::Sha256, + "sha512" => Hash::Sha512, + "ripemd160" | "rmd160" => Hash::Ripemd160, + _ => return None, + }) + } + pub fn size(self) -> usize { + match self { + Hash::Sha1 | Hash::Ripemd160 => 20, + Hash::Sha256 => 32, + Hash::Sha512 => 64, + } + } + pub fn digest(self, parts: &[&[u8]]) -> Vec { + fn run(parts: &[&[u8]]) -> Vec { + let mut d = D::new(); + for p in parts { + d.update(p); + } + d.finalize().to_vec() + } + match self { + Hash::Sha1 => run::(parts), + Hash::Sha256 => run::(parts), + Hash::Sha512 => run::(parts), + Hash::Ripemd160 => run::(parts), + } + } + pub fn pbkdf2(self, pass: &[u8], salt: &[u8], iterations: u32, len: usize) -> Vec { + let mut out = vec![0u8; len]; + let it = iterations.max(1); + match self { + Hash::Sha1 => pbkdf2::pbkdf2_hmac::(pass, salt, it, &mut out), + Hash::Sha256 => pbkdf2::pbkdf2_hmac::(pass, salt, it, &mut out), + Hash::Sha512 => pbkdf2::pbkdf2_hmac::(pass, salt, it, &mut out), + Hash::Ripemd160 => pbkdf2::pbkdf2_hmac::(pass, salt, it, &mut out), + } + out + } +} + +/// Difusão do "anti-forensic splitter". +fn diffuse(block: &mut [u8], h: Hash) { + let ds = h.size(); + let mut i = 0u32; + let mut off = 0; + while off < block.len() { + let n = ds.min(block.len() - off); + let d = h.digest(&[&i.to_be_bytes(), &block[off..off + n]]); + block[off..off + n].copy_from_slice(&d[..n]); + off += n; + i += 1; + } +} + +/// Recompõe a chave a partir das `stripes` faixas (AFmerge). +pub fn af_merge(src: &[u8], block: usize, stripes: usize, h: Hash) -> Vec { + let mut buf = vec![0u8; block]; + for i in 0..stripes.saturating_sub(1) { + for (b, s) in buf.iter_mut().zip(&src[i * block..(i + 1) * block]) { + *b ^= *s; + } + diffuse(&mut buf, h); + } + let last = &src[(stripes - 1) * block..stripes * block]; + buf.iter().zip(last).map(|(a, b)| a ^ b).collect() +} + +/// Cifra descrita por texto como "aes-xts-plain64" ou "aes-cbc-essiv:sha256". +pub fn parse_cipher(spec: &str, key: &[u8]) -> std::result::Result { + let (alg, rest) = spec.split_once('-').ok_or_else(|| UnlockError::Unsupported(spec.into()))?; + if !alg.eq_ignore_ascii_case("aes") { + return Err(UnlockError::Unsupported(format!("cifra {alg} (apenas AES)"))); + } + let (mode, ivs) = rest.split_once('-').unwrap_or((rest, "plain")); + let bad = || UnlockError::Unsupported(format!("chave inválida para {spec}")); + let mk_iv = |k: &[u8]| -> std::result::Result { + let ivl = ivs.to_ascii_lowercase(); + Ok(match ivl.as_str() { + "plain64" => Iv::Plain64, + "plain" | "plain32" => Iv::Plain32, + "plain64be" => Iv::Plain64Be, + s if s.starts_with("essiv:") => { + let h = Hash::parse(&s[6..]).ok_or_else(|| UnlockError::Unsupported(spec.into()))?; + Iv::Essiv(AesAny::new(&h.digest(&[k])).ok_or_else(bad)?) + } + _ => return Err(UnlockError::Unsupported(format!("IV {ivs}"))), + }) + }; + match mode.to_ascii_lowercase().as_str() { + "xts" => { + if key.len() % 2 != 0 { + return Err(bad()); + } + let h = key.len() / 2; + Ok(SectorCipher::Xts { + k1: AesAny::new(&key[..h]).ok_or_else(bad)?, + k2: AesAny::new(&key[h..]).ok_or_else(bad)?, + iv: mk_iv(&key[..h])?, + }) + } + "cbc" => Ok(SectorCipher::Cbc { + key: AesAny::new(key).ok_or_else(bad)?, + iv: mk_iv(key)?, + }), + m => Err(UnlockError::Unsupported(format!("modo {m}"))), + } +} + +fn passwords(secrets: &[Secret]) -> Vec> { + secrets + .iter() + .filter_map(|s| match s { + Secret::Password(p) => Some(p.as_bytes().to_vec()), + Secret::KeyFile(k) => Some(k.clone()), + _ => None, + }) + .collect() +} + +fn volume_keys(secrets: &[Secret]) -> Vec> { + secrets + .iter() + .filter_map(|s| match s { + Secret::VolumeKey(k) => Some(k.clone()), + _ => None, + }) + .collect() +} + +pub fn detect(head: &[u8]) -> bool { + head.len() >= 8 && &head[..6] == MAGIC +} + +pub fn unlock(src: Arc, secrets: &[Secret]) -> UnlockResult { + let head = read_vec(src.as_ref(), 0, 8).map_err(|e| UnlockError::Corrupt(e.to_string()))?; + if !detect(&head) { + return Err(UnlockError::Corrupt("não é LUKS".into())); + } + match u16::from_be_bytes([head[6], head[7]]) { + 1 => unlock_v1(src, secrets), + 2 => unlock_v2(src, secrets), + v => Err(UnlockError::Unsupported(format!("LUKS versão {v}"))), + } +} + +fn be32(b: &[u8], o: usize) -> u32 { + u32::from_be_bytes(b[o..o + 4].try_into().unwrap()) +} + +fn cstr(b: &[u8]) -> String { + let n = b.iter().position(|&c| c == 0).unwrap_or(b.len()); + String::from_utf8_lossy(&b[..n]).to_string() +} + +fn unlock_v1(src: Arc, secrets: &[Secret]) -> UnlockResult { + let h = read_vec(src.as_ref(), 0, 592).map_err(|e| UnlockError::Corrupt(e.to_string()))?; + if h.len() < 592 { + return Err(UnlockError::Corrupt("cabeçalho LUKS1 truncado".into())); + } + let cipher_name = cstr(&h[8..40]); + let cipher_mode = cstr(&h[40..72]); + let hash = Hash::parse(&cstr(&h[72..104])).ok_or_else(|| UnlockError::Unsupported(format!("hash {}", cstr(&h[72..104]))))?; + let payload = be32(&h, 104) as u64 * 512; + let key_bytes = be32(&h, 108) as usize; + let mk_digest = &h[112..132]; + let mk_salt = &h[132..164]; + let mk_iter = be32(&h, 164); + let spec = format!("{cipher_name}-{cipher_mode}"); + if key_bytes == 0 || key_bytes > 128 { + return Err(UnlockError::Corrupt("tamanho de chave inválido".into())); + } + let check = |mk: &[u8]| hash.pbkdf2(mk, mk_salt, mk_iter, 20) == mk_digest; + let finish = |mk: Vec| -> UnlockResult { + let cipher = parse_cipher(&spec, &mk)?; + let len = src.size().saturating_sub(payload); + let cs = CryptSource::new(src.clone(), cipher, payload, len, 512); + Ok(Unlocked { + source: Arc::new(cs), + description: format!("LUKS1 ({spec})"), + volume_key: Some(mk), + }) + }; + for vk in volume_keys(secrets) { + if vk.len() == key_bytes && check(&vk) { + return finish(vk); + } + } + let pws = passwords(secrets); + for slot in 0..8 { + let o = 208 + slot * 48; + if be32(&h, o) != 0x00AC_71F3 { + continue; + } + let iter = be32(&h, o + 4); + let salt = &h[o + 8..o + 40]; + let km_off = be32(&h, o + 40) as u64 * 512; + let stripes = be32(&h, o + 44) as usize; + if stripes == 0 || stripes > 100_000 { + continue; + } + let km_len = (key_bytes * stripes).div_ceil(512) * 512; + let Ok(mut km) = read_vec(src.as_ref(), km_off, km_len) else { continue }; + if km.len() < km_len { + continue; + } + for pw in &pws { + let dk = hash.pbkdf2(pw, salt, iter, key_bytes); + let cipher = parse_cipher(&spec, &dk)?; + let mut m = km.clone(); + for (i, s) in m.chunks_exact_mut(512).enumerate() { + cipher.decrypt_sector(s, i as u64, i as u64 * 512); + } + let mk = af_merge(&m, key_bytes, stripes, hash); + if check(&mk) { + return finish(mk); + } + } + km.clear(); + } + Err(UnlockError::WrongSecret) +} + +fn b64(s: &str) -> Option> { + let mut out = vec![]; + let mut acc = 0u32; + let mut bits = 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' => 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) +} + +fn jstr<'a>(v: &'a serde_json::Value, k: &str) -> Option<&'a str> { + v.get(k)?.as_str() +} + +/// Números no JSON do LUKS2 vêm como texto (inteiros grandes) ou número. +fn jnum(v: &serde_json::Value, k: &str) -> Option { + let x = v.get(k)?; + x.as_u64().or_else(|| x.as_str()?.parse().ok()) +} + +fn unlock_v2(src: Arc, secrets: &[Secret]) -> UnlockResult { + let h = read_vec(src.as_ref(), 0, 4096).map_err(|e| UnlockError::Corrupt(e.to_string()))?; + if h.len() < 4096 { + return Err(UnlockError::Corrupt("cabeçalho LUKS2 truncado".into())); + } + let hdr_size = u64::from_be_bytes(h[8..16].try_into().unwrap()); + if !(16384..=(4 << 20)).contains(&hdr_size) { + return Err(UnlockError::Corrupt("tamanho de cabeçalho LUKS2 inválido".into())); + } + let area = read_vec(src.as_ref(), 4096, (hdr_size - 4096) as usize).map_err(|e| UnlockError::Corrupt(e.to_string()))?; + let end = area.iter().position(|&c| c == 0).unwrap_or(area.len()); + let json: serde_json::Value = serde_json::from_slice(&area[..end]).map_err(|e| UnlockError::Corrupt(format!("JSON: {e}")))?; + let segments = json.get("segments").and_then(|s| s.as_object()).ok_or_else(|| UnlockError::Corrupt("sem segments".into()))?; + let (_, seg) = segments + .iter() + .find(|(_, s)| jstr(s, "type") == Some("crypt")) + .ok_or_else(|| UnlockError::Unsupported("sem segmento cifrado".into()))?; + let seg_off = jnum(seg, "offset").ok_or_else(|| UnlockError::Corrupt("offset do segmento".into()))?; + let seg_len = match seg.get("size").and_then(|s| s.as_str()) { + Some("dynamic") | None => src.size().saturating_sub(seg_off), + Some(n) => n.parse().unwrap_or(0), + }; + let seg_enc = jstr(seg, "encryption").unwrap_or("aes-xts-plain64").to_string(); + let sector = jnum(seg, "sector_size").unwrap_or(512); + let iv_tweak = jnum(seg, "iv_tweak").unwrap_or(0); + let digests = json.get("digests").and_then(|d| d.as_object()).ok_or_else(|| UnlockError::Corrupt("sem digests".into()))?; + + let check = |vk: &[u8]| -> bool { + for (_, d) in digests { + if jstr(d, "type") != Some("pbkdf2") { + continue; + } + let (Some(hash), Some(salt), Some(want), Some(it)) = ( + jstr(d, "hash").and_then(Hash::parse), + jstr(d, "salt").and_then(b64), + jstr(d, "digest").and_then(b64), + jnum(d, "iterations"), + ) else { + continue; + }; + if hash.pbkdf2(vk, &salt, it as u32, want.len()) == want { + return true; + } + } + false + }; + let finish = |vk: Vec| -> UnlockResult { + let cipher = parse_cipher(&seg_enc, &vk)?; + let cs = CryptSource::new(src.clone(), cipher, seg_off, seg_len, sector).with_iv_tweak(iv_tweak); + Ok(Unlocked { + source: Arc::new(cs), + description: format!("LUKS2 ({seg_enc})"), + volume_key: Some(vk), + }) + }; + for vk in volume_keys(secrets) { + if check(&vk) { + return finish(vk); + } + } + let pws = passwords(secrets); + let slots = json.get("keyslots").and_then(|d| d.as_object()).ok_or_else(|| UnlockError::Corrupt("sem keyslots".into()))?; + let mut unsupported: Option = None; + for (_, slot) in slots { + if jstr(slot, "type") != Some("luks2") { + continue; + } + let key_size = jnum(slot, "key_size").unwrap_or(0) as usize; + let af = slot.get("af"); + let stripes = af.and_then(|a| jnum(a, "stripes")).unwrap_or(4000) as usize; + let af_hash = af.and_then(|a| jstr(a, "hash")).and_then(Hash::parse).unwrap_or(Hash::Sha256); + let ar = slot.get("area"); + let (Some(a_off), Some(a_size)) = (ar.and_then(|a| jnum(a, "offset")), ar.and_then(|a| jnum(a, "size"))) else { continue }; + let a_enc = ar.and_then(|a| jstr(a, "encryption")).unwrap_or("aes-xts-plain64"); + let a_ksize = ar.and_then(|a| jnum(a, "key_size")).unwrap_or(64) as usize; + let kdf = slot.get("kdf"); + let Some(kdf) = kdf else { continue }; + let salt = jstr(kdf, "salt").and_then(b64).unwrap_or_default(); + let km_len = (key_size * stripes).div_ceil(512) * 512; + if key_size == 0 || stripes == 0 || km_len as u64 > a_size { + continue; + } + let Ok(km) = read_vec(src.as_ref(), a_off, km_len) else { continue }; + if km.len() < km_len { + continue; + } + for pw in &pws { + let dk: Vec = match jstr(kdf, "type") { + Some("pbkdf2") => { + let hh = jstr(kdf, "hash").and_then(Hash::parse).unwrap_or(Hash::Sha256); + hh.pbkdf2(pw, &salt, jnum(kdf, "iterations").unwrap_or(1000) as u32, a_ksize) + } + Some(t @ ("argon2i" | "argon2id")) => { + let alg = if t == "argon2i" { argon2::Algorithm::Argon2i } else { argon2::Algorithm::Argon2id }; + let params = match argon2::Params::new( + jnum(kdf, "memory").unwrap_or(65536) as u32, + jnum(kdf, "time").unwrap_or(4) as u32, + jnum(kdf, "cpus").unwrap_or(1) as u32, + Some(a_ksize), + ) { + Ok(p) => p, + Err(e) => { + unsupported = Some(format!("parâmetros Argon2: {e}")); + continue; + } + }; + let mut out = vec![0u8; a_ksize]; + if argon2::Argon2::new(alg, argon2::Version::V0x13, params).hash_password_into(pw, &salt, &mut out).is_err() { + continue; + } + out + } + Some(t) => { + unsupported = Some(format!("KDF {t}")); + continue; + } + None => continue, + }; + let cipher = parse_cipher(a_enc, &dk)?; + let mut m = km.clone(); + for (i, s) in m.chunks_exact_mut(512).enumerate() { + cipher.decrypt_sector(s, i as u64, i as u64 * 512); + } + let vk = af_merge(&m, key_size, stripes, af_hash); + if check(&vk) { + return finish(vk); + } + } + } + match unsupported { + Some(u) => Err(UnlockError::Unsupported(u)), + None => Err(UnlockError::WrongSecret), + } +} + +/// Atalho: abre uma janela da origem como LUKS. +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/src/crypt/mod.rs b/crates/guara-core/src/crypt/mod.rs new file mode 100644 index 0000000..61bd1af --- /dev/null +++ b/crates/guara-core/src/crypt/mod.rs @@ -0,0 +1,346 @@ +//! Cifras de disco (somente leitura) e desbloqueio de volumes cifrados. +//! +//! O volume desbloqueado é exposto como uma `Source` comum: o restante do Guará +//! (varredura, recuperação) enxerga o sistema de arquivos em claro, sem saber +//! que existe cifra por baixo. + +pub mod bitlocker; +pub mod filevault; +pub mod luks; + +use crate::source::Source; +use aes::cipher::generic_array::GenericArray; +use aes::cipher::{BlockDecrypt, BlockEncrypt, KeyInit}; +use aes::{Aes128, Aes192, Aes256}; +use std::io; +use std::sync::Arc; + +/// AES com tamanho de chave escolhido em tempo de execução. +#[derive(Clone)] +pub enum AesAny { + A128(Aes128), + A192(Aes192), + A256(Aes256), +} + +impl AesAny { + pub fn new(key: &[u8]) -> Option { + Some(match key.len() { + 16 => AesAny::A128(Aes128::new_from_slice(key).ok()?), + 24 => AesAny::A192(Aes192::new_from_slice(key).ok()?), + 32 => AesAny::A256(Aes256::new_from_slice(key).ok()?), + _ => return None, + }) + } + pub fn encrypt(&self, b: &mut [u8; 16]) { + let g = GenericArray::from_mut_slice(b); + match self { + AesAny::A128(c) => c.encrypt_block(g), + AesAny::A192(c) => c.encrypt_block(g), + AesAny::A256(c) => c.encrypt_block(g), + } + } + pub fn decrypt(&self, b: &mut [u8; 16]) { + let g = GenericArray::from_mut_slice(b); + match self { + AesAny::A128(c) => c.decrypt_block(g), + AesAny::A192(c) => c.decrypt_block(g), + AesAny::A256(c) => c.decrypt_block(g), + } + } +} + +fn xor16(a: &mut [u8; 16], b: &[u8]) { + for i in 0..16 { + a[i] ^= b[i]; + } +} + +/// Como o vetor de inicialização de cada setor é calculado. +#[derive(Clone)] +pub enum Iv { + /// Número do setor (de 512 bytes) em 64 bits little-endian. + Plain64, + /// Número do setor em 32 bits little-endian. + Plain32, + Plain64Be, + /// ESSIV: AES(SHA-256(chave), setor). + Essiv(AesAny), + /// Número da unidade de dados (setor de `usize` bytes) em 64 bits little-endian. + Unit(u64), + /// BitLocker (Windows 10 1511+): AES(chave, offset em bytes). + Eboiv(AesAny), + /// BitLocker CBC com difusor Elephant: IV = AES(chave, offset em bytes). + Bitlocker(AesAny), +} + +impl Iv { + fn make(&self, sector_512: u64, byte_off: u64) -> [u8; 16] { + let mut iv = [0u8; 16]; + match self { + Iv::Plain64 => iv[..8].copy_from_slice(§or_512.to_le_bytes()), + Iv::Plain32 => iv[..4].copy_from_slice(&(sector_512 as u32).to_le_bytes()), + Iv::Plain64Be => iv[8..].copy_from_slice(§or_512.to_be_bytes()), + Iv::Unit(u) => iv[..8].copy_from_slice(&(byte_off / u).to_le_bytes()), + Iv::Essiv(k) => { + iv[..8].copy_from_slice(§or_512.to_le_bytes()); + k.encrypt(&mut iv); + } + Iv::Eboiv(k) | Iv::Bitlocker(k) => { + iv[..8].copy_from_slice(&byte_off.to_le_bytes()); + k.encrypt(&mut iv); + } + } + iv + } +} + +/// Cifra aplicada setor a setor. +#[derive(Clone)] +pub enum SectorCipher { + Xts { k1: AesAny, k2: AesAny, iv: Iv }, + Cbc { key: AesAny, iv: Iv }, + /// BitLocker AES-CBC com difusor Elephant (chave de dados + chave de ajuste). + Elephant { key: AesAny, tweak: AesAny }, +} + +fn gf_double(t: &mut [u8; 16]) { + let mut carry = 0u8; + for b in t.iter_mut() { + let next = *b >> 7; + *b = (*b << 1) | carry; + carry = next; + } + if carry != 0 { + t[0] ^= 0x87; + } +} + +impl SectorCipher { + /// Decifra um setor no lugar. `sector_512` conta setores de 512 bytes; `byte_off` é a posição + /// em bytes relativa ao início dos dados cifrados. + pub fn decrypt_sector(&self, buf: &mut [u8], sector_512: u64, byte_off: u64) { + match self { + SectorCipher::Xts { k1, k2, iv } => { + let mut t = iv.make(sector_512, byte_off); + k2.encrypt(&mut t); + for ch in buf.chunks_exact_mut(16) { + let mut b = [0u8; 16]; + b.copy_from_slice(ch); + xor16(&mut b, &t); + k1.decrypt(&mut b); + xor16(&mut b, &t); + ch.copy_from_slice(&b); + gf_double(&mut t); + } + } + SectorCipher::Cbc { key, iv } => { + cbc_decrypt(key, &iv.make(sector_512, byte_off), buf); + } + SectorCipher::Elephant { key, tweak } => { + bitlocker::elephant_decrypt(key, tweak, buf, byte_off); + } + } + } +} + +pub fn cbc_decrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) { + let mut prev = *iv; + for ch in buf.chunks_exact_mut(16) { + let mut c = [0u8; 16]; + c.copy_from_slice(ch); + let mut b = c; + key.decrypt(&mut b); + xor16(&mut b, &prev); + ch.copy_from_slice(&b); + prev = c; + } +} + +pub fn cbc_encrypt(key: &AesAny, iv: &[u8; 16], buf: &mut [u8]) { + let mut prev = *iv; + for ch in buf.chunks_exact_mut(16) { + let mut b = [0u8; 16]; + b.copy_from_slice(ch); + xor16(&mut b, &prev); + key.encrypt(&mut b); + ch.copy_from_slice(&b); + prev = b; + } +} + +/// Volume cifrado visto em claro: `len` bytes que começam em `data_offset` na origem. +pub struct CryptSource { + inner: Arc, + cipher: SectorCipher, + data_offset: u64, + len: u64, + sector: u64, + /// Setores antes deste limite estão em claro (BitLocker parcialmente cifrado) ou + /// são cobertos por uma cópia (os primeiros setores do BitLocker). + patches: Vec, + /// A partir deste byte (relativo aos dados) o conteúdo ainda não foi cifrado. + plain_from: u64, + /// Somado ao número do setor no cálculo do IV (iv_tweak do LUKS2). + iv_tweak: u64, +} + +/// Trecho do volume lido de outro lugar da origem (BitLocker move o início do volume). +#[derive(Clone)] +pub struct Patch { + /// Início no volume em claro. + pub at: u64, + pub len: u64, + /// Onde estão os dados, na origem, já em claro. + pub from: u64, + /// Se os bytes de origem precisam ser decifrados (usando a posição `from_crypt`). + pub decrypt_from: Option, + /// Devolve zeros (áreas de metadados do BitLocker). + pub zero: bool, +} + +impl CryptSource { + pub fn new(inner: Arc, cipher: SectorCipher, data_offset: u64, len: u64, sector: u64) -> Self { + let sector = sector.max(512); + Self { + inner, + cipher, + data_offset, + len: len / sector * sector, + sector, + patches: vec![], + plain_from: u64::MAX, + iv_tweak: 0, + } + } + pub fn with_patches(mut self, p: Vec) -> Self { + self.patches = p; + self + } + pub fn with_iv_tweak(mut self, t: u64) -> Self { + self.iv_tweak = t; + self + } + pub fn with_plain_from(mut self, off: u64) -> Self { + self.plain_from = off; + self + } + + fn read_sector_plain(&self, rel: u64, out: &mut [u8]) -> io::Result<()> { + // `rel` alinhado ao setor; `out.len() == sector`. + for p in &self.patches { + if rel >= p.at && rel < p.at + p.len { + if p.zero { + out.fill(0); + return Ok(()); + } + let src_off = p.from + (rel - p.at); + crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + src_off, out)?; + if let Some(pos) = p.decrypt_from { + let r = pos + (rel - p.at); + self.cipher.decrypt_sector(out, r / 512, r); + } + return Ok(()); + } + } + crate::source::read_exact_at(self.inner.as_ref(), self.data_offset + rel, out)?; + if rel < self.plain_from { + self.cipher.decrypt_sector(out, rel / 512 + self.iv_tweak, rel); + } + Ok(()) + } +} + +impl Source for CryptSource { + fn size(&self) -> u64 { + self.len + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result { + if offset >= self.len || buf.is_empty() { + return Ok(0); + } + let want = buf.len().min((self.len - offset) as usize); + let s = self.sector; + let mut done = 0usize; + let mut tmp = vec![0u8; s as usize]; + while done < want { + let pos = offset + done as u64; + let rel = pos / s * s; + let within = (pos - rel) as usize; + let n = (s as usize - within).min(want - done); + self.read_sector_plain(rel, &mut tmp)?; + buf[done..done + n].copy_from_slice(&tmp[within..within + n]); + done += n; + } + Ok(done) + } +} + +/// Algo que o usuário fornece para abrir um volume. +#[derive(Clone)] +pub enum Secret { + Password(String), + /// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos). + RecoveryKey(String), + /// Conteúdo de um arquivo de chave (.BEK do BitLocker, keyfile do LUKS). + KeyFile(Vec), + /// Chave do volume já conhecida (FVEK/master key). + VolumeKey(Vec), +} + +impl std::fmt::Debug for Secret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Secret::Password(_) => "Secret::Password(..)", + Secret::RecoveryKey(_) => "Secret::RecoveryKey(..)", + Secret::KeyFile(_) => "Secret::KeyFile(..)", + Secret::VolumeKey(_) => "Secret::VolumeKey(..)", + }) + } +} + +#[derive(Debug)] +pub enum UnlockError { + /// Nenhuma das senhas/chaves abriu o volume. + WrongSecret, + Unsupported(String), + Corrupt(String), +} + +impl std::fmt::Display for UnlockError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + UnlockError::WrongSecret => write!(f, "senha ou chave incorreta"), + UnlockError::Unsupported(s) => write!(f, "não suportado: {s}"), + UnlockError::Corrupt(s) => write!(f, "cabeçalho inválido: {s}"), + } + } +} + +impl From for crate::Error { + fn from(e: UnlockError) -> Self { + crate::Error::msg(e.to_string()) + } +} + +/// Volume aberto. +pub struct Unlocked { + pub source: Arc, + pub description: String, + /// Chave do volume (para reabrir sem a senha, se o usuário permitir guardá-la). + pub volume_key: Option>, +} + +pub type UnlockResult = std::result::Result; + +pub fn to_hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +pub fn from_hex(s: &str) -> Option> { + let s = s.trim(); + if s.len() % 2 != 0 { + return None; + } + (0..s.len() / 2).map(|i| u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).ok()).collect() +} diff --git a/crates/guara-core/src/fs/ext.rs b/crates/guara-core/src/fs/ext.rs index 1df9f2a..0305701 100644 --- a/crates/guara-core/src/fs/ext.rs +++ b/crates/guara-core/src/fs/ext.rs @@ -1,90 +1,848 @@ -//! ext2/3/4: lê apenas o mapa de blocos livres, para o carving varrer só o -//! que está livre. (Inodes apagados de ext4 têm a árvore de extents zerada, -//! então a recuperação é por assinatura.) +//! ext2/3/4: arquivos apagados com nome, por três caminhos que se completam: +//! +//! 1. inodes com `dtime` cujo mapa de extents ainda tem sobras legíveis; +//! 2. cópias antigas do inode guardadas no diário (jbd2), de antes da exclusão; +//! 3. entradas de diretório "escondidas" na folga do `rec_len` (dão o nome). +//! +//! Também devolve o mapa de blocos livres, para o carving varrer só o que está livre. -use super::{merge_extents, Ctx, VolumeScan}; -use crate::source::read_vec; -use crate::types::Extent; +use super::{make_found, merge_extents, Ctx, NewFound, VolumeScan}; +use crate::signatures; +use crate::source::{read_vec, Source}; +use crate::types::{Confidence, DataLoc, Extent, Origin}; +use std::collections::{HashMap, HashSet}; -pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan { - let mut res = VolumeScan::default(); - let Some(sb) = read_vec(ctx.src, base + 1024, 1024) - .ok() - .filter(|s| s.len() == 1024) - else { - return res; - }; - let le32 = |o: usize| u32::from_le_bytes(sb[o..o + 4].try_into().unwrap()) as u64; - let le16 = |o: usize| u16::from_le_bytes(sb[o..o + 2].try_into().unwrap()) as usize; - if le16(56) != 0xEF53 { - return res; +const EXT4_EXTENTS_FL: u32 = 0x80000; +const EXT4_INLINE_DATA_FL: u32 = 0x1000_0000; +const EXT_MAGIC: u16 = 0xF30A; +const JBD2_MAGIC: u32 = 0xC03B_3998; + +fn le16(b: &[u8], o: usize) -> u16 { + b.get(o..o + 2).map(|s| u16::from_le_bytes([s[0], s[1]])).unwrap_or(0) +} +fn le32(b: &[u8], o: usize) -> u32 { + b.get(o..o + 4).map(|s| u32::from_le_bytes(s.try_into().unwrap())).unwrap_or(0) +} +fn be32(b: &[u8], o: usize) -> u32 { + b.get(o..o + 4).map(|s| u32::from_be_bytes(s.try_into().unwrap())).unwrap_or(0) +} + +#[derive(Clone, Copy, Default)] +struct Group { + block_bitmap: u64, + inode_bitmap: u64, + inode_table: u64, + flags: u16, + itable_unused: u32, +} + +#[derive(Clone)] +pub struct Inode { + pub mode: u16, + pub size: u64, + pub atime: u32, + pub ctime: u32, + pub mtime: u32, + pub dtime: u32, + pub links: u16, + pub flags: u32, + pub block: [u8; 60], + pub crtime: u32, +} + +impl Inode { + fn parse(b: &[u8]) -> Option { + if b.len() < 128 { + return None; + } + let mut block = [0u8; 60]; + block.copy_from_slice(&b[40..100]); + let mode = le16(b, 0); + let mut size = le32(b, 4) as u64; + if mode & 0xF000 == 0x8000 { + size |= (le32(b, 0x6C) as u64) << 32; + } + Some(Inode { + mode, + size, + atime: le32(b, 8), + ctime: le32(b, 12), + mtime: le32(b, 16), + dtime: le32(b, 20), + links: le16(b, 26), + flags: le32(b, 32), + block, + crtime: if b.len() >= 0x98 { le32(b, 0x90) } else { 0 }, + }) } - let bs = 1024u64 << le32(24).min(6); - let incompat = le32(96); - let is64 = incompat & 0x80 != 0; - let mut blocks = le32(4); - if is64 { - blocks |= le32(336) << 32; + fn is_reg(&self) -> bool { + self.mode & 0xF000 == 0x8000 } - let first_data = le32(20); - let bpg = le32(32).max(1); - let desc_size = if is64 { le16(254).max(64) } else { 32 }; - let groups = blocks.saturating_sub(first_data).div_ceil(bpg); - let gdt_block = first_data + 1; - let gdt_len = groups * desc_size as u64; - let Some(gdt) = read_vec(ctx.src, base + gdt_block * bs, gdt_len as usize) - .ok() - .filter(|g| g.len() as u64 == gdt_len) - else { - res.warnings - .push("ext: tabela de grupos ilegível; varrendo a partição inteira.".into()); - return res; - }; - let mut free: Vec = vec![]; - for g in 0..groups { + fn is_dir(&self) -> bool { + self.mode & 0xF000 == 0x4000 + } +} + +struct Ext<'a> { + src: &'a dyn Source, + base: u64, + bs: u64, + blocks: u64, + first_data: u64, + bpg: u64, + ipg: u64, + inode_size: u64, + inodes: u64, + groups: Vec, + journal_inum: u32, + incompat: u32, +} + +/// Sequência de (bloco lógico, bloco físico, quantidade, não-inicializado). +type ExtentList = Vec<(u64, u64, u64, bool)>; + +impl<'a> Ext<'a> { + fn open(src: &'a dyn Source, base: u64) -> Option { + let sb = read_vec(src, base + 1024, 1024).ok().filter(|s| s.len() == 1024)?; + if le16(&sb, 56) != 0xEF53 { + return None; + } + let log = le32(&sb, 24); + if log > 6 { + return None; + } + let bs = 1024u64 << log; + let incompat = le32(&sb, 96); + let is64 = incompat & 0x80 != 0; + let mut blocks = le32(&sb, 4) as u64; + if is64 { + blocks |= (le32(&sb, 336) as u64) << 32; + } + let first_data = le32(&sb, 20) as u64; + let bpg = (le32(&sb, 32) as u64).max(1); + let ipg = (le32(&sb, 40) as u64).max(1); + let rev = le32(&sb, 76); + let inode_size = if rev >= 1 { le16(&sb, 88) as u64 } else { 128 }; + if !(128..=4096).contains(&inode_size) { + return None; + } + let desc_size = if is64 { (le16(&sb, 254) as u64).max(64) } else { 32 }; + let ngroups = blocks.saturating_sub(first_data).div_ceil(bpg); + let gdt_block = first_data + 1; + let gdt = read_vec(src, base + gdt_block * bs, (ngroups * desc_size) as usize).ok()?; + if (gdt.len() as u64) < ngroups * desc_size { + return None; + } + let mut groups = Vec::with_capacity(ngroups as usize); + for g in 0..ngroups as usize { + let d = &gdt[g * desc_size as usize..]; + let mut gr = Group { + block_bitmap: le32(d, 0) as u64, + inode_bitmap: le32(d, 4) as u64, + inode_table: le32(d, 8) as u64, + flags: le16(d, 0x12), + itable_unused: le16(d, 0x1C) as u32, + }; + if is64 && desc_size >= 64 { + gr.block_bitmap |= (le32(d, 0x20) as u64) << 32; + gr.inode_bitmap |= (le32(d, 0x24) as u64) << 32; + gr.inode_table |= (le32(d, 0x28) as u64) << 32; + gr.itable_unused |= (le16(d, 0x32) as u32) << 16; + } + groups.push(gr); + } + Some(Self { + src, + base, + bs, + blocks, + first_data, + bpg, + ipg, + inode_size, + inodes: le32(&sb, 0) as u64, + groups, + journal_inum: le32(&sb, 224), + incompat, + }) + } + + fn read_block(&self, blk: u64) -> Option> { + if blk >= self.blocks { + return None; + } + let v = read_vec(self.src, self.base + blk * self.bs, self.bs as usize).ok()?; + (v.len() as u64 == self.bs).then_some(v) + } + + fn inode_bytes(&self, ino: u64) -> Option> { + if ino == 0 || ino > self.inodes { + return None; + } + let g = ((ino - 1) / self.ipg) as usize; + let idx = (ino - 1) % self.ipg; + let grp = self.groups.get(g)?; + let off = self.base + grp.inode_table * self.bs + idx * self.inode_size; + let v = read_vec(self.src, off, self.inode_size as usize).ok()?; + (v.len() as u64 == self.inode_size).then_some(v) + } + + fn read_inode(&self, ino: u64) -> Option { + Inode::parse(&self.inode_bytes(ino)?) + } + + /// Extents de uma árvore ext4 (raiz em `node`, 60 bytes ou um bloco). + fn walk_extent_node(&self, node: &[u8], depth_limit: u32, out: &mut ExtentList, strict: bool) -> bool { + if node.len() < 12 || le16(node, 0) != EXT_MAGIC { + return false; + } + let entries = le16(node, 2) as usize; + let max = le16(node, 4) as usize; + let depth = le16(node, 6) as u32; + if entries > max || depth > depth_limit || (strict && entries == 0) { + return false; + } + for i in 0..entries { + let e = match node.get(12 + i * 12..24 + i * 12) { + Some(e) => e, + None => return false, + }; + if depth == 0 { + let lblk = le32(e, 0) as u64; + let mut len = le16(e, 4) as u64; + let uninit = len > 32768; + if uninit { + len -= 32768; + } + let start = ((le16(e, 6) as u64) << 32) | le32(e, 8) as u64; + if len == 0 || start == 0 || start + len > self.blocks { + return false; + } + out.push((lblk, start, len, uninit)); + } else { + let child = ((le16(e, 8) as u64) << 32) | le32(e, 4) as u64; + let Some(b) = self.read_block(child) else { return false }; + if !self.walk_extent_node(&b, depth - 1, out, true) { + return false; + } + } + } + true + } + + /// Blocos de um inode ext2/3 (mapa de blocos clássico). + fn block_map(&self, blk: &[u8; 60], max_blocks: u64) -> ExtentList { + let mut v: Vec = vec![]; + let ptr = |i: usize| le32(blk, i * 4) as u64; + for i in 0..12 { + v.push(ptr(i)); + } + let ppb = self.bs / 4; + let ind = |b: u64, v: &mut Vec| { + if b == 0 { + v.extend(std::iter::repeat(0).take(ppb as usize)); + } else if let Some(d) = self.read_block(b) { + v.extend((0..ppb as usize).map(|i| le32(&d, i * 4) as u64)); + } else { + v.extend(std::iter::repeat(0).take(ppb as usize)); + } + }; + if v.len() as u64 > max_blocks { + v.truncate(max_blocks as usize); + } else { + ind(ptr(12), &mut v); + if (v.len() as u64) < max_blocks { + if ptr(13) != 0 { + if let Some(d) = self.read_block(ptr(13)) { + for i in 0..ppb as usize { + ind(le32(&d, i * 4) as u64, &mut v); + if v.len() as u64 >= max_blocks { + break; + } + } + } + } + } + if (v.len() as u64) < max_blocks && ptr(14) != 0 { + if let Some(d1) = self.read_block(ptr(14)) { + 'o: for i in 0..ppb as usize { + if let Some(d2) = self.read_block(le32(&d1, i * 4) as u64) { + for j in 0..ppb as usize { + ind(le32(&d2, j * 4) as u64, &mut v); + if v.len() as u64 >= max_blocks { + break 'o; + } + } + } + } + } + } + } + v.truncate(max_blocks as usize); + let mut out: ExtentList = vec![]; + for (l, &p) in v.iter().enumerate() { + if p == 0 || p >= self.blocks { + continue; + } + match out.last_mut() { + Some(last) if last.1 + last.2 == p && last.0 + last.2 == l as u64 => last.2 += 1, + _ => out.push((l as u64, p, 1, false)), + } + } + out + } + + /// Extents do arquivo (ordenados pelo bloco lógico), cortados em `size` bytes. + fn file_extents(&self, ino: &Inode, size: u64) -> Vec { + let need = size.div_ceil(self.bs); + let mut list: ExtentList = vec![]; + if ino.flags & EXT4_EXTENTS_FL != 0 { + if !self.walk_extent_node(&ino.block, 5, &mut list, false) { + return vec![]; + } + } else if ino.flags & EXT4_INLINE_DATA_FL == 0 { + list = self.block_map(&ino.block, need); + } + list.sort_by_key(|e| e.0); + let mut out = vec![]; + let mut left = size; + let mut cur_l = 0u64; + for (l, p, n, uninit) in list { + if left == 0 { + break; + } + if l > cur_l { + let hole = ((l - cur_l) * self.bs).min(left); + out.push(Extent { offset: 0, len: hole, sparse: true }); + left -= hole; + if left == 0 { + break; + } + } + let take = (n * self.bs).min(left); + out.push(Extent { offset: self.base + p * self.bs, len: take, sparse: uninit }); + left -= take; + cur_l = l + n; + } + out + } + + fn read_file(&self, ino: &Inode, max: u64) -> Vec { + let size = ino.size.min(max); + let mut out = Vec::with_capacity(size as usize); + for e in self.file_extents(ino, size) { + if e.sparse { + out.extend(std::iter::repeat(0).take(e.len as usize)); + } else if let Ok(v) = read_vec(self.src, e.offset, e.len as usize) { + out.extend(v); + } + } + out + } + + /// Mapa de blocos livres (bitmap), em extents absolutos. + fn free_map(&self, ctx: &Ctx) -> (Vec, Vec) { + let mut free: Vec = vec![]; + let mut allocated_all: Vec = vec![]; + let total_groups = self.groups.len() as u64; + for (g, gr) in self.groups.iter().enumerate() { + if (ctx.cancelled)() { + break; + } + let gstart = self.first_data + g as u64 * self.bpg; + let gblocks = self.bpg.min(self.blocks.saturating_sub(gstart)); + let mut used_bits = vec![true; gblocks as usize]; + if gr.flags & 0x2 != 0 { + used_bits.fill(false); + free.push(Extent::new(self.base + gstart * self.bs, gblocks * self.bs)); + } else if let Some(bm) = read_vec(self.src, self.base + gr.block_bitmap * self.bs, gblocks.div_ceil(8) as usize) + .ok() + .filter(|b| b.len() as u64 == gblocks.div_ceil(8)) + { + let mut run: Option = None; + for i in 0..gblocks { + let used = bm[(i / 8) as usize] >> (i % 8) & 1 == 1; + used_bits[i as usize] = used; + match (used, run) { + (false, None) => run = Some(i), + (true, Some(s)) => { + free.push(Extent::new(self.base + (gstart + s) * self.bs, (i - s) * self.bs)); + run = None; + } + _ => {} + } + } + if let Some(s) = run { + free.push(Extent::new(self.base + (gstart + s) * self.bs, (gblocks - s) * self.bs)); + } + } + allocated_all.extend(used_bits); + if g % 64 == 0 { + (ctx.progress)(g as u64, total_groups); + } + } + (merge_extents(free), allocated_all) + } + + fn block_allocated(&self, alloc: &[bool], blk: u64) -> bool { + blk.checked_sub(self.first_data) + .and_then(|i| alloc.get(i as usize).copied()) + .unwrap_or(true) + } +} + +struct DirEnt { + ino: u64, + name: String, + live: bool, +} + +/// Lê as entradas de um bloco de diretório, inclusive as escondidas na folga. +fn parse_dir_block(b: &[u8], max_ino: u64, out: &mut Vec) { + let mut off = 0usize; + while off + 8 <= b.len() { + let ino = le32(b, off) as u64; + let rec = le16(b, off + 4) as usize; + let nlen = b[off + 6] as usize; + if rec < 8 || rec % 4 != 0 || off + rec > b.len() || 8 + nlen > rec { + break; + } + let real = (8 + nlen).div_ceil(4) * 4; + if ino != 0 && nlen > 0 && ino <= max_ino { + let name = String::from_utf8_lossy(&b[off + 8..off + 8 + nlen]).to_string(); + out.push(DirEnt { ino, name, live: true }); + } + // Folga: entradas apagadas ficam coladas depois do fim "real" desta. + let mut s = off + if ino != 0 || nlen > 0 { real } else { 8 }; + let end = off + rec; + if ino == 0 { + s = end; // entrada nula: sem folga confiável + } + while s + 8 <= end { + let dino = le32(b, s) as u64; + let drec = le16(b, s + 4) as usize; + let dn = b[s + 6] as usize; + let ok = dino != 0 + && dino <= max_ino + && dn > 0 + && drec >= 8 + dn + && drec % 4 == 0 + && s + drec <= end + && b[s + 8..s + 8 + dn].iter().all(|&c| c >= 0x20 && c != b'/' && c != 0x7f); + if !ok { + s += 4; + continue; + } + out.push(DirEnt { + ino: dino, + name: String::from_utf8_lossy(&b[s + 8..s + 8 + dn]).to_string(), + live: false, + }); + s += (8 + dn).div_ceil(4) * 4; + } + off += rec; + } +} + +struct Names { + /// inode -> (pasta, nome) de entradas vivas. + live: HashMap, + /// inode -> (pasta, nome) de entradas apagadas (mais recente por varredura). + dead: HashMap, +} + +fn walk_dirs(ext: &Ext, ctx: &Ctx) -> Names { + let mut names = Names { live: HashMap::new(), dead: HashMap::new() }; + let mut queue: Vec<(u64, String)> = vec![(2, String::new())]; + let mut seen: HashSet = HashSet::new(); + let mut visited = 0usize; + while let Some((dino, path)) = queue.pop() { + if !seen.insert(dino) || (ctx.cancelled)() || visited > 2_000_000 { + continue; + } + visited += 1; + let Some(ino) = ext.read_inode(dino).filter(|i| i.is_dir()) else { continue }; + let mut ents = vec![]; + for e in ext.file_extents(&ino, ino.size.min(64 << 20)) { + if e.sparse { + continue; + } + let mut o = 0; + while o < e.len { + if let Ok(b) = read_vec(ext.src, e.offset + o, ext.bs as usize) { + if b.len() as u64 == ext.bs { + parse_dir_block(&b, ext.inodes, &mut ents); + } + } + o += ext.bs; + } + } + for en in ents { + if en.name == "." || en.name == ".." { + continue; + } + if en.live { + names.live.entry(en.ino).or_insert_with(|| (path.clone(), en.name.clone())); + queue.push((en.ino, format!("{}/{}", path, en.name))); + } else { + names.dead.entry(en.ino).or_insert_with(|| (path.clone(), en.name)); + } + } + } + names +} + +// ---------------------------------------------------------------- diário + +/// Cópias de inodes guardadas no diário: inode -> lista (ordem do diário) de registros brutos. +fn journal_inodes(ext: &Ext, ctx: &Ctx) -> HashMap>> { + let mut out: HashMap>> = HashMap::new(); + if ext.journal_inum == 0 { + return out; + } + let Some(jino) = ext.read_inode(ext.journal_inum as u64) else { return out }; + let jext = ext.file_extents(&jino, jino.size.min(1 << 30)); + // Blocos físicos do diário em ordem lógica. + let mut jblocks: Vec = vec![]; + for e in &jext { + if e.sparse { + continue; + } + for k in 0..e.len / ext.bs { + jblocks.push((e.offset - ext.base) / ext.bs + k); + } + } + if jblocks.len() < 4 { + return out; + } + let Some(jsb) = ext.read_block(jblocks[0]) else { return out }; + if be32(&jsb, 0) != JBD2_MAGIC { + return out; + } + let jbs = be32(&jsb, 12) as u64; + if jbs != ext.bs { + return out; + } + let incompat = be32(&jsb, 40); + let is64 = incompat & 0x2 != 0; + let csum_v3 = incompat & 0x10 != 0; + let csum_v2 = incompat & 0x8 != 0; + let tag_bytes = if csum_v3 { 16 } else { 8 + if is64 { 4 } else { 0 } }; + let have_csum = csum_v2 || csum_v3; + // Tabela de inodes: faixa de blocos de cada grupo. + let itable_blocks = (ext.ipg * ext.inode_size).div_ceil(ext.bs); + let mut tables: Vec<(u64, u64, u64)> = vec![]; // (início, fim, primeiro inode) + for (g, gr) in ext.groups.iter().enumerate() { + tables.push((gr.inode_table, gr.inode_table + itable_blocks, g as u64 * ext.ipg + 1)); + } + let n = jblocks.len(); + let first = (be32(&jsb, 20) as usize).clamp(1, n); + let mut seqs: Vec<(u32, usize)> = vec![]; // (sequência, índice do descritor) + let mut idx = first; + // Varredura física: qualquer bloco que seja descritor, em ordem do diário. + while idx < n { if (ctx.cancelled)() { break; } - let d = &gdt[(g * desc_size as u64) as usize..]; - let mut bb = u32::from_le_bytes(d[0..4].try_into().unwrap()) as u64; - if is64 && desc_size >= 64 { - bb |= (u32::from_le_bytes(d[0x20..0x24].try_into().unwrap()) as u64) << 32; - } - let flags = u16::from_le_bytes(d[0x12..0x14].try_into().unwrap()); - let gstart = first_data + g * bpg; - let gblocks = bpg.min(blocks.saturating_sub(gstart)); - if flags & 0x2 != 0 { - // BLOCK_UNINIT: grupo sem bitmap válido; trata como livre. - free.push(Extent::new(base + gstart * bs, gblocks * bs)); - continue; - } - let Some(bm) = read_vec(ctx.src, base + bb * bs, (gblocks.div_ceil(8)) as usize).ok() - else { + let Some(d) = ext.read_block(jblocks[idx]) else { + idx += 1; continue; }; - let mut run: Option = None; - for i in 0..gblocks { - let used = bm - .get((i / 8) as usize) - .map(|b| b >> (i % 8) & 1 == 1) - .unwrap_or(true); - match (used, run) { - (false, None) => run = Some(i), - (true, Some(s)) => { - free.push(Extent::new(base + (gstart + s) * bs, (i - s) * bs)); - run = None; + if be32(&d, 0) == JBD2_MAGIC && be32(&d, 4) == 1 { + seqs.push((be32(&d, 8), idx)); + } + idx += 1; + } + seqs.sort_by_key(|s| s.0); + for (_seq, didx) in seqs { + let Some(d) = ext.read_block(jblocks[didx]) else { continue }; + let limit = d.len() - if have_csum { 4 } else { 0 }; + let mut off = 12usize; + let mut data_idx = didx + 1; + loop { + if off + tag_bytes > limit { + break; + } + let mut blk = be32(&d, off) as u64; + let (flags, hi) = if csum_v3 { + (be32(&d, off + 4), be32(&d, off + 8)) + } else { + (u16::from_be_bytes([d[off + 6], d[off + 7]]) as u32, if is64 { be32(&d, off + 8) } else { 0 }) + }; + blk |= (hi as u64) << 32; + off += tag_bytes; + if flags & 2 == 0 { + off += 16; + } + let di = if data_idx >= n { first + (data_idx - n) } else { data_idx }; + data_idx += 1; + if flags & 4 == 0 { + if let Some(t) = tables.iter().position(|t| blk >= t.0 && blk < t.1) { + if let Some(mut data) = ext.read_block(jblocks[di.min(n - 1)]) { + if flags & 1 != 0 { + data[0..4].copy_from_slice(&JBD2_MAGIC.to_be_bytes()); + } + let per_block = (ext.bs / ext.inode_size) as usize; + let first_ino = tables[t].2 + (blk - tables[t].0) * per_block as u64; + for k in 0..per_block { + let rec = &data[k * ext.inode_size as usize..(k + 1) * ext.inode_size as usize]; + if rec[..128].iter().any(|&x| x != 0) { + out.entry(first_ino + k as u64).or_default().push(rec.to_vec()); + } + } + } } - _ => {} + } + if flags & 8 != 0 { + break; } } - if let Some(s) = run { - free.push(Extent::new(base + (gstart + s) * bs, (gblocks - s) * bs)); + } + out +} + +// -------------------------------------------------------------- varredura + +pub fn scan(ctx: &mut Ctx, base: u64, part_len: u64) -> VolumeScan { + let mut res = VolumeScan::default(); + let Some(ext) = Ext::open(ctx.src, base) else { + return res; + }; + let (free, alloc) = ext.free_map(ctx); + let _ = part_len; + res.free = Some(free); + + let names = walk_dirs(&ext, ctx); + let journal = journal_inodes(&ext, ctx); + let mut emitted: HashSet = HashSet::new(); + let mut seen_extents: HashSet<(u64, u64)> = HashSet::new(); + + // 1) Inodes apagados na tabela atual. + let total = ext.groups.len() as u64; + for (g, gr) in ext.groups.iter().enumerate() { + if (ctx.cancelled)() { + return res; } - if g % 64 == 0 { - (ctx.progress)(g, groups); + if gr.flags & 0x1 != 0 { + continue; // INODE_UNINIT + } + let used = ext.ipg.saturating_sub(gr.itable_unused as u64).min(ext.ipg); + let Some(table) = read_vec(ext.src, ext.base + gr.inode_table * ext.bs, (used * ext.inode_size) as usize).ok() else { + continue; + }; + for i in 0..(table.len() as u64 / ext.inode_size) { + let ino_no = g as u64 * ext.ipg + i + 1; + let raw = &table[(i * ext.inode_size) as usize..((i + 1) * ext.inode_size) as usize]; + let Some(ino) = Inode::parse(raw) else { continue }; + if names.live.contains_key(&ino_no) || ino_no < 11 || ino.mode == 0 { + continue; + } + let deleted = ino.dtime != 0 || ino.links == 0; + if !deleted || !ino.is_reg() { + continue; + } + // Melhor fonte: a cópia do diário anterior à exclusão; senão o que sobrou no inode. + let mut cand: Option<(Inode, &str)> = None; + if let Some(snaps) = journal.get(&ino_no) { + for raw in snaps.iter().rev() { + if let Some(j) = Inode::parse(raw) { + if j.is_reg() && j.size > 0 && j.dtime == 0 && j.links > 0 { + let ex = ext.file_extents(&j, j.size); + if ex.iter().any(|e| !e.sparse) { + cand = Some((j, "diário")); + break; + } + } + } + } + } + if cand.is_none() { + cand = Some((ino.clone(), "inode")); + } + if let Some((c, src_kind)) = cand { + if emit_inode(ctx, &ext, &alloc, &names, ino_no, &c, src_kind, &mut seen_extents) { + emitted.insert(ino_no); + } + } + } + if g % 16 == 0 { + (ctx.progress)(g as u64, total); + } + } + + // 2) Inodes que só existem no diário (liberados e já reaproveitados/zerados). + for (ino_no, snaps) in &journal { + if emitted.contains(ino_no) || names.live.contains_key(ino_no) || *ino_no < 11 { + continue; + } + if (ctx.cancelled)() { + return res; + } + let cur = ext.read_inode(*ino_no); + // Se o inode atual está vivo e é outro arquivo, não mexe. + if cur.as_ref().map(|c| c.dtime == 0 && c.links > 0 && c.mode != 0).unwrap_or(false) { + continue; + } + for raw in snaps.iter().rev() { + if let Some(j) = Inode::parse(raw) { + if j.is_reg() && j.size > 0 && j.dtime == 0 && j.links > 0 { + if emit_inode(ctx, &ext, &alloc, &names, *ino_no, &j, "diário", &mut seen_extents) { + break; + } + } + } } } - let _ = part_len; - res.free = Some(merge_extents(free)); res } + +#[allow(clippy::too_many_arguments)] +fn emit_inode( + ctx: &mut Ctx, + ext: &Ext, + alloc: &[bool], + names: &Names, + ino_no: u64, + ino: &Inode, + src_kind: &str, + seen: &mut HashSet<(u64, u64)>, +) -> bool { + let mut extents: Vec; + let mut size = ino.size; + let mut estimated = false; + if ino.flags & EXT4_EXTENTS_FL != 0 { + let mut list: ExtentList = vec![]; + if !ext.walk_extent_node(&ino.block, 5, &mut list, true) { + // Sobras de extent em inode truncado: cabeçalho zerado, registros ainda lá. + list = remnant_extents(ext, &ino.block); + } + if list.is_empty() { + return false; + } + list.sort_by_key(|e| e.0); + if size == 0 { + size = list.iter().map(|e| e.2).sum::() * ext.bs; + estimated = true; + } + extents = layout_from_list(ext, &list, size); + } else { + if size == 0 { + return false; + } + extents = ext.file_extents(ino, size); + } + if extents.is_empty() || extents.iter().all(|e| e.sparse) { + return false; + } + let first = extents.iter().find(|e| !e.sparse).map(|e| (e.offset, e.len)).unwrap(); + if !seen.insert((first.0, first.1)) { + return false; + } + // Quantos blocos de dados continuam livres? Reaproveitados => baixa confiança. + let mut reused = 0u64; + let mut total_blocks = 0u64; + for e in extents.iter().filter(|e| !e.sparse) { + let b0 = (e.offset - ext.base) / ext.bs; + for k in 0..e.len.div_ceil(ext.bs) { + total_blocks += 1; + if ext.block_allocated(alloc, b0 + k) { + reused += 1; + } + } + } + let mut note = Some(format!("recuperado do {src_kind} do ext4")); + let mut conf = Confidence::High; + if reused > 0 { + conf = if reused * 2 > total_blocks { Confidence::Low } else { Confidence::Medium }; + note = Some(format!("{reused} de {total_blocks} blocos já foram reaproveitados por outros arquivos")); + } + if estimated { + // Tamanho desconhecido: tenta achar o fim real pelo conteúdo. + if let Some(h) = extents + .iter() + .find(|e| !e.sparse) + .and_then(|e| read_vec(ext.src, e.offset, 128).ok()) + .and_then(|head| signatures::detect(ext.src, first.0, &head, size)) + { + if h.len <= size && h.len > 0 { + size = h.len; + extents = super::fat::trim_extents(extents, size); + estimated = false; + } + } + if estimated { + conf = conf.min(Confidence::Medium); + note = Some("tamanho exato perdido na exclusão; usando todos os blocos do arquivo".into()); + } + } + let (dir, name) = match names.dead.get(&ino_no).or_else(|| names.live.get(&ino_no)) { + Some((d, n)) => (Some(d.clone()), n.clone()), + None => (None, format!("inode_{ino_no}")), + }; + let ts = |t: u32| (t != 0).then_some(t as i64); + let nf = NewFound { + name, + dir, + size, + origin: Origin::Ext, + data: DataLoc::Extents { extents }, + modified: ts(ino.mtime), + created: ts(ino.crtime), + accessed: ts(ino.atime), + base: conf, + note: note.as_deref(), + }; + let f = make_found(ctx, nf); + (ctx.emit)(f); + true +} + +fn layout_from_list(ext: &Ext, list: &ExtentList, size: u64) -> Vec { + let mut out = vec![]; + let mut left = size; + let mut cur = 0u64; + for &(l, p, n, uninit) in list { + if left == 0 { + break; + } + if l > cur { + let hole = ((l - cur) * ext.bs).min(left); + out.push(Extent { offset: 0, len: hole, sparse: true }); + left -= hole; + if left == 0 { + break; + } + } + let take = (n * ext.bs).min(left); + out.push(Extent { offset: ext.base + p * ext.bs, len: take, sparse: uninit }); + left -= take; + cur = l + n; + } + out +} + +/// Lê registros de extent deixados em i_block depois que o cabeçalho foi zerado. +fn remnant_extents(ext: &Ext, block: &[u8; 60]) -> ExtentList { + let mut out: ExtentList = vec![]; + if le16(block, 0) != EXT_MAGIC || le16(block, 6) != 0 { + return out; + } + let max = (le16(block, 4) as usize).min(4); + let mut next_l = 0u64; + for i in 0..max { + let e = &block[12 + i * 12..24 + i * 12]; + let lblk = le32(e, 0) as u64; + let mut len = le16(e, 4) as u64; + let uninit = len > 32768; + if uninit { + len -= 32768; + } + let start = ((le16(e, 6) as u64) << 32) | le32(e, 8) as u64; + if len == 0 || start < ext.first_data || start + len > ext.blocks || lblk < next_l { + break; + } + next_l = lblk + len; + out.push((lblk, start, len, uninit)); + } + out +} diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index 6edfc5a..fec5fcc 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod carve; pub mod device; +pub mod crypt; pub mod error; pub mod ewf; pub mod explorer; diff --git a/crates/guara-core/src/partition.rs b/crates/guara-core/src/partition.rs index 7c73465..f14173c 100644 --- a/crates/guara-core/src/partition.rs +++ b/crates/guara-core/src/partition.rs @@ -35,7 +35,7 @@ pub fn detect_fs_in(b: &[u8]) -> Option<(FsKind, Option)> { return Some((FsKind::Exfat, Some(vol << shift))); } } - if &b[3..11] == b"-FVE-FS-" { + if crate::crypt::bitlocker::detect(b) { return Some((FsKind::Bitlocker, None)); } if b.starts_with(b"LUKS\xBA\xBE") { diff --git a/crates/guara-core/src/report.rs b/crates/guara-core/src/report.rs index a0f3567..a613b8e 100644 --- a/crates/guara-core/src/report.rs +++ b/crates/guara-core/src/report.rs @@ -292,12 +292,7 @@ footer{margin-top:34px;color:#5b6f6d;font-size:12px}@media print{body{background human(f.size), iso(f.modified), iso(f.created), - match f.origin { - Origin::Carved => "assinatura", - Origin::Ntfs => "NTFS", - Origin::Fat => "FAT", - Origin::Exfat => "exFAT", - }, + f.origin.label(), f.first_offset().map(|o| o.to_string()).unwrap_or_default() )); } diff --git a/crates/guara-core/src/types.rs b/crates/guara-core/src/types.rs index 4a91e57..e2f75b6 100644 --- a/crates/guara-core/src/types.rs +++ b/crates/guara-core/src/types.rs @@ -114,9 +114,32 @@ pub enum Origin { Ntfs, Fat, Exfat, + Ext, + Btrfs, + Xfs, + Hfs, + Apfs, + Refs, Carved, } +impl Origin { + pub fn label(self) -> &'static str { + match self { + Origin::Carved => "assinatura", + Origin::Ntfs => "NTFS", + Origin::Fat => "FAT", + Origin::Exfat => "exFAT", + Origin::Ext => "ext4", + Origin::Btrfs => "Btrfs", + Origin::Xfs => "XFS", + Origin::Hfs => "HFS+", + Origin::Apfs => "APFS", + Origin::Refs => "ReFS", + } + } +} + /// Um arquivo encontrado (apagado, perdido ou reconstruído por assinatura). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Found { diff --git a/crates/guara-core/tests/bitlocker.rs b/crates/guara-core/tests/bitlocker.rs new file mode 100644 index 0000000..05ddb8f --- /dev/null +++ b/crates/guara-core/tests/bitlocker.rs @@ -0,0 +1,95 @@ +//! BitLocker: imagens reais do conjunto de testes do cryptsetup (GitLab), com senhas conhecidas. +//! Baixe com `scripts/fetch-testdata.sh`; sem elas os testes são pulados. + +use guara_core::crypt::{bitlocker, Secret, UnlockError}; +use guara_core::partition::detect_fs_in; +use guara_core::source::{read_vec, FileSource, Source}; +use std::collections::BTreeMap; +use std::path::PathBuf; +use std::sync::Arc; + +fn dir() -> Option { + let d = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/testdata/bitlk-images"); + d.join("images.conf").exists().then_some(d) +} + +fn conf(d: &PathBuf) -> BTreeMap> { + let mut m: BTreeMap> = BTreeMap::new(); + let mut cur = String::new(); + for l in std::fs::read_to_string(d.join("images.conf")).unwrap().lines() { + let l = l.trim(); + if let Some(n) = l.strip_prefix('[').and_then(|x| x.strip_suffix(']')) { + cur = n.to_string(); + } else if let Some((k, v)) = l.split_once('=') { + m.entry(cur.clone()).or_default().insert(k.into(), v.into()); + } + } + m +} + +fn check_fs(src: &dyn Source, what: &str) { + // Imagens "encrypt-on-write" não têm sistema de arquivos reconhecível (UUID vazio no images.conf). + if what.contains("-eow ") { + return; + } + let head = read_vec(src, 0, 4096).unwrap(); + let fs = detect_fs_in(&head); + assert!(fs.is_some(), "{what}: setor 0 aberto não é um sistema de arquivos: {:02x?}", &head[..32]); +} + +#[test] +fn opens_all_reference_images_with_password_and_recovery_key() { + let Some(d) = dir() else { + eprintln!("SKIP: execute scripts/fetch-testdata.sh"); + return; + }; + let mut opened = 0; + for (name, c) in conf(&d) { + let path = d.join(format!("{name}.img")); + if !path.exists() { + continue; + } + let src: Arc = Arc::new(FileSource::open(&path).unwrap()); + let pw = c.get("PW").cloned().unwrap_or_default(); + let rp = c.get("RP").cloned().unwrap_or_default(); + // Proteção suspensa (chave limpa): abre sem segredo nenhum. + if let Ok(u) = bitlocker::unlock(src.clone(), &[]) { + check_fs(u.source.as_ref(), &format!("{name} (chave limpa)")); + eprintln!("ok {name}: chave limpa"); + opened += 1; + continue; + } + match bitlocker::unlock(src.clone(), &[Secret::Password("errada".into())]) { + Err(UnlockError::WrongSecret) | Err(UnlockError::Unsupported(_)) => {} + Err(e) => panic!("{name}: {e}"), + Ok(_) => panic!("{name}: abriu com senha errada"), + } + let mut views = vec![]; + if !pw.is_empty() { + let u = bitlocker::unlock(src.clone(), &[Secret::Password(pw.clone())]).unwrap_or_else(|e| panic!("{name} senha: {e}")); + check_fs(u.source.as_ref(), &format!("{name} (senha)")); + views.push(u); + } + if !rp.is_empty() { + let u = bitlocker::unlock(src.clone(), &[Secret::RecoveryKey(rp.clone())]).unwrap_or_else(|e| panic!("{name} recuperação: {e}")); + check_fs(u.source.as_ref(), &format!("{name} (recuperação)")); + views.push(u); + } + if let Some(g) = c.get("SK_VMK_GUID") { + let f = d.join(format!("{}.BEK", g.to_uppercase())); + if f.exists() { + let u = bitlocker::unlock(src.clone(), &[Secret::KeyFile(std::fs::read(f).unwrap())]).unwrap_or_else(|e| panic!("{name} BEK: {e}")); + check_fs(u.source.as_ref(), &format!("{name} (BEK)")); + views.push(u); + } + } + assert!(!views.is_empty(), "{name}: nenhum protetor testável"); + let first = read_vec(views[0].source.as_ref(), 0, 1 << 20).unwrap(); + for v in &views[1..] { + assert!(read_vec(v.source.as_ref(), 0, 1 << 20).unwrap() == first, "{name}: protetores divergem"); + } + opened += 1; + eprintln!("ok {name}: {} ({} protetores)", views[0].description, views.len()); + } + assert!(opened > 0); +} diff --git a/crates/guara-core/tests/ext4.rs b/crates/guara-core/tests/ext4.rs new file mode 100644 index 0000000..360030b --- /dev/null +++ b/crates/guara-core/tests/ext4.rs @@ -0,0 +1,189 @@ +//! ext4: arquivos apagados com nome, a partir de imagens criadas com mke2fs/debugfs. + +mod common; +use common::*; +use guara_core::*; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn have(tool: &str) -> bool { + Command::new("sh") + .args(["-c", &format!("command -v {tool}")]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn debugfs(img: &Path, cmds: &[&str]) -> String { + let mut out = String::new(); + for c in cmds { + let o = Command::new("debugfs").arg("-w").arg("-R").arg(c).arg(img).output().unwrap(); + out.push_str(&String::from_utf8_lossy(&o.stdout)); + out.push_str(&String::from_utf8_lossy(&o.stderr)); + } + out +} + +fn inode_of(img: &Path, path: &str) -> u64 { + let o = debugfs(img, &[&format!("stat {path}")]); + let l = o.lines().find(|l| l.starts_with("Inode:")).unwrap_or_else(|| panic!("{o}")); + l.split_whitespace().nth(1).unwrap().parse().unwrap() +} + +fn content(seed: u32, len: usize) -> Vec { + let mut x = 88172645463325252u64 ^ seed as u64; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 24) as u8 + }) + .collect() +} + +struct Img { + _dir: tempfile::TempDir, + img: PathBuf, + files: Vec<(&'static str, Vec)>, +} + +fn build(opts: &[&str]) -> Option { + if !have("mke2fs") || !have("debugfs") { + eprintln!("SKIP: e2fsprogs ausente"); + return None; + } + let dir = tempfile::tempdir().unwrap(); + let root = dir.path().join("root"); + std::fs::create_dir_all(root.join("docs/sub")).unwrap(); + std::fs::create_dir_all(root.join("fotos")).unwrap(); + let files = vec![ + ("docs/relatorio.dat", content(1, 150_000)), + ("docs/sub/contrato.dat", content(2, 9_000)), + ("fotos/viagem.dat", content(3, 700_000)), + ("fotos/fica.dat", content(4, 30_000)), + ]; + for (p, c) in &files { + std::fs::write(root.join(p), c).unwrap(); + } + let img = dir.path().join("e.img"); + let f = std::fs::File::create(&img).unwrap(); + f.set_len(24 << 20).unwrap(); + let st = Command::new("mke2fs") + .args(["-q", "-F", "-d"]) + .arg(&root) + .args(opts) + .arg(&img) + .output() + .unwrap(); + assert!(st.status.success(), "{}", String::from_utf8_lossy(&st.stderr)); + Some(Img { _dir: dir, img, files }) +} + +fn deep(img: &Path) -> common::Collect { + init(); + let opts = ScanOptions { mode: ScanMode::Deep, ..ScanOptions::default() }; + scan_path(img, &opts) +} + +fn recovered(img: &Path, f: &Found) -> Vec { + let d = tempfile::tempdir().unwrap(); + let rep = recover_all(img, std::slice::from_ref(f), d.path()); + assert!(rep.failed.is_empty(), "{:?}", rep.failed); + let p = &rep.recovered[0].path; + let p = if Path::new(p).is_absolute() { PathBuf::from(p) } else { d.path().join(p) }; + std::fs::read(p).unwrap() +} + +#[test] +fn deleted_by_debugfs_keeps_extents_and_name() { + let Some(i) = build(&["-t", "ext4"]) else { return }; + debugfs(&i.img, &["rm /docs/relatorio.dat", "rm /fotos/viagem.dat"]); + let c = deep(&i.img); + for (p, data) in i.files.iter().filter(|f| f.0 == "docs/relatorio.dat" || f.0 == "fotos/viagem.dat") { + let name = p.rsplit('/').next().unwrap(); + let f = c.found.iter().find(|f| f.name == name && f.origin == Origin::Ext).unwrap_or_else(|| panic!("{name} não encontrado: {:?}", c.found.iter().map(|f| (&f.name, f.origin)).collect::>())); + assert_eq!(f.size, data.len() as u64); + assert_eq!(f.dir.as_deref(), Some(&format!("/{}", p.rsplit_once('/').unwrap().0)[..])); + assert!(recovered(&i.img, f) == *data, "{name}"); + } + // O arquivo que não foi apagado não aparece. + assert!(!c.found.iter().any(|f| f.name == "fica.dat" && f.origin == Origin::Ext)); +} + +#[test] +fn truncated_inode_recovered_by_remnant_extents() { + // Simula o que o kernel faz ao apagar: tamanho zerado e contagem de extents zerada. + let Some(i) = build(&["-t", "ext4"]) else { return }; + let ino = inode_of(&i.img, "/docs/sub/contrato.dat"); + debugfs( + &i.img, + &[ + "rm /docs/sub/contrato.dat", + &format!("sif <{ino}> size 0"), + &format!("sif <{ino}> block[0] 0xF30A"), + ], + ); + let c = deep(&i.img); + let f = c.found.iter().find(|f| f.name == "contrato.dat").expect("contrato.dat"); + assert_eq!(f.origin, Origin::Ext); + // Tamanho exato é perdido: recupera todos os blocos (9000 bytes arredondados para 3 blocos de 4K). + assert_eq!(f.size, 12288); + let got = recovered(&i.img, f); + assert_eq!(&got[..9000], &i.files[1].1[..]); +} + +#[test] +fn journal_copy_restores_exact_size() { + if !have("debugfs") { + return; + } + let Some(i) = build(&["-t", "ext4", "-O", "has_journal"]) else { return }; + // Bloco da tabela de inodes que contém o inode do arquivo. + let ino = inode_of(&i.img, "/fotos/viagem.dat"); + let o = debugfs(&i.img, &["stats"]); + let _ = o; + let blk_line = debugfs(&i.img, &[&format!("imap <{ino}>")]); + // "Inode 12 is part of block group 0\n\tlocated at block 1058, offset 0x0300" + let blk: u64 = blk_line + .split("located at block ") + .nth(1) + .and_then(|s| s.split(',').next()) + .and_then(|s| s.trim().parse().ok()) + .unwrap_or_else(|| panic!("imap: {blk_line}")); + let bs = 4096u64; + let raw = std::fs::read(&i.img).unwrap(); + let old = &raw[(blk * bs) as usize..((blk + 1) * bs) as usize]; + let bin = i.img.with_extension("blk"); + std::fs::write(&bin, old).unwrap(); + let script = i.img.with_extension("cmds"); + std::fs::write(&script, format!("jo\njw -b {blk} {}\njc\njc\n", bin.display())).unwrap(); + let o = Command::new("debugfs").arg("-w").arg("-f").arg(&script).arg(&i.img).output().unwrap(); + let j = format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)); + if j.contains("Unknown request") || j.contains("not supported") { + eprintln!("SKIP: debugfs sem suporte a escrita no diário: {j}"); + return; + } + // Exclui e zera como o kernel faria. + debugfs( + &i.img, + &[ + "rm /fotos/viagem.dat", + &format!("sif <{ino}> size 0"), + &format!("sif <{ino}> block[0] 0xF30A"), + ], + ); + let c = deep(&i.img); + let f = c.found.iter().find(|f| f.name == "viagem.dat").unwrap_or_else(|| panic!("sem viagem.dat: {:?}", c.found.iter().map(|f| &f.name).collect::>())); + assert_eq!(f.size, 700_000, "{:?}", f.note); + assert!(recovered(&i.img, f) == i.files[2].1); +} + +#[test] +fn ext2_blockmap_deleted_is_not_invented() { + // ext2 zera os ponteiros ao apagar; sem diário não há o que recuperar por metadados, + // mas a varredura não pode falhar nem listar o arquivo que continua existindo. + let Some(i) = build(&["-t", "ext2"]) else { return }; + let c = deep(&i.img); + assert!(!c.found.iter().any(|f| f.origin == Origin::Ext)); +} diff --git a/crates/guara-core/tests/luks.rs b/crates/guara-core/tests/luks.rs new file mode 100644 index 0000000..2da6db4 --- /dev/null +++ b/crates/guara-core/tests/luks.rs @@ -0,0 +1,159 @@ +//! LUKS1/LUKS2: imagens criadas pelo cryptsetup; o conteúdo é cifrado por uma +//! implementação independente (python cryptography) com a chave real do volume. + +use guara_core::crypt::{luks, Secret, UnlockError}; +use guara_core::source::{read_vec, FileSource, Source}; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::sync::Arc; + +fn have(tool: &str) -> bool { + Command::new("sh") + .args(["-c", &format!("command -v {tool}")]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn py_ok() -> bool { + Command::new("python3") + .args(["-c", "import cryptography"]) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +fn run_stdin(cmd: &mut Command, input: &str) -> bool { + use std::io::Write; + let mut ch = cmd.stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn().unwrap(); + ch.stdin.take().unwrap().write_all(input.as_bytes()).unwrap(); + ch.wait().unwrap().success() +} + +fn plain_data(len: usize) -> Vec { + let mut x = 0x9E3779B97F4A7C15u64; + (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + (x >> 32) as u8 + }) + .collect() +} + +struct Case<'a> { + name: &'a str, + format_args: Vec<&'a str>, + cipher: &'a str, + sector: u32, + ver2: bool, +} + +fn payload_offset(img: &Path, ver2: bool) -> u64 { + let o = Command::new("cryptsetup").arg("luksDump").arg(img).output().unwrap(); + let t = String::from_utf8_lossy(&o.stdout).to_string(); + if ver2 { + let l = t.lines().skip_while(|l| !l.starts_with("Data segments")).find(|l| l.contains("offset:")).unwrap(); + l.split(':').nth(1).unwrap().split_whitespace().next().unwrap().parse().unwrap() + } else { + let l = t.lines().find(|l| l.starts_with("Payload offset")).unwrap(); + l.split(':').nth(1).unwrap().trim().parse::().unwrap() * 512 + } +} + +fn run_case(c: &Case) { + if !have("cryptsetup") || !py_ok() { + eprintln!("SKIP: cryptsetup/python3-cryptography ausentes"); + return; + } + let dir = tempfile::tempdir().unwrap(); + let img = dir.path().join("l.img"); + let plain = dir.path().join("plain.bin"); + let data = plain_data(3 * 1024 * 1024 + 4096); + std::fs::write(&plain, &data).unwrap(); + std::fs::File::create(&img).unwrap().set_len(24 << 20).unwrap(); + let pw = "senha com acentuação ç"; + let mut fmt = Command::new("cryptsetup"); + fmt.args(["luksFormat", "--batch-mode", "--key-file", "-"]); + fmt.args(&c.format_args); + fmt.arg(&img); + assert!(run_stdin(&mut fmt, pw), "luksFormat falhou ({})", c.name); + let vkf = dir.path().join("vk.bin"); + let mut dump = Command::new("cryptsetup"); + dump.args(["luksDump", "--dump-volume-key", "--batch-mode", "--key-file", "-", "--volume-key-file"]); + dump.arg(&vkf).arg(&img); + assert!(run_stdin(&mut dump, pw), "dump da chave falhou ({})", c.name); + let off = payload_offset(&img, c.ver2); + let st = Command::new("python3") + .arg(Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/support/luks_encrypt.py")) + .arg(&img) + .arg(&plain) + .arg(&vkf) + .arg(off.to_string()) + .arg(c.cipher) + .arg(c.sector.to_string()) + .status() + .unwrap(); + assert!(st.success()); + + let src: Arc = Arc::new(FileSource::open(&img).unwrap()); + // Senha errada primeiro. + match luks::unlock(src.clone(), &[Secret::Password("errada".into())]) { + Err(UnlockError::WrongSecret) => {} + Err(e) => panic!("{}: erro inesperado {e}", c.name), + Ok(_) => panic!("{}: abriu com senha errada", c.name), + } + let u = luks::unlock(src.clone(), &[Secret::Password("x".into()), Secret::Password(pw.into())]) + .unwrap_or_else(|e| panic!("{}: {e}", c.name)); + let got = read_vec(u.source.as_ref(), 0, data.len()).unwrap(); + assert!(got == data, "{}: conteúdo difere", c.name); + // Também abre com a chave do volume. + let vk = std::fs::read(&vkf).unwrap(); + let u2 = luks::unlock(src, &[Secret::VolumeKey(vk)]).unwrap(); + assert_eq!(read_vec(u2.source.as_ref(), 1000, 5000).unwrap(), &data[1000..6000]); +} + +#[test] +fn luks1_xts() { + run_case(&Case { + name: "luks1-xts", + format_args: vec!["--type", "luks1", "--pbkdf-force-iterations", "1000", "-c", "aes-xts-plain64", "-s", "512"], + cipher: "aes-xts-plain64", + sector: 512, + ver2: false, + }); +} + +#[test] +fn luks1_cbc_essiv_sha1() { + run_case(&Case { + name: "luks1-cbc-essiv", + format_args: vec!["--type", "luks1", "--pbkdf-force-iterations", "1000", "-c", "aes-cbc-essiv:sha256", "-s", "256", "-h", "sha1"], + cipher: "aes-cbc-essiv:sha256", + sector: 512, + ver2: false, + }); +} + +#[test] +fn luks2_argon2id_xts() { + run_case(&Case { + name: "luks2-argon2id", + format_args: vec!["--type", "luks2", "--pbkdf", "argon2id", "--pbkdf-force-iterations", "4", "--pbkdf-memory", "64", "-c", "aes-xts-plain64", "-s", "512"], + cipher: "aes-xts-plain64", + sector: 4096, + ver2: true, + }); +} + +#[test] +fn luks2_pbkdf2_sector512_aes256() { + run_case(&Case { + name: "luks2-pbkdf2", + format_args: vec!["--type", "luks2", "--pbkdf", "pbkdf2", "--pbkdf-force-iterations", "1000", "-c", "aes-xts-plain64", "-s", "256", "--sector-size", "512"], + cipher: "aes-xts-plain64", + sector: 512, + ver2: true, + }); +} diff --git a/crates/guara-core/tests/support/luks_encrypt.py b/crates/guara-core/tests/support/luks_encrypt.py new file mode 100644 index 0000000..2933f2f --- /dev/null +++ b/crates/guara-core/tests/support/luks_encrypt.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Cifra um volume em claro dentro de uma imagem LUKS, com cryptography (referência independente). +uso: luks_encrypt.py IMG PLAIN VOLUME_KEY_FILE PAYLOAD_OFFSET CIPHER SECTOR_SIZE""" +import sys, hashlib +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +img, plain, vkf, off, cipher, ss = sys.argv[1:7] +off = int(off); ss = int(ss) +vk = open(vkf, 'rb').read() +data = open(plain, 'rb').read() +data += bytes((-len(data)) % ss) +out = bytearray() +for i in range(0, len(data), ss): + sector512 = i // 512 + blk = data[i:i+ss] + if 'xts' in cipher: + tweak = sector512.to_bytes(16, 'little') + e = Cipher(algorithms.AES(vk), modes.XTS(tweak)).encryptor() + else: + if 'essiv:sha256' in cipher: + salt = hashlib.sha256(vk).digest() + iv = Cipher(algorithms.AES(salt), modes.ECB()).encryptor().update(sector512.to_bytes(16, 'little')) + else: + iv = sector512.to_bytes(16, 'little') + e = Cipher(algorithms.AES(vk), modes.CBC(iv)).encryptor() + out += e.update(blk) + e.finalize() +with open(img, 'r+b') as f: + f.seek(off) + f.write(out) diff --git a/scripts/fetch-testdata.sh b/scripts/fetch-testdata.sh new file mode 100755 index 0000000..3a069d6 --- /dev/null +++ b/scripts/fetch-testdata.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Baixa imagens de referência (BitLocker e FileVault2) do conjunto de testes do cryptsetup. +# Ficam em target/testdata (não versionado). Sem elas, os testes correspondentes são pulados. +set -euo pipefail +dest="$(cd "$(dirname "$0")/.." && pwd)/target/testdata" +mkdir -p "$dest" +base=https://gitlab.com/cryptsetup/cryptsetup/-/raw/main/tests +for n in bitlk-images fvault2-images; do + [ -d "$dest/$n" ] && continue + tmp="$(mktemp -d)" + curl -fsSL "$base/$n.tar.xz" -o "$tmp/$n.tar.xz" + tar xJf "$tmp/$n.tar.xz" -C "$dest" + rm -rf "$tmp" +done +echo "ok: $dest"