Merge pull request #40 from lucasrguerra/feat/signatures
Assinatura digital Ed25519 (guara sign)
This commit is contained in:
16 files changed
+936
-14
No files matched your search
Generated
+73
@@ -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"
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+3
-3
@@ -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 |
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
+3
-2
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
},
|
||||
/// Assina um arquivo e grava <arquivo>.assinatura.json
|
||||
File {
|
||||
path: String,
|
||||
#[arg(long)]
|
||||
key: Option<String>,
|
||||
/// Nome de quem assina (padrão: GUARA_EXAMINER)
|
||||
#[arg(long)]
|
||||
signer: Option<String>,
|
||||
},
|
||||
/// Confere a assinatura de um arquivo
|
||||
Verify {
|
||||
path: String,
|
||||
/// Arquivo de assinatura (padrão: <arquivo>.assinatura.json)
|
||||
#[arg(long)]
|
||||
sig: Option<String>,
|
||||
/// Impressão digital esperada da chave, publicada pelo perito
|
||||
#[arg(long)]
|
||||
trust: Option<String>,
|
||||
#[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<String>,
|
||||
#[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<String>, verify: bool, tail: Option<usize>, 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<String>| 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<Value, String> {
|
||||
let interrupted = Arc::new(AtomicBool::new(false));
|
||||
let flag = interrupted.clone();
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 `<arquivo>.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<Vec<u8>> {
|
||||
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::<Vec<_>>()
|
||||
.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::<String>()
|
||||
};
|
||||
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<Self> {
|
||||
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<String>,
|
||||
pub chave_publica: String,
|
||||
pub impressao_digital: String,
|
||||
pub assinatura: String,
|
||||
}
|
||||
|
||||
fn message(f: &SignatureFile) -> Vec<u8> {
|
||||
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 `<path>.assinatura.json`. Devolve o caminho da assinatura.
|
||||
pub fn sign_file(key: &SigningKey, path: &Path, signer: Option<&str>) -> Result<PathBuf> {
|
||||
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<String>,
|
||||
/// `Some(true/false)` quando uma impressão digital de confiança foi informada.
|
||||
pub trusted: Option<bool>,
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn verify_file(
|
||||
path: &Path,
|
||||
sig_path: Option<&Path>,
|
||||
trusted_fingerprint: Option<&str>,
|
||||
) -> Result<Check> {
|
||||
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<Check>,
|
||||
pub files: usize,
|
||||
pub ok: usize,
|
||||
pub missing: Vec<String>,
|
||||
pub changed: Vec<String>,
|
||||
/// `true` quando tudo confere (e, havendo assinatura, ela vale).
|
||||
pub valid: bool,
|
||||
}
|
||||
|
||||
pub fn verify_recovery(dir: &Path, trusted_fingerprint: Option<&str>) -> Result<RecoveryCheck> {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
@@ -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<u8>) -> 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::<String>()
|
||||
.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<u8> {
|
||||
(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:?}");
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
+12
@@ -20,6 +20,7 @@ Reading a raw disk requires privileges: administrator on Windows, `sudo` on Linu
|
||||
| `search <source> <terms...>` | Text across the whole disk, UTF-8 and UTF-16 |
|
||||
| `export <session> <csv>` | Spreadsheet of the findings |
|
||||
| `report <session> <file>` | 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.
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -20,6 +20,7 @@ Ler disco bruto exige privilégios: administrador no Windows, `sudo` no Linux e
|
||||
| `search <origem> <termos...>` | Texto no disco inteiro, UTF-8 e UTF-16 |
|
||||
| `export <sessão> <csv>` | Planilha dos achados |
|
||||
| `report <sessão> <arquivo>` | 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.
|
||||
|
||||
Reference in new issue
Block a user