From 1c8cf30761a959583ac974ed77a8df47be8f405f Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 07:55:18 -0300 Subject: [PATCH 1/8] =?UTF-8?q?Depend=C3=AAncias:=20ctutils=200.4.5=20(RUS?= =?UTF-8?q?TSEC-2026-0385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0725c0..0f6513e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,9 +309,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] From 3a6b0c7b748c228b99e7302c4ef4a309707c0ef1 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 07:55:18 -0300 Subject: [PATCH 2/8] =?UTF-8?q?Depend=C3=AAncias:=20ctutils=200.4.5=20(RUS?= =?UTF-8?q?TSEC-2026-0385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0725c0..0f6513e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,9 +309,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] From 4bec3e971a6929ddb8b0336176de7c19f598cdd3 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 07:55:58 -0300 Subject: [PATCH 3/8] Discos virtuais: arquivo de apoio do QCOW2, snapshots de VMDK e DMG com LZFSE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QCOW2 e VMDK filhos leem o pai no que não alocaram (o QCOW2 respeita o formato do apoio; o VMDK acha o pai por nome ou, no VirtualBox, por UUID, e recusa o filho se o CID do pai mudou). Chunks LZFSE do DMG usam lzfse_rust, conferidos com dados do compressor de referência da Apple. Snapshot real do VirtualBox entra como fixture. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 7 + ROADMAP.md | 3 +- ROADMAP.pt-BR.md | 3 +- crates/guara-core/Cargo.toml | 1 + crates/guara-core/src/vdisk/dmg.rs | 7 +- crates/guara-core/src/vdisk/mod.rs | 22 +- crates/guara-core/src/vdisk/qcow2.rs | 87 ++++++- crates/guara-core/src/vdisk/vhd.rs | 2 +- crates/guara-core/src/vdisk/vhdx.rs | 2 +- crates/guara-core/src/vdisk/vmdk.rs | 187 +++++++++++---- .../tests/fixtures/vdisk/lzfse-64k.bin | Bin 0 -> 65536 bytes .../tests/fixtures/vdisk/lzfse-64k.bin.lzfse | Bin 0 -> 22091 bytes .../tests/fixtures/vdisk/lzfse-small.bin | Bin 0 -> 2900 bytes .../fixtures/vdisk/lzfse-small.bin.lzfse | Bin 0 -> 2056 bytes .../tests/fixtures/vdisk/vbox-snapshot.vmdk | Bin 0 -> 65536 bytes crates/guara-core/tests/vdisk.rs | 218 +++++++++++++++++- docs/pt-BR/suporte.md | 8 +- docs/support.md | 8 +- 18 files changed, 469 insertions(+), 86 deletions(-) create mode 100644 crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin create mode 100644 crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin.lzfse create mode 100644 crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin create mode 100644 crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin.lzfse create mode 100644 crates/guara-core/tests/fixtures/vdisk/vbox-snapshot.vmdk diff --git a/Cargo.lock b/Cargo.lock index 0f6513e..b0be34d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -430,6 +430,7 @@ dependencies = [ "jpeg-encoder", "libc", "lz4_flex", + "lzfse_rust", "md-5", "mp4", "pbkdf2", @@ -582,6 +583,12 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226" +[[package]] +name = "lzfse_rust" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6" + [[package]] name = "md-5" version = "0.11.0" diff --git a/ROADMAP.md b/ROADMAP.md index 5c24832..4824956 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -68,7 +68,8 @@ Today Guará opens volumes when you **already have** the password or key. Passwo | E01 and Ex01: read, write and `guara verify` | ✅ | | AFF4: read and write | ✅ | | Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ | -| QCOW2 with a backing file, VMDK snapshots, DMG with LZFSE or a password | 🔜 | +| QCOW2 with a backing file, VMDK snapshots (by name or VirtualBox UUID), DMG with LZFSE | ✅ | +| Password-protected DMG, parent identity check for VHD, VHDX and QCOW2 | 🔜 | | Mobile device image (see item 7) | 🔭 | ### 4. Assembling loose disks diff --git a/ROADMAP.pt-BR.md b/ROADMAP.pt-BR.md index 88e6fa7..7899fba 100644 --- a/ROADMAP.pt-BR.md +++ b/ROADMAP.pt-BR.md @@ -68,7 +68,8 @@ Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recupera | E01 e Ex01: leitura, gravação e `guara verify` | ✅ | | AFF4: leitura e gravação | ✅ | | Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ | -| QCOW2 com arquivo de apoio, snapshots de VMDK, DMG com LZFSE ou com senha | 🔜 | +| QCOW2 com arquivo de apoio, snapshots de VMDK (por nome ou UUID do VirtualBox), DMG com LZFSE | ✅ | +| DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2 | 🔜 | | Imagem de dispositivo móvel (ver item 7) | 🔭 | ### 4. Montagem de discos soltos diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index 5aa5bc1..e3b6b12 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -29,6 +29,7 @@ hmac = "0.13" pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] } ripemd = "0.2" argon2 = "0.6" +lzfse_rust = "0.2.1" [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/guara-core/src/vdisk/dmg.rs b/crates/guara-core/src/vdisk/dmg.rs index baad42d..664f9c0 100644 --- a/crates/guara-core/src/vdisk/dmg.rs +++ b/crates/guara-core/src/vdisk/dmg.rs @@ -1,6 +1,6 @@ //! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC, -//! zlib e bzip2. Imagens cifradas (`encrcdsa`) e chunks LZFSE (`ULFO`) são recusados com -//! mensagem clara. +//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem +//! clara. use super::{bad, be32, be64, read_exact, read_vec, Backend}; use crate::source::{FileSource, Source}; @@ -187,7 +187,8 @@ impl Dmg { bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?; } 0x8000_0007 => { - return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado")); + lzfse_rust::decode_bytes(&raw, &mut out) + .map_err(|e| bad(format!("chunk LZFSE inválido: {e}")))?; } t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))), } diff --git a/crates/guara-core/src/vdisk/mod.rs b/crates/guara-core/src/vdisk/mod.rs index 000d253..eed5bc9 100644 --- a/crates/guara-core/src/vdisk/mod.rs +++ b/crates/guara-core/src/vdisk/mod.rs @@ -2,9 +2,9 @@ //! //! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia //! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos -//! diferenciais (VHD e VHDX com pai) seguem a cadeia até o disco base; QCOW2 e VMDK com -//! arquivo de apoio são recusados com mensagem clara, porque ler só a camada filha -//! devolveria dados errados. +//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a +//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi +//! alterado depois do filho, os dados lidos refletem o pai atual. mod dmg; mod qcow2; @@ -130,8 +130,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result> { }; let name = format!("{format} {}", path.display()); let inner: Box = match format { - "QCOW2" => Box::new(qcow2::Qcow2::open(file).map_err(|e| io_err(e, &name))?), - "VMDK" => Box::new(vmdk::Vmdk::open(file, path).map_err(|e| io_err(e, &name))?), + "QCOW2" => Box::new(qcow2::Qcow2::open(file, path, depth).map_err(|e| io_err(e, &name))?), + "VMDK" => Box::new(vmdk::Vmdk::open(file, path, depth).map_err(|e| io_err(e, &name))?), "VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?), "VHDX" => Box::new(vhdx::Vhdx::open(file, path, depth).map_err(|e| io_err(e, &name))?), _ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?), @@ -139,17 +139,23 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result> { Ok(Some(VDisk { inner, format })) } -/// Abre o disco pai de um VHD/VHDX diferencial. +/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o +/// arquivo é tratado como disco bruto sem tentar reconhecer o formato. pub(crate) fn open_parent( candidates: &[std::path::PathBuf], depth: u32, + raw: bool, ) -> io::Result> { for p in candidates { if !p.is_file() { continue; } - if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? { - return Ok(Box::new(v)); + if !raw { + if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? { + return Ok(Box::new(v)); + } + } else if depth > MAX_DEPTH { + return Err(bad("cadeia de discos pai longa demais ou circular")); } let f = FileSource::open(p)?; return Ok(Box::new(f)); diff --git a/crates/guara-core/src/vdisk/qcow2.rs b/crates/guara-core/src/vdisk/qcow2.rs index b84b31a..5e446f1 100644 --- a/crates/guara-core/src/vdisk/qcow2.rs +++ b/crates/guara-core/src/vdisk/qcow2.rs @@ -1,10 +1,15 @@ -//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd) e -//! clusters zerados. Imagens cifradas, com arquivo de apoio, com arquivo de dados externo ou -//! com L2 estendido são recusadas. +//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd), +//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai. +//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um +//! arquivo local (`json:`, `nbd:`, URLs) são recusadas. -use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend}; +use super::{ + bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec, + Backend, +}; use crate::source::{FileSource, Source}; use std::io::{self, Read}; +use std::path::Path; use std::sync::Mutex; const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00; @@ -16,12 +21,13 @@ pub(super) struct Qcow2 { cluster_bits: u32, l1: Vec, zstd: bool, + parent: Option>, /// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados). cache: Mutex)>>, } impl Qcow2 { - pub(super) fn open(file: FileSource) -> io::Result { + pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result { let h = read_vec(&file, 0, 112.min(file.size() as usize))?; if h.len() < 72 { return Err(bad("cabeçalho QCOW2 truncado")); @@ -30,11 +36,6 @@ impl Qcow2 { if version != 2 && version != 3 { return Err(bad(format!("versão QCOW {version} não suportada"))); } - if be64(&h, 8) != 0 { - return Err(bad( - "a imagem QCOW2 usa arquivo de apoio (backing file); abrir só a camada filha daria dados errados", - )); - } let cluster_bits = be32(&h, 20); if !(9..=21).contains(&cluster_bits) { return Err(bad(format!( @@ -63,6 +64,11 @@ impl Qcow2 { } } } + let parent = if be64(&h, 8) != 0 { + Some(Self::open_backing(&file, &h, version, path, depth)?) + } else { + None + }; let l1_size = be32(&h, 36) as u64; let l1_off = be64(&h, 40); let n = check_table(l1_size, 8, "tabela L1")?; @@ -74,10 +80,65 @@ impl Qcow2 { cluster_bits, l1, zstd, + parent, cache: Mutex::new(None), }) } + /// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw` + /// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz. + fn open_backing( + file: &FileSource, + h: &[u8], + version: u32, + path: &Path, + depth: u32, + ) -> io::Result> { + let (off, len) = (be64(h, 8), be32(h, 16) as usize); + if len == 0 || len > 1023 { + return Err(bad("nome do arquivo de apoio QCOW2 inválido")); + } + let name = String::from_utf8_lossy(&read_vec(file, off, len)?).into_owned(); + if name.starts_with("json:") + || name.contains("://") + || name.split(':').next().is_some_and(|p| { + matches!(p, "nbd" | "ssh" | "rbd" | "sheepdog" | "gluster" | "iscsi") + }) + { + return Err(bad(format!( + "o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados" + ))); + } + // extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes + let hlen = if version == 3 && h.len() >= 104 { + be32(h, 100) as u64 + } else { + 72 + }; + let mut raw = false; + let mut pos = hlen.div_ceil(8) * 8; + for _ in 0..64 { + let mut eh = [0u8; 8]; + if read_exact(file, pos, &mut eh).is_err() { + break; + } + let (kind, elen) = (be32(&eh, 0), be32(&eh, 4) as u64); + if kind == 0 || elen > 1 << 20 { + break; + } + if kind == 0xE279_2ACA { + let fmt = read_vec(file, pos + 8, elen as usize)?; + raw = fmt == b"raw"; + } + pos += 8 + elen.div_ceil(8) * 8; + } + if version == 2 { + raw = false; + } + let dir = path.parent().unwrap_or(Path::new(".")); + open_parent(&candidate_paths(dir, &[name]), depth, raw) + } + fn cluster_size(&self) -> u64 { 1 << self.cluster_bits } @@ -139,8 +200,10 @@ impl Backend for Qcow2 { return Ok(n); } let off = entry & OFFSET_MASK; - if off == 0 || entry & 1 != 0 { - buf[..n].fill(0); + if entry & 1 != 0 { + buf[..n].fill(0); // zerado de propósito: não consulta o pai + } else if off == 0 { + read_parent(&self.parent, voff, &mut buf[..n])?; } else { read_exact(&self.file, off + within, &mut buf[..n])?; } diff --git a/crates/guara-core/src/vdisk/vhd.rs b/crates/guara-core/src/vdisk/vhd.rs index 204cb43..c25cb0a 100644 --- a/crates/guara-core/src/vdisk/vhd.rs +++ b/crates/guara-core/src/vdisk/vhd.rs @@ -112,7 +112,7 @@ impl Vhd { } } names.push(utf16(&dh[64..64 + 512], true)); - Some(open_parent(&candidate_paths(dir, &names), depth)?) + Some(open_parent(&candidate_paths(dir, &names), depth, false)?) } else { None }; diff --git a/crates/guara-core/src/vdisk/vhdx.rs b/crates/guara-core/src/vdisk/vhdx.rs index ff8063b..75b801c 100644 --- a/crates/guara-core/src/vdisk/vhdx.rs +++ b/crates/guara-core/src/vdisk/vhdx.rs @@ -130,7 +130,7 @@ impl Vhdx { } } let dir = path.parent().unwrap_or(Path::new(".")); - Some(open_parent(&candidate_paths(dir, &names), depth)?) + Some(open_parent(&candidate_paths(dir, &names), depth, false)?) } else { None }; diff --git a/crates/guara-core/src/vdisk/vmdk.rs b/crates/guara-core/src/vdisk/vmdk.rs index c6baf22..b7078c8 100644 --- a/crates/guara-core/src/vdisk/vmdk.rs +++ b/crates/guara-core/src/vdisk/vmdk.rs @@ -1,9 +1,12 @@ //! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos //! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como -//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) e o formato SEsparse -//! são recusados. +//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos +//! que o filho não alocou; o formato SEsparse é recusado. -use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend}; +use super::{ + bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent, + read_vec, Backend, +}; use crate::source::{FileSource, Source}; use std::io::{self, Read}; use std::path::Path; @@ -36,20 +39,73 @@ struct Extent { pub(super) struct Vmdk { extents: Vec, size: u64, + parent: Option>, } -fn parent_in(descriptor: &str) -> bool { - descriptor.lines().any(|l| { - let l = l.trim(); - l.strip_prefix("parentCID") +/// Valor de uma chave `chave=valor` do descritor, sem aspas. +fn desc_value(descriptor: &str, key: &str) -> Option { + descriptor.lines().find_map(|l| { + l.trim() + .strip_prefix(key) .and_then(|r| r.trim_start().strip_prefix('=')) - .is_some_and(|v| { - let v = v.trim().trim_matches('"'); - !v.is_empty() && !v.eq_ignore_ascii_case("ffffffff") - }) + .map(|v| v.trim().trim_matches('"').to_string()) }) } +/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido +/// logo depois do cabeçalho de uma extensão esparsa. +fn descriptor_of(path: &Path) -> Option { + let f = FileSource::open(path).ok()?; + let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?; + if head.starts_with(b"KDMV") { + let off = (le64(&head, 28) * SECTOR) as usize; + let len = (le64(&head, 36) * SECTOR) as usize; + let d = head.get(off..(off + len).min(head.len()))?; + let end = d.iter().position(|&b| b == 0).unwrap_or(d.len()); + return Some(String::from_utf8_lossy(&d[..end]).into_owned()); + } + head.starts_with(b"# Disk Desc") + .then(|| String::from_utf8_lossy(&head).into_owned()) +} + +/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só +/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter. +struct ParentRef { + name: Option, + uuid: Option, + cid: Option, +} + +fn parent_ref(descriptor: &str) -> Option { + let cid = desc_value(descriptor, "parentCID") + .filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("ffffffff")); + let uuid = desc_value(descriptor, "ddb.uuid.parent") + .filter(|u| !u.is_empty() && u.chars().any(|c| c != '0' && c != '-')); + let name = desc_value(descriptor, "parentFileNameHint").filter(|n| !n.is_empty()); + (cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid }) +} + +/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`. +fn find_by_uuid(dir: &Path, uuid: &str) -> Option { + let mut d = Some(dir); + for _ in 0..3 { + let cur = d?; + for e in std::fs::read_dir(cur).ok()?.flatten() { + let p = e.path(); + if p.extension() + .is_some_and(|x| x.eq_ignore_ascii_case("vmdk")) + && descriptor_of(&p) + .and_then(|ds| desc_value(&ds, "ddb.uuid.image")) + .is_some_and(|u| u.eq_ignore_ascii_case(uuid)) + { + return Some(p); + } + } + d = cur.parent(); + } + None +} + impl Sparse { fn open(file: FileSource) -> io::Result<(Self, u64, Option)> { let mut h = read_vec(&file, 0, SECTOR as usize)?; @@ -95,11 +151,6 @@ impl Sparse { } else { None }; - if descriptor.as_deref().is_some_and(parent_in) { - return Err(bad( - "VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados", - )); - } let gde = capacity.div_ceil(gtes * grain); let n = check_table(gde, 4, "diretório de grãos")?; let raw = read_vec(&file, gd_off * SECTOR, n * 4)?; @@ -118,7 +169,9 @@ impl Sparse { )) } - fn read(&self, off: u64, buf: &mut [u8]) -> io::Result { + /// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo + /// (o chamador completa com o pai); grão zerado de propósito devolve zeros. + fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> { let g = off / self.grain; let within = off % self.grain; let n = buf.len().min((self.grain - within) as usize); @@ -129,13 +182,16 @@ impl Sparse { read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?; sector = u32::from_le_bytes(b) as u64; } - if sector <= 1 { + if sector == 0 { + return Ok((n, false)); + } + if sector == 1 { buf[..n].fill(0); - return Ok(n); + return Ok((n, true)); } if !self.compressed { read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?; - return Ok(n); + return Ok((n, true)); } let mut cache = self.cache.lock().unwrap(); if cache.as_ref().map(|c| c.0) != Some(g) { @@ -153,17 +209,12 @@ impl Sparse { } let data = &cache.as_ref().unwrap().1; buf[..n].copy_from_slice(&data[within as usize..within as usize + n]); - Ok(n) + Ok((n, true)) } } /// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`. fn parse_descriptor(text: &str, dir: &Path) -> io::Result> { - if parent_in(text) { - return Err(bad( - "VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados", - )); - } let mut out = vec![]; let mut start = 0u64; for line in text.lines() { @@ -216,27 +267,79 @@ fn parse_descriptor(text: &str, dir: &Path) -> io::Result> { } impl Vmdk { - pub(super) fn open(file: FileSource, path: &Path) -> io::Result { + fn open_parent_of(r: &ParentRef, dir: &Path, depth: u32) -> io::Result> { + let mut found = r + .name + .as_ref() + .map(|n| candidate_paths(dir, std::slice::from_ref(n))) + .unwrap_or_default(); + let by_name = found.iter().any(|p| p.is_file()); + if !by_name { + if let Some(p) = r.uuid.as_deref().and_then(|u| find_by_uuid(dir, u)) { + found = vec![p]; + } + } + if r.name.is_none() && r.uuid.is_none() { + return Err(bad( + "VMDK com pai, mas o descritor não diz qual (sem parentFileNameHint nem ddb.uuid.parent)", + )); + } + if r.name.is_none() && found.is_empty() { + return Err(bad( + "VMDK com pai identificado só por UUID (VirtualBox) e o pai não está na pasta do filho nem nas duas acima", + )); + } + if let Some(cid) = &r.cid { + // o pai precisa ser o mesmo de quando o filho foi criado + if let Some(p) = found.iter().find(|p| p.is_file()) { + let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID")); + if have + .as_deref() + .is_some_and(|h| !h.eq_ignore_ascii_case(cid)) + { + return Err(bad(format!( + "o pai {} foi alterado depois do filho (CID {} em vez de {cid}); os dados do filho não valem mais", + p.display(), + have.unwrap_or_default() + ))); + } + } + } + open_parent(&found, depth, false) + } + + pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result { let mut head = [0u8; 4]; let _ = file.read_at(0, &mut head); - let extents = if &head == b"KDMV" { - let (sp, size, _) = Sparse::open(file)?; - vec![Extent { - start: 0, - len: size, - kind: Kind::Sparse(sp), - }] + let dir = path.parent().unwrap_or(Path::new(".")); + let (extents, descriptor) = if &head == b"KDMV" { + let (sp, size, descriptor) = Sparse::open(file)?; + ( + vec![Extent { + start: 0, + len: size, + kind: Kind::Sparse(sp), + }], + descriptor, + ) } else { if file.size() > 1 << 20 { return Err(bad("descritor VMDK grande demais")); } let raw = read_vec(&file, 0, file.size() as usize)?; - let text = String::from_utf8_lossy(&raw); - let dir = path.parent().unwrap_or(Path::new(".")); - parse_descriptor(&text, dir)? + let text = String::from_utf8_lossy(&raw).into_owned(); + (parse_descriptor(&text, dir)?, Some(text)) + }; + let parent = match descriptor.as_deref().and_then(parent_ref) { + Some(r) => Some(Self::open_parent_of(&r, dir, depth)?), + None => None, }; let size = extents.iter().map(|e| e.len).sum(); - Ok(Self { extents, size }) + Ok(Self { + extents, + size, + parent, + }) } } @@ -261,7 +364,13 @@ impl Backend for Vmdk { read_exact(file, offset + rel, &mut buf[..n])?; Ok(n) } - Kind::Sparse(s) => s.read(rel, &mut buf[..n]), + Kind::Sparse(s) => { + let (got, allocated) = s.read(rel, &mut buf[..n])?; + if !allocated { + read_parent(&self.parent, voff, &mut buf[..got])?; + } + Ok(got) + } } } } diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin b/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin new file mode 100644 index 0000000000000000000000000000000000000000..bcd1dfaf8884265d758de61463f4a806337b9d4c GIT binary patch literal 65536 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 literal 0 HcmV?d00001 diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin.lzfse b/crates/guara-core/tests/fixtures/vdisk/lzfse-64k.bin.lzfse new file mode 100644 index 0000000000000000000000000000000000000000..2a774e29ba06eb0b52a608abe82b9ff074975d18 GIT binary patch literal 22091 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 ic@R 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 Q0@44MU_1m^L%iAk2l7Px# literal 0 HcmV?d00001 diff --git a/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin b/crates/guara-core/tests/fixtures/vdisk/lzfse-small.bin new file mode 100644 index 0000000000000000000000000000000000000000..ed605f91cf0fcf1055bf18baa7fbf2ce7a661620 GIT binary patch literal 2900 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 literal 0 HcmV?d00001 diff --git a/crates/guara-core/tests/fixtures/vdisk/vbox-snapshot.vmdk b/crates/guara-core/tests/fixtures/vdisk/vbox-snapshot.vmdk new file mode 100644 index 0000000000000000000000000000000000000000..c8f22786984d0eab9be7757d2ba4dbb56dcf4bd8 GIT binary patch literal 65536 zcmeIx&uiR96aZkgxfDTfX%6j0$Y&YtTD!InduX~TDWy>2kep|wQ7q#1nqAq%ltTXW zYQ1Z4O%A!_knjy?M!cstKRz*k&i{JzU6LeEh9~)9+a<}zyFN*_@ojs$88Fo z{_}qPc%MtNAJhAH<5weakj=Zz`)nRJ^}4(2SL@%qiv-93L+!q$^*V+=zP`PRvwXR_TwQeic~_qeUv6TarU%)J5B*S+HTwmIS<_c< zvvFCSWM_Z>`s(aOmjBaE#!X!ww?$l(v8YCERfO`i9=g$ZTE&xi6ej;3-YlEJT zk6ClK>(GbndDz4(J($m*r)kqH4sW`3e;qDfzJ0ShL^jLvD!)sGCS3J#z1wZq3>(}Q zyo;-4?AN!4_3cG>*$h>)eEcYN9z!#PN7F~~O{@pAAu~PNWv;Kg=CE6acZ0|09P2^V zPIs2qb*N72vMDB^IV!4gT^5T;oD`vr;kYa(E_~p}K zQ33=A5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C7 z2oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N a0t5&UAV7cs0RjXF5FkK+009DDMBqQnC9}c+ literal 0 HcmV?d00001 diff --git a/crates/guara-core/tests/vdisk.rs b/crates/guara-core/tests/vdisk.rs index 211254b..7aebafc 100644 --- a/crates/guara-core/tests/vdisk.rs +++ b/crates/guara-core/tests/vdisk.rs @@ -281,22 +281,82 @@ fn qcow2_with_zero_raw_and_compressed_clusters() { } #[test] -fn qcow2_with_backing_file_or_encryption_is_refused() { +fn qcow2_with_encryption_or_a_remote_backing_file_is_refused() { let dir = tempfile::tempdir().unwrap(); - let mut img = build_qcow2(&raw()); - let p = dir.path().join("b.qcow2"); - be(&mut img, 8, 4096, 8); - be(&mut img, 16, 3, 4); - std::fs::write(&p, &img).unwrap(); - let e = open_path(&p).err().expect("deveria recusar").to_string(); - assert!(e.contains("arquivo de apoio"), "{e}"); - let mut img = build_qcow2(&raw()); be(&mut img, 32, 2, 4); let p = dir.path().join("e.qcow2"); std::fs::write(&p, &img).unwrap(); let e = open_path(&p).err().expect("deveria recusar").to_string(); assert!(e.contains("LUKS"), "{e}"); + + let mut img = build_qcow2(&raw()); + let name = b"nbd://servidor/disco"; + img[512..512 + name.len()].copy_from_slice(name); + be(&mut img, 8, 512, 8); + be(&mut img, 16, name.len() as u64, 4); + let p = dir.path().join("r.qcow2"); + std::fs::write(&p, &img).unwrap(); + let e = open_path(&p).err().expect("deveria recusar").to_string(); + assert!(e.contains("não é um arquivo local"), "{e}"); +} + +/// Filho QCOW2 que só aloca o cluster 1 (dados), marca o 2 como zerado e deixa o resto para o pai. +fn build_qcow2_child(backing: &str, fmt: Option<&str>, own: &[u8]) -> Vec { + const CS: usize = 1 << 16; + let mut img = vec![0u8; 3 * CS]; + img[..4].copy_from_slice(b"QFI\xfb"); + be(&mut img, 4, 3, 4); + be(&mut img, 16, backing.len() as u64, 4); + be(&mut img, 8, 1024, 8); + img[1024..1024 + backing.len()].copy_from_slice(backing.as_bytes()); + be(&mut img, 20, 16, 4); + be(&mut img, 24, 16 * CS as u64, 8); + be(&mut img, 36, 1, 4); + be(&mut img, 40, CS as u64, 8); + be(&mut img, 100, 112, 4); + if let Some(f) = fmt { + // extensão 0xE2792ACA com o nome do formato do apoio, depois o fim das extensões + be(&mut img, 112, 0xE279_2ACA, 4); + be(&mut img, 116, f.len() as u64, 4); + img[120..120 + f.len()].copy_from_slice(f.as_bytes()); + } + be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8); + let host = img.len() as u64; + img.extend_from_slice(own); + img.resize(img.len().div_ceil(CS) * CS, 0); + be(&mut img, 2 * CS + 8, host | (1 << 63), 8); // cluster 1 + be(&mut img, 2 * CS + 16, 1, 8); // cluster 2: zerado de propósito + img +} + +#[test] +fn qcow2_backing_file_supplies_what_the_child_did_not_allocate() { + const CS: usize = 1 << 16; + let base = raw(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("base.raw"), &base).unwrap(); + let own = vec![0x42u8; CS]; + let mut expect = base.clone(); + expect[CS..2 * CS].copy_from_slice(&own); + expect[2 * CS..3 * CS].fill(0); + for (name, fmt) in [("a.qcow2", Some("raw")), ("b.qcow2", None)] { + let p = dir.path().join(name); + std::fs::write(&p, build_qcow2_child("base.raw", fmt, &own)).unwrap(); + check_content(&p, &expect); + } + // com o pai em outro formato: o filho aponta para um QCOW2 + std::fs::write(dir.path().join("base.qcow2"), build_qcow2(&base)).unwrap(); + let p = dir.path().join("c.qcow2"); + std::fs::write(&p, build_qcow2_child("base.qcow2", Some("qcow2"), &own)).unwrap(); + check_content(&p, &expect); + // sem o pai, a abertura diz o que falta + std::fs::remove_file(dir.path().join("base.raw")).unwrap(); + let e = open_path(&dir.path().join("a.qcow2")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("disco pai não encontrado"), "{e}"); } // ---------- VMDK ---------- @@ -324,8 +384,114 @@ fn vmdk_descriptor_with_flat_and_zero_extents() { check_content(&p, &expect); } +/// VMDK esparso feito à mão: 16 grãos de 64 KiB; `grains[i]` = `None` (não alocado, vem do pai), +/// `Some(None)` (zerado de propósito) ou `Some(Some(dados))`. +fn build_vmdk_sparse(descriptor: &str, grains: &[Option>>]) -> Vec { + const GRAIN: usize = 128; // setores + let mut img = vec![0u8; 26 * 512]; + img[..4].copy_from_slice(b"KDMV"); + le(&mut img, 4, 1, 4); + le(&mut img, 8, 3, 4); + le(&mut img, 12, (grains.len() * GRAIN) as u64, 8); + le(&mut img, 20, GRAIN as u64, 8); + le(&mut img, 28, 1, 8); + le(&mut img, 36, 20, 8); + le(&mut img, 44, 512, 4); + le(&mut img, 56, 21, 8); + le(&mut img, 64, 26, 8); + img[512..512 + descriptor.len()].copy_from_slice(descriptor.as_bytes()); + le(&mut img, 21 * 512, 22, 4); // diretório de grãos -> tabela no setor 22 + for (i, g) in grains.iter().enumerate() { + let entry = match g { + None => 0, + Some(None) => 1, + Some(Some(d)) => { + let sector = (img.len() / 512) as u64; + let mut d = d.clone(); + d.resize(GRAIN * 512, 0); + img.extend_from_slice(&d); + sector + } + }; + le(&mut img, 22 * 512 + i * 4, entry, 4); + } + img +} + #[test] -fn vmdk_with_a_parent_is_refused() { +fn vmdk_child_reads_the_parent_for_unallocated_grains() { + let base = raw(); + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("base.vmdk"), build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=11111111\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n", + &(0..16) + .map(|i| Some(Some(base[i * 65536..(i + 1) * 65536].to_vec()))) + .collect::>(), + )) + .unwrap(); + let own = vec![0x37u8; 65536]; + let mut grains: Vec>>> = vec![None; 16]; + grains[3] = Some(Some(own.clone())); + grains[5] = Some(None); // zerado de propósito: não consulta o pai + let child = build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=22222222\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n", + &grains, + ); + let p = dir.path().join("child.vmdk"); + std::fs::write(&p, child).unwrap(); + let mut expect = base.clone(); + expect[3 * 65536..4 * 65536].copy_from_slice(&own); + expect[5 * 65536..6 * 65536].fill(0); + check_content(&p, &expect); +} + +/// Snapshot real do VirtualBox: o filho só diz o UUID do pai (`ddb.uuid.parent`), sem nome. +#[test] +fn virtualbox_snapshot_vmdk_finds_its_parent_by_uuid() { + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("Snapshots"); + std::fs::create_dir(&sub).unwrap(); + // o pai fica na pasta de cima, como o VirtualBox organiza a máquina + std::fs::copy(fx("disk.vmdk"), dir.path().join("disco-base.vmdk")).unwrap(); + std::fs::copy(fx("vbox-snapshot.vmdk"), sub.join("filho.vmdk")).unwrap(); + check_content(&sub.join("filho.vmdk"), &raw()); + // sem o pai por perto, a abertura explica + std::fs::remove_file(dir.path().join("disco-base.vmdk")).unwrap(); + let e = open_path(&sub.join("filho.vmdk")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("identificado só por UUID"), "{e}"); +} + +#[test] +fn vmdk_child_is_refused_when_the_parent_changed() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write( + dir.path().join("base.vmdk"), + build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=aaaaaaaa\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n", + &vec![None; 16], + ), + ) + .unwrap(); + std::fs::write( + dir.path().join("filho.vmdk"), + build_vmdk_sparse( + "# Disk DescriptorFile\nversion=1\nCID=bbbbbbbb\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n", + &vec![None; 16], + ), + ) + .unwrap(); + let e = open_path(&dir.path().join("filho.vmdk")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("foi alterado depois do filho"), "{e}"); +} + +#[test] +fn vmdk_with_a_parent_but_no_name_is_refused() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("c.vmdk"); std::fs::write( @@ -334,7 +500,7 @@ fn vmdk_with_a_parent_is_refused() { ) .unwrap(); let e = open_path(&p).err().expect("deveria recusar").to_string(); - assert!(e.contains("disco pai"), "{e}"); + assert!(e.contains("não diz qual"), "{e}"); } // ---------- VHD ---------- @@ -619,8 +785,36 @@ fn dmg_with_every_chunk_type() { check_content(&p, &expect); } +/// Chunks `ULFO` com dados compactados pelo compressor de referência da Apple (`lzfse`): +/// um bloco `bvx2` (FSE, 64 KiB) e um pequeno (`bvxn`/LZVN). #[test] -fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() { +fn dmg_lzfse_chunks_from_the_reference_compressor() { + let big = std::fs::read(fx("lzfse-64k.bin")).unwrap(); + let small = std::fs::read(fx("lzfse-small.bin")).unwrap(); + let mut small_padded = small.clone(); + small_padded.resize(small.len().div_ceil(512) * 512, 0); + let chunks = [ + DmgChunk { + ty: 0x8000_0007, + sectors: (big.len() / 512) as u64, + payload: std::fs::read(fx("lzfse-64k.bin.lzfse")).unwrap(), + }, + DmgChunk { + ty: 0x8000_0007, + sectors: (small_padded.len() / 512) as u64, + payload: std::fs::read(fx("lzfse-small.bin.lzfse")).unwrap(), + }, + ]; + let mut expect = big.clone(); + expect.extend_from_slice(&small_padded); + let dir = tempfile::tempdir().unwrap(); + let p = dir.path().join("l.dmg"); + std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap(); + check_content(&p, &expect); +} + +#[test] +fn dmg_with_a_corrupt_lzfse_chunk_and_encrypted_are_refused_with_a_reason() { let dir = tempfile::tempdir().unwrap(); let p = dir.path().join("l.dmg"); let chunks = [DmgChunk { diff --git a/docs/pt-BR/suporte.md b/docs/pt-BR/suporte.md index b2f9a2d..6b048e5 100644 --- a/docs/pt-BR/suporte.md +++ b/docs/pt-BR/suporte.md @@ -55,12 +55,12 @@ As senhas **nunca** vão para o arquivo de sessão. Com `--remember-keys` a sess | Bruta (dd, `.img`) | ✅ | ✅ com mapa de erros de setores ruins | | E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação | | AFF4 | ✅ | ✅ | -| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) | ❌ | -| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized` e descritores com extensões flat/zero | ❌ | +| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) e arquivo de apoio | ❌ | +| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized`, descritores com extensões flat/zero e snapshots (pai por nome ou, no VirtualBox, por UUID) | ❌ | | VHD e VHDX (Hyper-V) | ✅ fixo, dinâmico e diferencial (segue a cadeia de pais) | ❌ | -| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib e bzip2 | ❌ | +| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib, bzip2 e LZFSE | ❌ | -Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai (snapshot) ou em SEsparse, DMG protegido por senha e DMG com chunks LZFSE. O log de transações do VHDX não é reproduzido (nada é gravado na imagem). +Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 cifrado, QCOW2 cujo apoio não é um arquivo local, VMDK em SEsparse e DMG protegido por senha. Discos diferenciais (VHD, VHDX, QCOW2 com apoio, snapshot de VMDK) leem o pai no que o filho não gravou; o pai é procurado ao lado do filho (VMDK: por nome ou UUID) e, no VMDK, o CID dele precisa bater, senão a imagem é recusada porque o pai mudou. A identidade dos pais de VHD, VHDX e QCOW2 não é conferida. O log de transações do VHDX não é reproduzido (nada é gravado na imagem). `guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela. diff --git a/docs/support.md b/docs/support.md index 5b61db7..d7e7459 100644 --- a/docs/support.md +++ b/docs/support.md @@ -55,12 +55,12 @@ Passwords are **never** written to the session file. With `--remember-keys` the | Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map | | E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation | | AFF4 | ✅ | ✅ | -| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) | ❌ | -| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized` and descriptors with flat/zero extents | ❌ | +| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) and backing files | ❌ | +| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized`, descriptors with flat/zero extents and snapshots (parent by name or, for VirtualBox, by UUID) | ❌ | | VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ | -| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib and bzip2 chunks | ❌ | +| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib, bzip2 and LZFSE chunks | ❌ | -Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent (snapshot) or in SEsparse, password-protected DMG and DMG with LZFSE chunks. The VHDX transaction log is not replayed (nothing is ever written to the image). +Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: encrypted QCOW2, QCOW2 whose backing file is not a local file, VMDK in SEsparse, and password-protected DMG. Differencing disks (VHD, VHDX, QCOW2 backing file, VMDK snapshot) read the parent for what the child did not write; the parent is found next to the child (VMDK: by name or UUID) and, for VMDK, its CID must match, otherwise the image is refused because the parent changed. The identity of VHD, VHDX and QCOW2 parents is not checked. The VHDX transaction log is not replayed (nothing is ever written to the image). `guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it. From 447e57bc1b5f8f0939f1f1d48114f2f459c1da35 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 07:55:18 -0300 Subject: [PATCH 4/8] =?UTF-8?q?Depend=C3=AAncias:=20ctutils=200.4.5=20(RUS?= =?UTF-8?q?TSEC-2026-0385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0725c0..0f6513e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,9 +309,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] From 34fb72bf247b7f3eaca278c8d0da0862b0ec6984 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 08:00:02 -0300 Subject: [PATCH 5/8] =?UTF-8?q?Assinatura=20digital=20Ed25519=20de=20manif?= =?UTF-8?q?estos,=20relat=C3=B3rios=20e=20recupera=C3=A7=C3=B5es?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit guara sign keygen, file, verify e recovery. Com chave configurada, recover e report assinam sozinhos o manifesto e o relatório. A assinatura cobre hash, tamanho, nome, hora e assinante e é Ed25519 comum (conferida com o openssl). A conferência de uma pasta de recuperação refaz o hash de cada arquivo contra o manifesto e não deixa o manifesto apontar para fora da pasta. A hora é a do relógio local, sem carimbo de terceiros (RFC 3161). Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 73 ++++++ README.md | 6 +- README.pt-BR.md | 6 +- ROADMAP.md | 5 +- ROADMAP.pt-BR.md | 5 +- crates/guara-cli/src/main.rs | 153 ++++++++++++ crates/guara-core/Cargo.toml | 1 + crates/guara-core/src/lib.rs | 1 + crates/guara-core/src/recover.rs | 7 +- crates/guara-core/src/service.rs | 1 + crates/guara-core/src/sign.rs | 399 +++++++++++++++++++++++++++++++ crates/guara-core/tests/sign.rs | 267 +++++++++++++++++++++ docs/architecture.md | 1 + docs/cli.md | 12 + docs/pt-BR/arquitetura.md | 1 + docs/pt-BR/cli.md | 12 + 16 files changed, 936 insertions(+), 14 deletions(-) create mode 100644 crates/guara-core/src/sign.rs create mode 100644 crates/guara-core/tests/sign.rs diff --git a/Cargo.lock b/Cargo.lock index 0f6513e..58df776 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -340,6 +340,15 @@ dependencies = [ "objc2", ] +[[package]] +name = "ed25519-compact" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8" +dependencies = [ + "getrandom", +] + [[package]] name = "equivalent" version = "1.0.3" @@ -389,8 +398,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "wasm-bindgen", ] [[package]] @@ -423,6 +434,7 @@ dependencies = [ "aes", "argon2", "bzip2", + "ed25519-compact", "flate2", "hmac", "image", @@ -552,6 +564,16 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0370574b86f7eca156b9f298392b5e69a23f8c86f3f865add60bbc2e79467a6" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -844,6 +866,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "ruzstd" version = "0.9.1" @@ -1053,6 +1081,51 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "weezl" version = "0.1.12" diff --git a/README.md b/README.md index 0359f6f..d8a61ba 100644 --- a/README.md +++ b/README.md @@ -91,7 +91,7 @@ A desktop app (Tauri) in the style of a file manager, designed for when Guará f - **Automatic sessions** and reconnection: Guará recognizes the same disk coming back under another name. - **Duplicate detection** by content signature. -> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no audit log or signed report. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md). +> **Warning:** Guará has **not been externally validated** yet (for example, in the NIST CFTT tests) and has no trusted timestamp (RFC 3161) on its signatures. It is well suited to recovery and triage; for use in court, check the results by other means. The path to get there is in the [ROADMAP](ROADMAP.md). ## Known limits @@ -111,8 +111,8 @@ Summary of the [ROADMAP](ROADMAP.md): | | | |---|---| -| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, APFS free map | -| **Next** | Digitally signed report, tests on Windows and macOS | +| **Done** | Metadata from 8 file systems, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, fragmented JPEG, MP4 without an index, SQLite reader, SMART, QCOW2/VMDK/VHD/VHDX/DMG, hash-chained audit trail, PDF report, Ed25519 signatures, APFS free map | +| **Next** | Trusted timestamps (RFC 3161), tests on Windows and macOS | | **Later** | NSRL and hash lists, content search, unified timeline, browser interpretation, Windows registry and messaging apps, dictionary and brute-force attacks on encrypted volumes | | **No reference image** | ReFS and Storage Spaces: they only land with validation | | **Long term** | iOS and Android backups (iTunes, ADB), OCR, NIST CFTT validation, physical phone access through bootloader/chip exploits | diff --git a/README.pt-BR.md b/README.pt-BR.md index 0aea4d1..d68d82c 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -91,7 +91,7 @@ App desktop (Tauri) no estilo de um gerenciador de arquivos, pensado para quando - **Sessões automáticas** e reconexão: o Guará reconhece o mesmo disco que volta com outro nome. - **Detecção de duplicadas** por assinatura de conteúdo. -> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e não tem registro de auditoria nem relatório assinado. Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md). +> **Atenção:** o Guará ainda **não foi validado externamente** (por exemplo, nos testes do NIST CFTT) e suas assinaturas não têm carimbo de tempo de terceiros (RFC 3161). Serve bem para recuperação e triagem; para uso em juízo, confira os resultados por outro meio. O caminho para isso está no [ROADMAP](ROADMAP.pt-BR.md). ## Limites conhecidos @@ -111,8 +111,8 @@ Resumo do [ROADMAP](ROADMAP.pt-BR.md): | | | |---|---| -| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, mapa livre do APFS | -| **Próximo** | Relatório assinado digitalmente, testes em Windows e macOS | +| **Feito** | Metadados de 8 sistemas de arquivos, BitLocker, LUKS, FileVault 2, RAID, LVM, E01, Ex01, AFF4, JPEG fragmentado, MP4 sem índice, leitor de SQLite, SMART, QCOW2/VMDK/VHD/VHDX/DMG, trilha de auditoria encadeada por hash, relatório em PDF, assinaturas Ed25519, mapa livre do APFS | +| **Próximo** | Carimbo de tempo de terceiros (RFC 3161), testes em Windows e macOS | | **Depois** | NSRL e listas de hashes, busca em conteúdo, linha do tempo unificada, interpretação de navegadores, registro do Windows e apps de mensagem, ataque de dicionário e força bruta a volumes cifrados | | **Sem imagem de referência** | ReFS e Storage Spaces: só entram com validação | | **Longo prazo** | Backups de iOS e Android (iTunes, ADB), OCR, validação NIST CFTT, acesso físico a celulares por exploit de bootloader/chip | diff --git a/ROADMAP.md b/ROADMAP.md index 5c24832..b0ed57d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,8 @@ Physical access is the differentiator of commercial tools like Cellebrite. It de | HTML report with case, examiner, source, partitions, summary and hashes | ✅ | | Verification of an E01/Ex01 image against the stored hash | ✅ | | **Audit trail** of every examiner action (who, when, what), chained by hash, with `guara audit --verify` | ✅ | -| **Digitally signed reports** and a timestamped manifest | 🔜 | +| **Digital signatures** (Ed25519) of manifests, reports and any file; `guara sign recovery` checks a whole recovery folder | ✅ | +| Trusted timestamp (RFC 3161) on signatures | 🔜 | | **Reproducibility:** replay the recorded procedure and get the same result | 🔜 | | PDF report (A4, built in) | ✅ | | **External validation:** NIST CFTT (Computer Forensic Tool Testing) tests and publication of the results | 🔭 | @@ -165,7 +166,7 @@ External validation is what makes a report accepted. Without it, Guará is good No dates, in the order that makes the most sense. **1.3: trust and custody** -Signed manifest, XFS and APFS validation with real deletions, tests with real images on Windows and macOS. +Trusted timestamps, XFS and APFS validation with real deletions, tests with real images on Windows and macOS. **1.4: analysis** NSRL and hash lists, search in file content, unified timeline. diff --git a/ROADMAP.pt-BR.md b/ROADMAP.pt-BR.md index 88e6fa7..e2641d5 100644 --- a/ROADMAP.pt-BR.md +++ b/ROADMAP.pt-BR.md @@ -129,7 +129,8 @@ O acesso físico é o diferencial de ferramentas comerciais como a Cellebrite. D | Relatório HTML com caso, examinador, origem, partições, resumo e hashes | ✅ | | Verificação de imagem E01/Ex01 contra o hash gravado | ✅ | | **Trilha de auditoria** de cada ação do perito (quem, quando, o quê), encadeada por hash, com `guara audit --verify` | ✅ | -| **Relatórios assinados** digitalmente e manifesto com carimbo de tempo | 🔜 | +| **Assinatura digital** (Ed25519) de manifestos, relatórios e qualquer arquivo; `guara sign recovery` confere uma pasta de recuperação inteira | ✅ | +| Carimbo de tempo de terceiros (RFC 3161) nas assinaturas | 🔜 | | **Reprodutibilidade:** repetir o procedimento gravado e obter o mesmo resultado | 🔜 | | Relatório em PDF (A4, próprio) | ✅ | | **Validação externa:** testes do NIST CFTT (Computer Forensic Tool Testing) e publicação dos resultados | 🔭 | @@ -165,7 +166,7 @@ A validação externa é o que faz um laudo ser aceito. Sem ela, o Guará serve Sem datas, na ordem em que fazem mais sentido. **1.3: confiança e custódia** -Manifesto assinado, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS. +Carimbo de tempo, validação de XFS e APFS com exclusões reais, testes com imagens reais no Windows e macOS. **1.4: análise** NSRL e listas de hashes, busca em conteúdo de arquivos, linha do tempo unificada. diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index 52d4f1d..6f178da 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -18,6 +18,45 @@ struct Cli { cmd: Cmd, } +#[derive(Subcommand)] +enum SignCmd { + /// Cria o par de chaves de assinatura (recusa sobrescrever uma chave existente) + Keygen { + /// Onde gravar a chave privada (padrão: assinatura.key na pasta de configuração ou GUARA_SIGNING_KEY) + #[arg(long)] + key: Option, + }, + /// Assina um arquivo e grava .assinatura.json + File { + path: String, + #[arg(long)] + key: Option, + /// Nome de quem assina (padrão: GUARA_EXAMINER) + #[arg(long)] + signer: Option, + }, + /// Confere a assinatura de um arquivo + Verify { + path: String, + /// Arquivo de assinatura (padrão: .assinatura.json) + #[arg(long)] + sig: Option, + /// Impressão digital esperada da chave, publicada pelo perito + #[arg(long)] + trust: Option, + #[arg(long)] + json: bool, + }, + /// Confere uma pasta de recuperação: assinatura do manifesto e hash de cada arquivo + Recovery { + dir: String, + #[arg(long)] + trust: Option, + #[arg(long)] + json: bool, + }, +} + #[derive(Subcommand)] enum Cmd { /// Lista discos e pendrives detectados @@ -88,6 +127,9 @@ enum Cmd { }, /// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem Verify { path: String }, + /// Assina e confere arquivos (manifestos, relatórios) e pastas de recuperação com Ed25519 + #[command(subcommand)] + Sign(SignCmd), /// Mostra a trilha de auditoria (quem fez o quê e quando) ou confere o encadeamento por hash Audit { /// Arquivo da trilha (padrão: auditoria.jsonl na pasta de configuração ou GUARA_AUDIT_LOG) @@ -455,6 +497,7 @@ fn run(cli: Cli) -> Result<(), String> { tail, json, } => audit(log, verify, tail, json), + Cmd::Sign(c) => sign_cmd(c), Cmd::Image { path, dest, @@ -881,6 +924,116 @@ fn audit(log: Option, verify: bool, tail: Option, json: bool) -> Ok(()) } +/// `guara sign`: chaves, assinatura e conferência. +fn sign_cmd(c: SignCmd) -> Result<(), String> { + use guara_core::sign; + use std::path::{Path, PathBuf}; + let key_path = |k: Option| k.map(PathBuf::from).unwrap_or_else(sign::default_key_path); + match c { + SignCmd::Keygen { key } => { + let p = key_path(key); + let k = sign::SigningKey::generate(); + k.save(&p).map_err(s)?; + guara_core::audit::record("sign.keygen", json!({ "fingerprint": k.fingerprint() })); + println!( + "Chave privada: {} (guarde uma cópia segura; quem a tem assina em seu nome)", + p.display() + ); + println!("Chave pública: {}", p.with_extension("pub").display()); + println!("Impressão digital: {}", k.fingerprint()); + println!("Publique a impressão digital fora desta máquina para que outros confiram as assinaturas."); + Ok(()) + } + SignCmd::File { path, key, signer } => { + let p = key_path(key); + let k = sign::SigningKey::load(&p).map_err(s)?; + let who = signer.or_else(|| std::env::var("GUARA_EXAMINER").ok()); + let out = sign::sign_file(&k, Path::new(&path), who.as_deref()).map_err(s)?; + guara_core::audit::record( + "sign", + json!({ "path": path, "signature": out.to_string_lossy() }), + ); + println!("Assinatura gravada em {}", out.display()); + println!("Impressão digital da chave: {}", k.fingerprint()); + Ok(()) + } + SignCmd::Verify { + path, + sig, + trust, + json, + } => { + let c = sign::verify_file( + Path::new(&path), + sig.as_deref().map(Path::new), + trust.as_deref(), + ) + .map_err(s)?; + guara_core::audit::record("sign.verify", json!({ "path": path, "valid": c.valid })); + if json { + println!("{}", serde_json::to_string_pretty(&c).unwrap()); + } else if c.valid { + println!("Assinatura válida: o arquivo não mudou desde que foi assinado."); + println!( + "Chave: {} · assinado em {}{}", + c.fingerprint, + c.signed_at, + c.signer + .as_ref() + .map(|s| format!(" por {s}")) + .unwrap_or_default() + ); + if c.trusted.is_none() { + println!( + "Confira a impressão digital acima com a que o perito publicou (--trust)." + ); + } + } + if c.valid { + Ok(()) + } else { + Err(c.problems.join("; ")) + } + } + SignCmd::Recovery { dir, trust, json } => { + let c = sign::verify_recovery(Path::new(&dir), trust.as_deref()).map_err(s)?; + guara_core::audit::record( + "sign.verify_recovery", + json!({ "dir": dir, "valid": c.valid, "files": c.files }), + ); + if json { + println!("{}", serde_json::to_string_pretty(&c).unwrap()); + } else { + println!( + "{} de {} arquivo(s) conferem com o manifesto.", + c.ok, c.files + ); + for f in &c.missing { + println!(" ausente: {f}"); + } + for f in &c.changed { + println!(" alterado: {f}"); + } + match &c.manifest_signature { + Some(m) if m.valid => { + println!("Manifesto assinado e íntegro. Chave: {}", m.fingerprint) + } + Some(m) => println!( + "Assinatura do manifesto com problema: {}", + m.problems.join("; ") + ), + None => println!("Manifesto sem assinatura (assine com guara sign file)."), + } + } + if c.valid { + Ok(()) + } else { + Err("a recuperação não confere".into()) + } + } + } +} + fn wait(eng: &Engine, job: &str, session: Option<&str>) -> Result { let interrupted = Arc::new(AtomicBool::new(false)); let flag = interrupted.clone(); diff --git a/crates/guara-core/Cargo.toml b/crates/guara-core/Cargo.toml index 5aa5bc1..2bc1536 100644 --- a/crates/guara-core/Cargo.toml +++ b/crates/guara-core/Cargo.toml @@ -29,6 +29,7 @@ hmac = "0.13" pbkdf2 = { version = "0.13", default-features = false, features = ["hmac"] } ripemd = "0.2" argon2 = "0.6" +ed25519-compact = { version = "2", default-features = false, features = ["random", "std"] } [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/crates/guara-core/src/lib.rs b/crates/guara-core/src/lib.rs index deb4e40..f950c7c 100644 --- a/crates/guara-core/src/lib.rs +++ b/crates/guara-core/src/lib.rs @@ -44,6 +44,7 @@ pub mod scan; pub mod search; pub mod service; pub mod settings; +pub mod sign; pub mod signatures; pub mod source; pub mod sqlite; diff --git a/crates/guara-core/src/recover.rs b/crates/guara-core/src/recover.rs index 701e080..b1abc29 100644 --- a/crates/guara-core/src/recover.rs +++ b/crates/guara-core/src/recover.rs @@ -286,10 +286,9 @@ pub fn recover_resumable( "algoritmos_hash": ["MD5", "SHA-1", "SHA-256"], "relatorio": &report, }); - crate::owner::write_owned( - &dest.join("guara-manifesto.json"), - serde_json::to_vec_pretty(&manifest)?, - )?; + let manifest_path = dest.join("guara-manifesto.json"); + crate::owner::write_owned(&manifest_path, serde_json::to_vec_pretty(&manifest)?)?; + crate::sign::sign_if_configured(&manifest_path); Ok(report) } diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index 7796af7..0da3fa7 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -706,6 +706,7 @@ impl Engine { crate::report::html(&input).into_bytes() }; crate::owner::write_owned(Path::new(path), bytes)?; + crate::sign::sign_if_configured(Path::new(path)); crate::audit::record( "export.report", json!({ "job": id, "source": j.target, "path": path, "case": case.case_id, "examiner": case.examiner }), diff --git a/crates/guara-core/src/sign.rs b/crates/guara-core/src/sign.rs new file mode 100644 index 0000000..aab03ac --- /dev/null +++ b/crates/guara-core/src/sign.rs @@ -0,0 +1,399 @@ +//! Assinatura digital (Ed25519) de manifestos, relatórios e qualquer outro arquivo, e conferência +//! de uma recuperação inteira. +//! +//! A assinatura fica ao lado do arquivo, em `.assinatura.json`, e cobre o SHA-256 do +//! conteúdo, o tamanho, o nome, a hora e o assinante. Quem confere recalcula o hash do arquivo, +//! refaz a mensagem a partir dos campos do JSON e valida com a chave pública que vem nele. Isso +//! prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de +//! quem é a chave, compare a impressão digital (`fingerprint`) com a que o perito publicou ou +//! registrou fora da máquina, passando-a em `trusted_fingerprint`. +//! +//! A hora é a do relógio da máquina de quem assinou: não é um carimbo de tempo de terceiros +//! (RFC 3161) e, sozinha, não prova *quando* a assinatura foi feita. +//! +//! A chave privada fica em `assinatura.key` na pasta de configuração (ou onde +//! `GUARA_SIGNING_KEY` apontar), em arquivo de texto com permissão 0600 nos sistemas Unix. Quem +//! precisa de mais proteção deve guardá-la num pendrive ou volume cifrado e apontar para lá. + +use crate::error::{Error, Result}; +use ed25519_compact::{KeyPair, Noise, PublicKey, Seed, Signature}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::io::Read; +use std::path::{Path, PathBuf}; + +const FORMAT: &str = "guara-assinatura-v1"; +const KEY_HEADER: &str = "guara-ed25519-seed-v1"; +const PUB_HEADER: &str = "guara-ed25519-public-v1"; + +fn hex(b: &[u8]) -> String { + b.iter().map(|x| format!("{x:02x}")).collect() +} + +fn unhex(s: &str) -> Option> { + let s = s.trim(); + if s.len() % 2 != 0 || !s.is_ascii() { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} + +/// Impressão digital da chave pública: os 10 primeiros bytes do SHA-256, em grupos de 4 hex. +pub fn fingerprint(public_key: &[u8]) -> String { + let h = Sha256::digest(public_key); + let h = hex(&h[..10]); + h.as_bytes() + .chunks(4) + .map(|c| std::str::from_utf8(c).unwrap()) + .collect::>() + .join(":") +} + +/// Compara impressões digitais ignorando maiúsculas, `:` e espaços. +fn same_fingerprint(a: &str, b: &str) -> bool { + let n = |s: &str| { + s.chars() + .filter(|c| c.is_ascii_hexdigit()) + .map(|c| c.to_ascii_lowercase()) + .collect::() + }; + let (a, b) = (n(a), n(b)); + !a.is_empty() && a == b +} + +/// Par de chaves de assinatura. +pub struct SigningKey { + kp: KeyPair, +} + +impl SigningKey { + pub fn generate() -> Self { + Self { + kp: KeyPair::from_seed(Seed::generate()), + } + } + + pub fn public_bytes(&self) -> [u8; 32] { + *self.kp.pk + } + + pub fn fingerprint(&self) -> String { + fingerprint(&self.public_bytes()) + } + + pub fn load(path: &Path) -> Result { + let text = std::fs::read_to_string(path).map_err(|e| { + Error::msg(format!( + "não foi possível ler a chave {}: {e}", + path.display() + )) + })?; + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + if lines.next() != Some(KEY_HEADER) { + return Err(Error::msg(format!( + "{} não é uma chave de assinatura do Guará", + path.display() + ))); + } + let seed = lines + .next() + .and_then(unhex) + .and_then(|b| <[u8; 32]>::try_from(b).ok()) + .ok_or_else(|| Error::msg("chave de assinatura corrompida"))?; + Ok(Self { + kp: KeyPair::from_seed(Seed::new(seed)), + }) + } + + /// Grava a chave privada (0600 no Unix) e, ao lado, a pública (`.pub`). Recusa sobrescrever. + pub fn save(&self, path: &Path) -> Result<()> { + use std::io::Write; + if let Some(dir) = path.parent() { + crate::owner::create_dir_all_owned(dir)?; + } + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600); + let mut f = opts.open(path).map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + Error::msg(format!( + "já existe uma chave em {}; apague-a de propósito se quiser trocar (as assinaturas antigas continuam válidas com a chave pública antiga)", + path.display() + )) + } else { + e.into() + } + })?; + f.write_all(format!("{KEY_HEADER}\n{}\n", hex(self.kp.sk.seed().as_ref())).as_bytes())?; + drop(f); + crate::owner::give_to_user(path); + let pubp = pub_path(path); + crate::owner::write_owned( + &pubp, + format!( + "{PUB_HEADER}\n{}\n# impressão digital: {}\n", + hex(&self.public_bytes()), + self.fingerprint() + ), + )?; + Ok(()) + } +} + +fn pub_path(key: &Path) -> PathBuf { + key.with_extension("pub") +} + +/// `GUARA_SIGNING_KEY` ou `assinatura.key` na pasta de configuração. +pub fn default_key_path() -> PathBuf { + match std::env::var("GUARA_SIGNING_KEY") { + Ok(p) if !p.is_empty() => PathBuf::from(p), + _ => crate::settings::config_dir().join("assinatura.key"), + } +} + +/// Arquivo que acompanha `path` com a assinatura. +pub fn signature_path(path: &Path) -> PathBuf { + let mut n = path.as_os_str().to_os_string(); + n.push(".assinatura.json"); + PathBuf::from(n) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignatureFile { + pub formato: String, + pub arquivo: String, + pub tamanho: u64, + pub sha256: String, + pub assinado_em: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assinante: Option, + pub chave_publica: String, + pub impressao_digital: String, + pub assinatura: String, +} + +fn message(f: &SignatureFile) -> Vec { + format!( + "{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante={}\n", + f.formato, + f.arquivo, + f.tamanho, + f.sha256, + f.assinado_em, + f.assinante.as_deref().unwrap_or("") + ) + .into_bytes() +} + +/// SHA-256 e tamanho de um arquivo, lido em blocos. +pub fn hash_file(path: &Path) -> Result<(String, u64)> { + let mut f = std::fs::File::open(path)?; + let mut h = Sha256::new(); + let mut buf = vec![0u8; 1 << 20]; + let mut total = 0u64; + loop { + let n = f.read(&mut buf)?; + if n == 0 { + break; + } + h.update(&buf[..n]); + total += n as u64; + } + Ok((hex(&h.finalize()), total)) +} + +fn now_iso() -> String { + let secs = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + crate::audit::utc_iso(secs) +} + +/// Assina `path` e grava `.assinatura.json`. Devolve o caminho da assinatura. +pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result { + let (sha256, size) = hash_file(path)?; + let mut sig = SignatureFile { + formato: FORMAT.into(), + arquivo: path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + tamanho: size, + sha256, + assinado_em: now_iso(), + assinante: signer.filter(|s| !s.is_empty()).map(String::from), + chave_publica: hex(&key.public_bytes()), + impressao_digital: key.fingerprint(), + assinatura: String::new(), + }; + sig.assinatura = hex(key + .kp + .sk + .sign(message(&sig), Some(Noise::generate())) + .as_ref()); + let out = signature_path(path); + crate::owner::write_owned(&out, serde_json::to_vec_pretty(&sig)?)?; + Ok(out) +} + +/// Resultado de conferir uma assinatura. +#[derive(Debug, Clone, Serialize)] +pub struct Check { + /// O arquivo é o mesmo e a assinatura vale para a chave que vem nela. + pub valid: bool, + pub file_matches: bool, + pub signature_valid: bool, + pub fingerprint: String, + pub signed_at: String, + pub signer: Option, + /// `Some(true/false)` quando uma impressão digital de confiança foi informada. + pub trusted: Option, + pub problems: Vec, +} + +pub fn verify_file( + path: &Path, + sig_path: Option<&Path>, + trusted_fingerprint: Option<&str>, +) -> Result { + let sp = sig_path + .map(Path::to_path_buf) + .unwrap_or_else(|| signature_path(path)); + let raw = std::fs::read(&sp).map_err(|e| { + Error::msg(format!( + "não foi possível ler a assinatura {}: {e}", + sp.display() + )) + })?; + let sig: SignatureFile = serde_json::from_slice(&raw) + .map_err(|e| Error::msg(format!("assinatura ilegível: {e}")))?; + let mut problems = vec![]; + if sig.formato != FORMAT { + problems.push(format!( + "formato de assinatura desconhecido ({})", + sig.formato + )); + } + let (sha, size) = hash_file(path)?; + let file_matches = sha == sig.sha256 && size == sig.tamanho; + if !file_matches { + problems.push("o arquivo mudou depois de assinado (hash ou tamanho diferem)".into()); + } + let pk = unhex(&sig.chave_publica).and_then(|b| PublicKey::from_slice(&b).ok()); + let sg = unhex(&sig.assinatura).and_then(|b| Signature::from_slice(&b).ok()); + let signature_valid = match (&pk, &sg) { + (Some(pk), Some(sg)) => pk.verify(message(&sig), sg).is_ok(), + _ => false, + }; + if !signature_valid { + problems.push("a assinatura não confere (campos alterados ou chave errada)".into()); + } + let real_fp = pk + .as_ref() + .map(|p| fingerprint(p.as_ref())) + .unwrap_or_default(); + if real_fp != sig.impressao_digital { + problems.push("a impressão digital declarada não é a da chave pública".into()); + } + let trusted = trusted_fingerprint.map(|t| same_fingerprint(t, &real_fp)); + if trusted == Some(false) { + problems.push(format!( + "a chave que assinou ({real_fp}) não é a esperada ({})", + trusted_fingerprint.unwrap_or_default() + )); + } + Ok(Check { + valid: problems.is_empty(), + file_matches, + signature_valid, + fingerprint: real_fp, + signed_at: sig.assinado_em, + signer: sig.assinante, + trusted, + problems, + }) +} + +/// Confere uma pasta de recuperação: a assinatura do manifesto (se houver) e o hash de cada +/// arquivo contra o que o manifesto registrou. +#[derive(Debug, Clone, Serialize)] +pub struct RecoveryCheck { + pub manifest_signature: Option, + pub files: usize, + pub ok: usize, + pub missing: Vec, + pub changed: Vec, + /// `true` quando tudo confere (e, havendo assinatura, ela vale). + pub valid: bool, +} + +pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result { + let manifest = dir.join("guara-manifesto.json"); + let raw = std::fs::read(&manifest) + .map_err(|e| Error::msg(format!("não foi possível ler {}: {e}", manifest.display())))?; + let v: serde_json::Value = serde_json::from_slice(&raw)?; + let sigp = signature_path(&manifest); + let manifest_signature = if sigp.is_file() { + Some(verify_file(&manifest, None, trusted_fingerprint)?) + } else { + None + }; + let list = v["relatorio"]["recovered"] + .as_array() + .cloned() + .unwrap_or_default(); + let (mut ok, mut missing, mut changed) = (0, vec![], vec![]); + for f in &list { + let (Some(rel), Some(want)) = (f["path"].as_str(), f["sha256"].as_str()) else { + continue; + }; + // o manifesto é dado de entrada: não deixa sair da pasta + if rel.split('/').any(|c| c == "..") || Path::new(rel).is_absolute() { + changed.push(format!("{rel} (caminho fora da pasta)")); + continue; + } + let p = dir.join(rel); + match hash_file(&p) { + Ok((h, _)) if h == want => ok += 1, + Ok(_) => changed.push(rel.to_string()), + Err(_) => missing.push(rel.to_string()), + } + } + let signature_ok = match &manifest_signature { + Some(c) => c.valid, + None => true, + }; + let valid = missing.is_empty() && changed.is_empty() && signature_ok; + Ok(RecoveryCheck { + manifest_signature, + files: list.len(), + ok, + missing, + changed, + valid, + }) +} + +/// Assina `path` se existir uma chave configurada; nunca interrompe quem chamou. Registra na +/// trilha de auditoria. +pub fn sign_if_configured(path: &Path) { + let kp = default_key_path(); + if !kp.is_file() { + return; + } + let result = SigningKey::load(&kp) + .and_then(|k| sign_file(&k, path, std::env::var("GUARA_EXAMINER").ok().as_deref())); + match result { + Ok(sig) => crate::audit::record( + "sign", + serde_json::json!({ "path": path.to_string_lossy(), "signature": sig.to_string_lossy() }), + ), + Err(e) => eprintln!("aviso: não foi possível assinar {}: {e}", path.display()), + } +} diff --git a/crates/guara-core/tests/sign.rs b/crates/guara-core/tests/sign.rs new file mode 100644 index 0000000..6a4abfd --- /dev/null +++ b/crates/guara-core/tests/sign.rs @@ -0,0 +1,267 @@ +//! Assinatura digital: chaves, arquivos, adulteração, recuperação inteira e interoperabilidade. + +use guara_core::recover; +use guara_core::sign::{self, SigningKey}; +use guara_core::source::FileSource; +use guara_core::*; +use std::path::Path; + +fn resident(id: u64, name: &str, bytes: Vec) -> Found { + let ext = name.rsplit('.').next().unwrap_or("").to_string(); + Found { + id, + name: name.into(), + dir: None, + category: signatures::category_for_ext(&ext), + ext, + size: bytes.len() as u64, + origin: Origin::Carved, + confidence: Confidence::High, + data: DataLoc::Resident { bytes }, + modified: None, + created: None, + accessed: None, + taken: None, + camera: None, + px: None, + gps: false, + sig: None, + note: None, + } +} + +/// `recover` assina sozinho quando `GUARA_SIGNING_KEY` existe; testes que recuperam não se misturam. +static ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn keyed(dir: &Path) -> SigningKey { + let k = SigningKey::generate(); + k.save(&dir.join("k.key")).unwrap(); + k +} + +#[test] +fn key_roundtrip_and_no_overwrite() { + let dir = tempfile::tempdir().unwrap(); + let k = keyed(dir.path()); + let again = SigningKey::load(&dir.path().join("k.key")).unwrap(); + assert_eq!(k.public_bytes(), again.public_bytes()); + assert_eq!(k.fingerprint(), again.fingerprint()); + assert_eq!(k.fingerprint().len(), 24); // 5 grupos de 4 + 4 separadores + let pubfile = std::fs::read_to_string(dir.path().join("k.pub")).unwrap(); + assert!(pubfile.contains(&k.fingerprint())); + let e = SigningKey::generate() + .save(&dir.path().join("k.key")) + .err() + .unwrap() + .to_string(); + assert!(e.contains("já existe uma chave"), "{e}"); + // a chave antiga continua intacta + assert_eq!( + SigningKey::load(&dir.path().join("k.key")) + .unwrap() + .fingerprint(), + k.fingerprint() + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(dir.path().join("k.key")) + .unwrap() + .permissions() + .mode(); + assert_eq!( + mode & 0o777, + 0o600, + "a chave privada precisa ser só do dono" + ); + } + std::fs::write(dir.path().join("lixo.key"), "outra coisa").unwrap(); + assert!(SigningKey::load(&dir.path().join("lixo.key")).is_err()); +} + +#[test] +fn signed_file_verifies_and_tampering_is_caught() { + let dir = tempfile::tempdir().unwrap(); + let k = keyed(dir.path()); + let f = dir.path().join("relatorio.pdf"); + std::fs::write(&f, b"conteudo do relatorio").unwrap(); + let sig = sign::sign_file(&k, &f, Some("Perita Ana")).unwrap(); + assert_eq!(sig, sign::signature_path(&f)); + let c = sign::verify_file(&f, None, Some(&k.fingerprint())).unwrap(); + assert!( + c.valid && c.trusted == Some(true) && c.signer.as_deref() == Some("Perita Ana"), + "{c:?}" + ); + // fingerprint aceito em outro formato (maiúsculas, sem dois pontos) + let loose = k.fingerprint().replace(':', " ").to_uppercase(); + assert_eq!( + sign::verify_file(&f, None, Some(&loose)).unwrap().trusted, + Some(true) + ); + + // outra chave não é a esperada + let other = SigningKey::generate().fingerprint(); + let c = sign::verify_file(&f, None, Some(&other)).unwrap(); + assert!(!c.valid && c.trusted == Some(false)); + + // arquivo alterado + std::fs::write(&f, b"conteudo ALTERADO do relatorio").unwrap(); + let c = sign::verify_file(&f, None, None).unwrap(); + assert!(!c.valid && !c.file_matches && c.signature_valid, "{c:?}"); + std::fs::write(&f, b"conteudo do relatorio").unwrap(); + assert!(sign::verify_file(&f, None, None).unwrap().valid); + + // campos da assinatura alterados (hora, assinante, hash) invalidam + let raw = std::fs::read_to_string(&sig).unwrap(); + for (from, to) in [ + ("Perita Ana", "Outra Pessoa"), + ("\"assinado_em\": \"20", "\"assinado_em\": \"19"), + ] { + assert!(raw.contains(from), "{from}"); + std::fs::write(&sig, raw.replacen(from, to, 1)).unwrap(); + let c = sign::verify_file(&f, None, None).unwrap(); + assert!(!c.valid && !c.signature_valid, "{from}: {c:?}"); + } + // trocar a chave pública pela de outra pessoa também não passa + let mut j: serde_json::Value = serde_json::from_str(&raw).unwrap(); + let evil = SigningKey::generate(); + j["chave_publica"] = evil + .public_bytes() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + .into(); + std::fs::write(&sig, serde_json::to_vec(&j).unwrap()).unwrap(); + assert!(!sign::verify_file(&f, None, None).unwrap().valid); +} + +#[test] +fn recovery_folder_is_checked_file_by_file() { + let _g = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let k = keyed(dir.path()); + let src = dir.path().join("origem.img"); + std::fs::write(&src, b"x").unwrap(); + let fs = FileSource::open(&src).unwrap(); + let a = resident(1, "a.txt", b"primeiro arquivo".to_vec()); + let b = resident(2, "b.txt", b"segundo arquivo".to_vec()); + let out = dir.path().join("saida"); + let rep = recover::recover(&fs, &[&a, &b], &out, &|| false, &mut |_, _, _| {}).unwrap(); + assert_eq!(rep.recovered.len(), 2); + let manifest = out.join("guara-manifesto.json"); + sign::sign_file(&k, &manifest, None).unwrap(); + + let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap(); + assert!(c.valid && c.ok == 2 && c.files == 2, "{c:?}"); + + let first = out.join(&rep.recovered[0].path); + let second = out.join(&rep.recovered[1].path); + std::fs::write(&first, b"adulterado").unwrap(); + std::fs::remove_file(&second).unwrap(); + let c = sign::verify_recovery(&out, None).unwrap(); + assert!( + !c.valid && c.changed.len() == 1 && c.missing.len() == 1, + "{c:?}" + ); + + // manifesto adulterado (hash trocado para acompanhar o arquivo alterado) quebra a assinatura + let m = std::fs::read_to_string(&manifest).unwrap(); + let real = &rep.recovered[0].sha256; + let fake = sign::hash_file(&first).unwrap().0; + std::fs::write(&manifest, m.replace(real, &fake)).unwrap(); + let c = sign::verify_recovery(&out, None).unwrap(); + let sigcheck = c.manifest_signature.as_ref().unwrap(); + assert!(!sigcheck.valid && !sigcheck.file_matches, "{c:?}"); +} + +#[test] +fn manifest_paths_cannot_escape_the_folder() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("fora.txt"), b"segredo").unwrap(); + let out = dir.path().join("saida"); + std::fs::create_dir(&out).unwrap(); + let sha = sign::hash_file(&dir.path().join("fora.txt")).unwrap().0; + std::fs::write( + out.join("guara-manifesto.json"), + serde_json::json!({ "relatorio": { "recovered": [ { "path": "../fora.txt", "sha256": sha } ] } }).to_string(), + ) + .unwrap(); + let c = sign::verify_recovery(&out, None).unwrap(); + assert!(!c.valid && c.changed[0].contains("fora da pasta"), "{c:?}"); +} + +/// O mesmo que o `openssl` faria: a assinatura é Ed25519 padrão sobre a mensagem documentada. +#[test] +fn signature_verifies_with_openssl() { + let Ok(v) = std::process::Command::new("openssl") + .arg("version") + .output() + else { + return; + }; + if !v.status.success() { + return; + } + let dir = tempfile::tempdir().unwrap(); + let k = keyed(dir.path()); + let f = dir.path().join("doc.txt"); + std::fs::write(&f, b"hello\n").unwrap(); + let sig = sign::sign_file(&k, &f, Some("Ana")).unwrap(); + let j: serde_json::Value = serde_json::from_slice(&std::fs::read(sig).unwrap()).unwrap(); + let unhex = |s: &str| -> Vec { + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + }; + let msg = format!( + "{}\narquivo={}\ntamanho={}\nsha256={}\nassinado_em={}\nassinante=Ana\n", + j["formato"].as_str().unwrap(), + j["arquivo"].as_str().unwrap(), + j["tamanho"], + j["sha256"].as_str().unwrap(), + j["assinado_em"].as_str().unwrap() + ); + std::fs::write(dir.path().join("msg"), msg).unwrap(); + std::fs::write( + dir.path().join("sig"), + unhex(j["assinatura"].as_str().unwrap()), + ) + .unwrap(); + let mut der = unhex("302a300506032b6570032100"); + der.extend(unhex(j["chave_publica"].as_str().unwrap())); + std::fs::write(dir.path().join("pub.der"), der).unwrap(); + let r = std::process::Command::new("openssl") + .current_dir(dir.path()) + .args([ + "pkeyutl", "-verify", "-pubin", "-inkey", "pub.der", "-keyform", "DER", "-rawin", + "-in", "msg", "-sigfile", "sig", + ]) + .output() + .unwrap(); + // OpenSSL 1.1 não tem -rawin para Ed25519; só exige sucesso onde ele existe + let text = String::from_utf8_lossy(&r.stdout).to_string() + &String::from_utf8_lossy(&r.stderr); + if text.contains("Unknown option") || text.contains("unknown option") { + return; + } + assert!(r.status.success(), "{text}"); +} + +/// Com chave configurada, a recuperação e o relatório saem assinados sem ninguém pedir. +#[test] +fn configured_key_signs_manifest_automatically() { + let _g = ENV.lock().unwrap_or_else(|e| e.into_inner()); + let dir = tempfile::tempdir().unwrap(); + let k = keyed(dir.path()); + std::env::set_var("GUARA_SIGNING_KEY", dir.path().join("k.key")); + std::env::set_var("GUARA_CONFIG_DIR", dir.path().join("cfg")); + let src = dir.path().join("origem.img"); + std::fs::write(&src, b"x").unwrap(); + let fs = FileSource::open(&src).unwrap(); + let a = resident(1, "a.txt", b"dados".to_vec()); + let out = dir.path().join("saida"); + recover::recover(&fs, &[&a], &out, &|| false, &mut |_, _, _| {}).unwrap(); + std::env::remove_var("GUARA_SIGNING_KEY"); + let c = sign::verify_recovery(&out, Some(&k.fingerprint())).unwrap(); + assert!(c.valid && c.manifest_signature.is_some(), "{c:?}"); +} diff --git a/docs/architecture.md b/docs/architecture.md index 3ccb89e..cc84455 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,6 +43,7 @@ recover copies with MD5, SHA-1 and SHA-256 and writes the | `source`, `device` | Open devices and images read-only; list disks | | `image` | Raw image tolerant of bad sectors, with an error map | | `ewf`, `aff4` | Read and write E01/Ex01 and AFF4 | +| `sign` | Ed25519 signatures of files and recovery folders | | `audit` | Hash-chained audit trail of every examiner action | | `vdisk/` | Read QCOW2, VMDK, VHD, VHDX and DMG as the disk they contain | | `assemble` | Builds the virtual disk: image, RAID, LVM and encrypted volume | diff --git a/docs/cli.md b/docs/cli.md index 32b4689..51f5b04 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -20,6 +20,7 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu | `search ` | Text across the whole disk, UTF-8 and UTF-16 | | `export ` | Spreadsheet of the findings | | `report ` | Printable report, HTML or PDF (by the file extension) | +| `sign keygen\|file\|verify\|recovery` | Ed25519 signatures of manifests, reports and files; checks a whole recovery folder | | `audit` | Audit trail of every action (who, when, what); `--verify` checks the hash chain | | `sessions` | Automatically saved sessions | | `ui` | Interface in the browser (127.0.0.1, with token) | @@ -100,6 +101,17 @@ guara verify /mnt/other-disk/sdb.E01 For E01: `--segment-mib` (size of each segment, default 1500, 0 for a single file) and `--compression` (0 to 9, default 1). +## Digital signatures + +```bash +guara sign keygen # creates the key pair (never overwrites one) +guara sign file report.pdf # writes report.pdf.assinatura.json +guara sign verify report.pdf --trust 1a2b:3c4d:5e6f:7a8b:9c0d +guara sign recovery /mnt/other-disk/Recovered # manifest signature and the hash of every file +``` + +With a key configured, `recover` and `report` sign the manifest and the report on their own. The signature covers the SHA-256, size, name, time and signer; anyone can check it with Guará or with `openssl` (it is plain Ed25519 over the message documented in `crates/guara-core/src/sign.rs`). A valid signature proves the file did not change since it was signed **by whoever holds the key**; to know whose key it is, compare the fingerprint with the one the examiner published outside the machine (`--trust`). The private key lives in `assinatura.key` in the configuration folder (or where `GUARA_SIGNING_KEY` points), mode 0600; keep a copy and consider keeping it on an encrypted volume. The time is the signing machine's clock, not a third-party timestamp. + ## Audit trail Every action that touches evidence is appended to `auditoria.jsonl` in the configuration folder (or to the file named by `GUARA_AUDIT_LOG`): opening a source (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exports and sessions. Each line has the time (UTC), the system user (the one behind `sudo`), the host, the Guará version and the details; set `GUARA_EXAMINER` to also record the examiner's name. Passwords and keys are never written. diff --git a/docs/pt-BR/arquitetura.md b/docs/pt-BR/arquitetura.md index 2ba8541..40f00d9 100644 --- a/docs/pt-BR/arquitetura.md +++ b/docs/pt-BR/arquitetura.md @@ -43,6 +43,7 @@ recover copia com MD5, SHA-1 e SHA-256 e grava o manifesto | `source`, `device` | Abrir dispositivos e imagens somente para leitura; listar discos | | `image` | Imagem bruta tolerante a setores ruins, com mapa de erros | | `ewf`, `aff4` | Ler e gravar E01/Ex01 e AFF4 | +| `sign` | Assinaturas Ed25519 de arquivos e pastas de recuperação | | `audit` | Trilha de auditoria encadeada por hash de cada ação do perito | | `vdisk/` | Ler QCOW2, VMDK, VHD, VHDX e DMG como o disco que contêm | | `assemble` | Monta o disco virtual: imagem, RAID, LVM e volume cifrado | diff --git a/docs/pt-BR/cli.md b/docs/pt-BR/cli.md index 3e9d6c4..dc54e70 100644 --- a/docs/pt-BR/cli.md +++ b/docs/pt-BR/cli.md @@ -20,6 +20,7 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e | `search ` | Texto no disco inteiro, UTF-8 e UTF-16 | | `export ` | Planilha dos achados | | `report ` | Relatório imprimível, HTML ou PDF (pela extensão do arquivo) | +| `sign keygen\|file\|verify\|recovery` | Assinaturas Ed25519 de manifestos, relatórios e arquivos; confere uma pasta de recuperação inteira | | `audit` | Trilha de auditoria de cada ação (quem, quando, o quê); `--verify` confere o encadeamento por hash | | `sessions` | Sessões salvas automaticamente | | `ui` | Interface no navegador (127.0.0.1, com token) | @@ -100,6 +101,17 @@ guara verify /mnt/outro-disco/sdb.E01 Para E01: `--segment-mib` (tamanho de cada segmento, padrão 1500, 0 para um arquivo só) e `--compression` (0 a 9, padrão 1). +## Assinatura digital + +```bash +guara sign keygen # cria o par de chaves (nunca sobrescreve uma existente) +guara sign file relatorio.pdf # grava relatorio.pdf.assinatura.json +guara sign verify relatorio.pdf --trust 1a2b:3c4d:5e6f:7a8b:9c0d +guara sign recovery /mnt/outro-disco/Recuperados # assinatura do manifesto e o hash de cada arquivo +``` + +Com uma chave configurada, `recover` e `report` assinam sozinhos o manifesto e o relatório. A assinatura cobre o SHA-256, o tamanho, o nome, a hora e o assinante; qualquer pessoa confere com o Guará ou com o `openssl` (é Ed25519 comum sobre a mensagem documentada em `crates/guara-core/src/sign.rs`). Uma assinatura válida prova que o arquivo não mudou desde que foi assinado **por quem tem a chave**; para saber de quem é a chave, compare a impressão digital com a que o perito publicou fora da máquina (`--trust`). A chave privada fica em `assinatura.key` na pasta de configuração (ou onde `GUARA_SIGNING_KEY` apontar), com permissão 0600; guarde uma cópia e considere mantê-la num volume cifrado. A hora é a do relógio da máquina que assinou, não um carimbo de tempo de terceiros. + ## Trilha de auditoria Toda ação que mexe na evidência é acrescentada a `auditoria.jsonl` na pasta de configuração (ou ao arquivo indicado em `GUARA_AUDIT_LOG`): abrir uma origem (`inspect`, `scan`), `recover`, `image`, `hash`, `search`, `verify`, exportações e sessões. Cada linha traz a hora (UTC), o usuário do sistema (o que está por trás do `sudo`), a máquina, a versão do Guará e os detalhes; defina `GUARA_EXAMINER` para registrar também o nome do perito. Senhas e chaves nunca são gravadas. From 6ae4dbe9b01d13d508fc989c2cd810007d34ba2a Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 07:55:18 -0300 Subject: [PATCH 6/8] =?UTF-8?q?Depend=C3=AAncias:=20ctutils=200.4.5=20(RUS?= =?UTF-8?q?TSEC-2026-0385)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Comparação em tempo constante corrigida na 0.4.4; o job de auditoria falhava. Co-Authored-By: Claude Sonnet 5.5 --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f0725c0..0f6513e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -309,9 +309,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.3" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" +checksum = "38d28fb6aa7dd680b3ffd1a3e64e115c177bdba63c0ae7f9cd48129ff05c4fa0" dependencies = [ "cmov", ] From 4b18acc79207096e94ea0cf372ff3519cc85a5b9 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 08:00:24 -0300 Subject: [PATCH 7/8] =?UTF-8?q?CI:=20baixa=20as=20imagens=20de=20refer?= =?UTF-8?q?=C3=AAncia=20em=20Linux,=20macOS=20e=20Windows?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Os testes com imagens reais (BitLocker, FileVault 2, dfvfs, AFF4, discos virtuais) só rodavam na máquina de quem baixava as imagens; no CI eram pulados em silêncio. Agora o script de download roda nos três sistemas, com cache pela versão do script. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9e65dc2..c68079c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,14 @@ jobs: run: | sudo apt-get update sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs imagemagick zip + - name: Cache das imagens de referência + uses: actions/cache@v6 + with: + path: target/testdata + key: testdata-${{ runner.os }}-${{ hashFiles('scripts/fetch-testdata.sh') }} + - name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4) + shell: bash + run: scripts/fetch-testdata.sh - name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux) shell: bash run: | From 21d5fc4c7f942c6d5af96c8135c0399f99d1af86 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Fri, 23 Oct 2026 08:08:10 -0300 Subject: [PATCH 8/8] =?UTF-8?q?CI:=20nome=20do=20passo=20de=20testes=20ref?= =?UTF-8?q?lete=20as=20imagens=20de=20refer=C3=AAncia=20nos=20tr=C3=AAs=20?= =?UTF-8?q?sistemas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c68079c..d8a95f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,7 +55,7 @@ jobs: - name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4) shell: bash run: scripts/fetch-testdata.sh - - name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux) + - name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux) shell: bash run: | if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi