diff --git a/Cargo.lock b/Cargo.lock index b0be34d..6ad895b 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", @@ -553,6 +565,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" @@ -851,6 +873,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" @@ -1060,6 +1088,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 4824956..6188bd6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,7 +130,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 | 🔭 | @@ -166,7 +167,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 7899fba..e50b96c 100644 --- a/ROADMAP.pt-BR.md +++ b/ROADMAP.pt-BR.md @@ -130,7 +130,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 | 🔭 | @@ -166,7 +167,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 e3b6b12..f31172b 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"] } lzfse_rust = "0.2.1" [target.'cfg(unix)'.dependencies] 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.