Mescla a main (CI com imagens de referência e o passo do XFS)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
ccbaa2d7e6
31 files changed
+1408
-95
No files matched your search
@@ -47,10 +47,18 @@ jobs:
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y mtools dosfstools exfatprogs ntfs-3g e2fsprogs xfsprogs imagemagick zip
|
||||
- name: Cache das imagens de referência
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: target/testdata
|
||||
key: testdata-${{ runner.os }}-${{ hashFiles('scripts/fetch-testdata.sh') }}
|
||||
- name: Imagens de referência (BitLocker, FileVault 2, dfvfs, AFF4)
|
||||
shell: bash
|
||||
run: scripts/fetch-testdata.sh
|
||||
- name: Imagem XFS com exclusão feita pelo kernel (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo scripts/make-xfs-real-deletion.sh
|
||||
- name: Testes (com imagens reais FAT32/NTFS/ext4 no Linux)
|
||||
- name: Testes (imagens de referência nos três sistemas; FAT32, NTFS e ext4 gerados no Linux)
|
||||
shell: bash
|
||||
run: |
|
||||
if [ "$RUNNER_OS" = "Linux" ]; then export GUARA_REQUIRE_FIXTURES=1; fi
|
||||
|
||||
Generated
+80
@@ -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",
|
||||
@@ -430,6 +442,7 @@ dependencies = [
|
||||
"jpeg-encoder",
|
||||
"libc",
|
||||
"lz4_flex",
|
||||
"lzfse_rust",
|
||||
"md-5",
|
||||
"mp4",
|
||||
"pbkdf2",
|
||||
@@ -552,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"
|
||||
@@ -582,6 +605,12 @@ version = "0.14.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecbdfe44b1bd960b68170b417450a628c43f7cf56bb3c5317e61cb230ee7f226"
|
||||
|
||||
[[package]]
|
||||
name = "lzfse_rust"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5edeac3ec657925dececc672bdd58fa2212291886ddcb3e08aa46924c78340c6"
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
@@ -844,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"
|
||||
@@ -1053,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 |
|
||||
|
||||
+5
-3
@@ -69,7 +69,8 @@ Today Guará opens volumes when you **already have** the password or key. Passwo
|
||||
| E01 and Ex01: read, write and `guara verify` | ✅ |
|
||||
| AFF4: read and write | ✅ |
|
||||
| Virtual disk containers: QCOW2, VMDK, VHD, VHDX and DMG (read) | ✅ |
|
||||
| QCOW2 with a backing file, VMDK snapshots, DMG with LZFSE or a password | 🔜 |
|
||||
| QCOW2 with a backing file, VMDK snapshots (by name or VirtualBox UUID), DMG with LZFSE | ✅ |
|
||||
| Password-protected DMG, parent identity check for VHD, VHDX and QCOW2 | 🔜 |
|
||||
| Mobile device image (see item 7) | 🔭 |
|
||||
|
||||
### 4. Assembling loose disks
|
||||
@@ -130,7 +131,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 +168,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 journal reading, APFS validation with real deletions, tests with real images on Windows and macOS.
|
||||
Trusted timestamps, XFS journal reading, APFS validation with real deletions.
|
||||
|
||||
**1.4: analysis**
|
||||
NSRL and hash lists, search in file content, unified timeline.
|
||||
|
||||
+5
-3
@@ -69,7 +69,8 @@ Hoje o Guará abre volumes quando você **já tem** a senha ou a chave. Recupera
|
||||
| E01 e Ex01: leitura, gravação e `guara verify` | ✅ |
|
||||
| AFF4: leitura e gravação | ✅ |
|
||||
| Contêineres de disco virtual: QCOW2, VMDK, VHD, VHDX e DMG (leitura) | ✅ |
|
||||
| QCOW2 com arquivo de apoio, snapshots de VMDK, DMG com LZFSE ou com senha | 🔜 |
|
||||
| QCOW2 com arquivo de apoio, snapshots de VMDK (por nome ou UUID do VirtualBox), DMG com LZFSE | ✅ |
|
||||
| DMG com senha, conferência da identidade do pai em VHD, VHDX e QCOW2 | 🔜 |
|
||||
| Imagem de dispositivo móvel (ver item 7) | 🔭 |
|
||||
|
||||
### 4. Montagem de discos soltos
|
||||
@@ -130,7 +131,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 +168,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, leitura do diário do XFS, validação de APFS com exclusões reais, testes com imagens reais no Windows e macOS.
|
||||
Carimbo de tempo de terceiros, leitura do diário do XFS, validação de APFS com exclusões reais.
|
||||
|
||||
**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,8 @@ 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]
|
||||
libc = "0.2"
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! DMG (Apple UDIF): rodapé `koly`, tabelas `mish` do plist e chunks zerados, brutos, ADC,
|
||||
//! zlib e bzip2. Imagens cifradas (`encrcdsa`) e chunks LZFSE (`ULFO`) são recusados com
|
||||
//! mensagem clara.
|
||||
//! zlib, bzip2 e LZFSE (`ULFO`). Imagens cifradas (`encrcdsa`) são recusadas com mensagem
|
||||
//! clara.
|
||||
|
||||
use super::{bad, be32, be64, read_exact, read_vec, Backend};
|
||||
use crate::source::{FileSource, Source};
|
||||
@@ -187,7 +187,8 @@ impl Dmg {
|
||||
bzip2::read::BzDecoder::new(&raw[..]).read_to_end(&mut out)?;
|
||||
}
|
||||
0x8000_0007 => {
|
||||
return Err(bad("chunk LZFSE (formato ULFO) ainda não é suportado"));
|
||||
lzfse_rust::decode_bytes(&raw, &mut out)
|
||||
.map_err(|e| bad(format!("chunk LZFSE inválido: {e}")))?;
|
||||
}
|
||||
t => return Err(bad(format!("tipo de chunk DMG 0x{t:08x} desconhecido"))),
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//!
|
||||
//! Cada formato é aberto como o disco que contém, somente para leitura, e entra na cadeia
|
||||
//! normal (partições, sistemas de arquivos, cifras) por meio de [`Source`]. Discos
|
||||
//! diferenciais (VHD e VHDX com pai) seguem a cadeia até o disco base; QCOW2 e VMDK com
|
||||
//! arquivo de apoio são recusados com mensagem clara, porque ler só a camada filha
|
||||
//! devolveria dados errados.
|
||||
//! diferenciais (VHD e VHDX com pai, QCOW2 com arquivo de apoio, VMDK com pai) seguem a
|
||||
//! cadeia até o disco base. A identidade do pai (UUID, CID, data) não é conferida: se o pai foi
|
||||
//! alterado depois do filho, os dados lidos refletem o pai atual.
|
||||
|
||||
mod dmg;
|
||||
mod qcow2;
|
||||
@@ -130,8 +130,8 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
||||
};
|
||||
let name = format!("{format} {}", path.display());
|
||||
let inner: Box<dyn Backend> = match format {
|
||||
"QCOW2" => Box::new(qcow2::Qcow2::open(file).map_err(|e| io_err(e, &name))?),
|
||||
"VMDK" => Box::new(vmdk::Vmdk::open(file, path).map_err(|e| io_err(e, &name))?),
|
||||
"QCOW2" => Box::new(qcow2::Qcow2::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VMDK" => Box::new(vmdk::Vmdk::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VHD" => Box::new(vhd::Vhd::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
"VHDX" => Box::new(vhdx::Vhdx::open(file, path, depth).map_err(|e| io_err(e, &name))?),
|
||||
_ => Box::new(dmg::Dmg::open(file).map_err(|e| io_err(e, &name))?),
|
||||
@@ -139,18 +139,24 @@ pub(crate) fn open_depth(path: &Path, depth: u32) -> Result<Option<VDisk>> {
|
||||
Ok(Some(VDisk { inner, format }))
|
||||
}
|
||||
|
||||
/// Abre o disco pai de um VHD/VHDX diferencial.
|
||||
/// Abre o disco pai (VHD/VHDX diferencial, QCOW2 com apoio, VMDK com pai). Com `raw`, o
|
||||
/// arquivo é tratado como disco bruto sem tentar reconhecer o formato.
|
||||
pub(crate) fn open_parent(
|
||||
candidates: &[std::path::PathBuf],
|
||||
depth: u32,
|
||||
raw: bool,
|
||||
) -> io::Result<Box<dyn Source>> {
|
||||
for p in candidates {
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
if !raw {
|
||||
if let Some(v) = open_depth(p, depth + 1).map_err(|e| bad(e.to_string()))? {
|
||||
return Ok(Box::new(v));
|
||||
}
|
||||
} else if depth > MAX_DEPTH {
|
||||
return Err(bad("cadeia de discos pai longa demais ou circular"));
|
||||
}
|
||||
let f = FileSource::open(p)?;
|
||||
return Ok(Box::new(f));
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd) e
|
||||
//! clusters zerados. Imagens cifradas, com arquivo de apoio, com arquivo de dados externo ou
|
||||
//! com L2 estendido são recusadas.
|
||||
//! QCOW2 (QEMU), versões 2 e 3: tabelas L1/L2, clusters comprimidos (deflate e zstd),
|
||||
//! clusters zerados e arquivo de apoio (backing file): o que o filho não alocou vem do pai.
|
||||
//! Imagens cifradas, com arquivo de dados externo, com L2 estendido ou com apoio que não é um
|
||||
//! arquivo local (`json:`, `nbd:`, URLs) são recusadas.
|
||||
|
||||
use super::{bad, be32, be64, check_table, read_exact, read_vec, Backend};
|
||||
use super::{
|
||||
bad, be32, be64, candidate_paths, check_table, open_parent, read_exact, read_parent, read_vec,
|
||||
Backend,
|
||||
};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
const OFFSET_MASK: u64 = 0x00ff_ffff_ffff_fe00;
|
||||
@@ -16,12 +21,13 @@ pub(super) struct Qcow2 {
|
||||
cluster_bits: u32,
|
||||
l1: Vec<u64>,
|
||||
zstd: bool,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
/// Último cluster comprimido decodificado: (deslocamento do cluster virtual, dados).
|
||||
cache: Mutex<Option<(u64, Vec<u8>)>>,
|
||||
}
|
||||
|
||||
impl Qcow2 {
|
||||
pub(super) fn open(file: FileSource) -> io::Result<Self> {
|
||||
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||
let h = read_vec(&file, 0, 112.min(file.size() as usize))?;
|
||||
if h.len() < 72 {
|
||||
return Err(bad("cabeçalho QCOW2 truncado"));
|
||||
@@ -30,11 +36,6 @@ impl Qcow2 {
|
||||
if version != 2 && version != 3 {
|
||||
return Err(bad(format!("versão QCOW {version} não suportada")));
|
||||
}
|
||||
if be64(&h, 8) != 0 {
|
||||
return Err(bad(
|
||||
"a imagem QCOW2 usa arquivo de apoio (backing file); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let cluster_bits = be32(&h, 20);
|
||||
if !(9..=21).contains(&cluster_bits) {
|
||||
return Err(bad(format!(
|
||||
@@ -63,6 +64,11 @@ impl Qcow2 {
|
||||
}
|
||||
}
|
||||
}
|
||||
let parent = if be64(&h, 8) != 0 {
|
||||
Some(Self::open_backing(&file, &h, version, path, depth)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let l1_size = be32(&h, 36) as u64;
|
||||
let l1_off = be64(&h, 40);
|
||||
let n = check_table(l1_size, 8, "tabela L1")?;
|
||||
@@ -74,10 +80,65 @@ impl Qcow2 {
|
||||
cluster_bits,
|
||||
l1,
|
||||
zstd,
|
||||
parent,
|
||||
cache: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
/// Abre o arquivo de apoio. O formato vem da extensão de cabeçalho `0xE2792ACA`; `raw`
|
||||
/// (ou ausente em v2) é lido sem reconhecer formato, como o QEMU faz.
|
||||
fn open_backing(
|
||||
file: &FileSource,
|
||||
h: &[u8],
|
||||
version: u32,
|
||||
path: &Path,
|
||||
depth: u32,
|
||||
) -> io::Result<Box<dyn Source>> {
|
||||
let (off, len) = (be64(h, 8), be32(h, 16) as usize);
|
||||
if len == 0 || len > 1023 {
|
||||
return Err(bad("nome do arquivo de apoio QCOW2 inválido"));
|
||||
}
|
||||
let name = String::from_utf8_lossy(&read_vec(file, off, len)?).into_owned();
|
||||
if name.starts_with("json:")
|
||||
|| name.contains("://")
|
||||
|| name.split(':').next().is_some_and(|p| {
|
||||
matches!(p, "nbd" | "ssh" | "rbd" | "sheepdog" | "gluster" | "iscsi")
|
||||
})
|
||||
{
|
||||
return Err(bad(format!(
|
||||
"o arquivo de apoio {name} não é um arquivo local; só caminhos de arquivo são suportados"
|
||||
)));
|
||||
}
|
||||
// extensões de cabeçalho: depois do cabeçalho, alinhadas a 8 bytes
|
||||
let hlen = if version == 3 && h.len() >= 104 {
|
||||
be32(h, 100) as u64
|
||||
} else {
|
||||
72
|
||||
};
|
||||
let mut raw = false;
|
||||
let mut pos = hlen.div_ceil(8) * 8;
|
||||
for _ in 0..64 {
|
||||
let mut eh = [0u8; 8];
|
||||
if read_exact(file, pos, &mut eh).is_err() {
|
||||
break;
|
||||
}
|
||||
let (kind, elen) = (be32(&eh, 0), be32(&eh, 4) as u64);
|
||||
if kind == 0 || elen > 1 << 20 {
|
||||
break;
|
||||
}
|
||||
if kind == 0xE279_2ACA {
|
||||
let fmt = read_vec(file, pos + 8, elen as usize)?;
|
||||
raw = fmt == b"raw";
|
||||
}
|
||||
pos += 8 + elen.div_ceil(8) * 8;
|
||||
}
|
||||
if version == 2 {
|
||||
raw = false;
|
||||
}
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
open_parent(&candidate_paths(dir, &[name]), depth, raw)
|
||||
}
|
||||
|
||||
fn cluster_size(&self) -> u64 {
|
||||
1 << self.cluster_bits
|
||||
}
|
||||
@@ -139,8 +200,10 @@ impl Backend for Qcow2 {
|
||||
return Ok(n);
|
||||
}
|
||||
let off = entry & OFFSET_MASK;
|
||||
if off == 0 || entry & 1 != 0 {
|
||||
buf[..n].fill(0);
|
||||
if entry & 1 != 0 {
|
||||
buf[..n].fill(0); // zerado de propósito: não consulta o pai
|
||||
} else if off == 0 {
|
||||
read_parent(&self.parent, voff, &mut buf[..n])?;
|
||||
} else {
|
||||
read_exact(&self.file, off + within, &mut buf[..n])?;
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ impl Vhd {
|
||||
}
|
||||
}
|
||||
names.push(utf16(&dh[64..64 + 512], true));
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -130,7 +130,7 @@ impl Vhdx {
|
||||
}
|
||||
}
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth)?)
|
||||
Some(open_parent(&candidate_paths(dir, &names), depth, false)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
//! VMDK (VMware, VirtualBox): extensões esparsas (inclusive `streamOptimized` com grãos
|
||||
//! comprimidos) e descritores de texto com extensões `FLAT`, `SPARSE` e `ZERO`, como
|
||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) e o formato SEsparse
|
||||
//! são recusados.
|
||||
//! `monolithicFlat` e `twoGbMaxExtentSparse`. Discos com pai (snapshots) leem o pai nos grãos
|
||||
//! que o filho não alocou; o formato SEsparse é recusado.
|
||||
|
||||
use super::{bad, check_table, le16, le32, le64, read_exact, read_vec, Backend};
|
||||
use super::{
|
||||
bad, candidate_paths, check_table, le16, le32, le64, open_parent, read_exact, read_parent,
|
||||
read_vec, Backend,
|
||||
};
|
||||
use crate::source::{FileSource, Source};
|
||||
use std::io::{self, Read};
|
||||
use std::path::Path;
|
||||
@@ -36,20 +39,73 @@ struct Extent {
|
||||
pub(super) struct Vmdk {
|
||||
extents: Vec<Extent>,
|
||||
size: u64,
|
||||
parent: Option<Box<dyn Source>>,
|
||||
}
|
||||
|
||||
fn parent_in(descriptor: &str) -> bool {
|
||||
descriptor.lines().any(|l| {
|
||||
let l = l.trim();
|
||||
l.strip_prefix("parentCID")
|
||||
/// Valor de uma chave `chave=valor` do descritor, sem aspas.
|
||||
fn desc_value(descriptor: &str, key: &str) -> Option<String> {
|
||||
descriptor.lines().find_map(|l| {
|
||||
l.trim()
|
||||
.strip_prefix(key)
|
||||
.and_then(|r| r.trim_start().strip_prefix('='))
|
||||
.is_some_and(|v| {
|
||||
let v = v.trim().trim_matches('"');
|
||||
!v.is_empty() && !v.eq_ignore_ascii_case("ffffffff")
|
||||
})
|
||||
.map(|v| v.trim().trim_matches('"').to_string())
|
||||
})
|
||||
}
|
||||
|
||||
/// Descritor (texto) de um arquivo VMDK: o próprio arquivo, se for descritor, ou o embutido
|
||||
/// logo depois do cabeçalho de uma extensão esparsa.
|
||||
fn descriptor_of(path: &Path) -> Option<String> {
|
||||
let f = FileSource::open(path).ok()?;
|
||||
let head = read_vec(&f, 0, (f.size() as usize).min(24 * 512)).ok()?;
|
||||
if head.starts_with(b"KDMV") {
|
||||
let off = (le64(&head, 28) * SECTOR) as usize;
|
||||
let len = (le64(&head, 36) * SECTOR) as usize;
|
||||
let d = head.get(off..(off + len).min(head.len()))?;
|
||||
let end = d.iter().position(|&b| b == 0).unwrap_or(d.len());
|
||||
return Some(String::from_utf8_lossy(&d[..end]).into_owned());
|
||||
}
|
||||
head.starts_with(b"# Disk Desc")
|
||||
.then(|| String::from_utf8_lossy(&head).into_owned())
|
||||
}
|
||||
|
||||
/// Como o descritor identifica o pai: pelo nome (`parentFileNameHint`) e/ou, no VirtualBox, só
|
||||
/// pelo UUID (`ddb.uuid.parent`); `cid` é o `parentCID` que o pai precisa ter.
|
||||
struct ParentRef {
|
||||
name: Option<String>,
|
||||
uuid: Option<String>,
|
||||
cid: Option<String>,
|
||||
}
|
||||
|
||||
fn parent_ref(descriptor: &str) -> Option<ParentRef> {
|
||||
let cid = desc_value(descriptor, "parentCID")
|
||||
.filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("ffffffff"));
|
||||
let uuid = desc_value(descriptor, "ddb.uuid.parent")
|
||||
.filter(|u| !u.is_empty() && u.chars().any(|c| c != '0' && c != '-'));
|
||||
let name = desc_value(descriptor, "parentFileNameHint").filter(|n| !n.is_empty());
|
||||
(cid.is_some() || uuid.is_some()).then_some(ParentRef { name, uuid, cid })
|
||||
}
|
||||
|
||||
/// Procura, na pasta do filho e nas duas acima, um VMDK cujo `ddb.uuid.image` seja `uuid`.
|
||||
fn find_by_uuid(dir: &Path, uuid: &str) -> Option<std::path::PathBuf> {
|
||||
let mut d = Some(dir);
|
||||
for _ in 0..3 {
|
||||
let cur = d?;
|
||||
for e in std::fs::read_dir(cur).ok()?.flatten() {
|
||||
let p = e.path();
|
||||
if p.extension()
|
||||
.is_some_and(|x| x.eq_ignore_ascii_case("vmdk"))
|
||||
&& descriptor_of(&p)
|
||||
.and_then(|ds| desc_value(&ds, "ddb.uuid.image"))
|
||||
.is_some_and(|u| u.eq_ignore_ascii_case(uuid))
|
||||
{
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
d = cur.parent();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl Sparse {
|
||||
fn open(file: FileSource) -> io::Result<(Self, u64, Option<String>)> {
|
||||
let mut h = read_vec(&file, 0, SECTOR as usize)?;
|
||||
@@ -95,11 +151,6 @@ impl Sparse {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if descriptor.as_deref().is_some_and(parent_in) {
|
||||
return Err(bad(
|
||||
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let gde = capacity.div_ceil(gtes * grain);
|
||||
let n = check_table(gde, 4, "diretório de grãos")?;
|
||||
let raw = read_vec(&file, gd_off * SECTOR, n * 4)?;
|
||||
@@ -118,7 +169,9 @@ impl Sparse {
|
||||
))
|
||||
}
|
||||
|
||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<usize> {
|
||||
/// Lê dentro de um grão. O `bool` é `false` quando o grão não está alocado neste arquivo
|
||||
/// (o chamador completa com o pai); grão zerado de propósito devolve zeros.
|
||||
fn read(&self, off: u64, buf: &mut [u8]) -> io::Result<(usize, bool)> {
|
||||
let g = off / self.grain;
|
||||
let within = off % self.grain;
|
||||
let n = buf.len().min((self.grain - within) as usize);
|
||||
@@ -129,13 +182,16 @@ impl Sparse {
|
||||
read_exact(&self.file, gt_sector * SECTOR + (g % self.gtes) * 4, &mut b)?;
|
||||
sector = u32::from_le_bytes(b) as u64;
|
||||
}
|
||||
if sector <= 1 {
|
||||
if sector == 0 {
|
||||
return Ok((n, false));
|
||||
}
|
||||
if sector == 1 {
|
||||
buf[..n].fill(0);
|
||||
return Ok(n);
|
||||
return Ok((n, true));
|
||||
}
|
||||
if !self.compressed {
|
||||
read_exact(&self.file, sector * SECTOR + within, &mut buf[..n])?;
|
||||
return Ok(n);
|
||||
return Ok((n, true));
|
||||
}
|
||||
let mut cache = self.cache.lock().unwrap();
|
||||
if cache.as_ref().map(|c| c.0) != Some(g) {
|
||||
@@ -153,17 +209,12 @@ impl Sparse {
|
||||
}
|
||||
let data = &cache.as_ref().unwrap().1;
|
||||
buf[..n].copy_from_slice(&data[within as usize..within as usize + n]);
|
||||
Ok(n)
|
||||
Ok((n, true))
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensões de um descritor de texto: `RW 2048 FLAT "disco-f001.vmdk" 0`.
|
||||
fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
||||
if parent_in(text) {
|
||||
return Err(bad(
|
||||
"VMDK com disco pai (snapshot); abrir só a camada filha daria dados errados",
|
||||
));
|
||||
}
|
||||
let mut out = vec![];
|
||||
let mut start = 0u64;
|
||||
for line in text.lines() {
|
||||
@@ -216,27 +267,79 @@ fn parse_descriptor(text: &str, dir: &Path) -> io::Result<Vec<Extent>> {
|
||||
}
|
||||
|
||||
impl Vmdk {
|
||||
pub(super) fn open(file: FileSource, path: &Path) -> io::Result<Self> {
|
||||
fn open_parent_of(r: &ParentRef, dir: &Path, depth: u32) -> io::Result<Box<dyn Source>> {
|
||||
let mut found = r
|
||||
.name
|
||||
.as_ref()
|
||||
.map(|n| candidate_paths(dir, std::slice::from_ref(n)))
|
||||
.unwrap_or_default();
|
||||
let by_name = found.iter().any(|p| p.is_file());
|
||||
if !by_name {
|
||||
if let Some(p) = r.uuid.as_deref().and_then(|u| find_by_uuid(dir, u)) {
|
||||
found = vec![p];
|
||||
}
|
||||
}
|
||||
if r.name.is_none() && r.uuid.is_none() {
|
||||
return Err(bad(
|
||||
"VMDK com pai, mas o descritor não diz qual (sem parentFileNameHint nem ddb.uuid.parent)",
|
||||
));
|
||||
}
|
||||
if r.name.is_none() && found.is_empty() {
|
||||
return Err(bad(
|
||||
"VMDK com pai identificado só por UUID (VirtualBox) e o pai não está na pasta do filho nem nas duas acima",
|
||||
));
|
||||
}
|
||||
if let Some(cid) = &r.cid {
|
||||
// o pai precisa ser o mesmo de quando o filho foi criado
|
||||
if let Some(p) = found.iter().find(|p| p.is_file()) {
|
||||
let have = descriptor_of(p).and_then(|d| desc_value(&d, "CID"));
|
||||
if have
|
||||
.as_deref()
|
||||
.is_some_and(|h| !h.eq_ignore_ascii_case(cid))
|
||||
{
|
||||
return Err(bad(format!(
|
||||
"o pai {} foi alterado depois do filho (CID {} em vez de {cid}); os dados do filho não valem mais",
|
||||
p.display(),
|
||||
have.unwrap_or_default()
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
open_parent(&found, depth, false)
|
||||
}
|
||||
|
||||
pub(super) fn open(file: FileSource, path: &Path, depth: u32) -> io::Result<Self> {
|
||||
let mut head = [0u8; 4];
|
||||
let _ = file.read_at(0, &mut head);
|
||||
let extents = if &head == b"KDMV" {
|
||||
let (sp, size, _) = Sparse::open(file)?;
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
let (extents, descriptor) = if &head == b"KDMV" {
|
||||
let (sp, size, descriptor) = Sparse::open(file)?;
|
||||
(
|
||||
vec![Extent {
|
||||
start: 0,
|
||||
len: size,
|
||||
kind: Kind::Sparse(sp),
|
||||
}]
|
||||
}],
|
||||
descriptor,
|
||||
)
|
||||
} else {
|
||||
if file.size() > 1 << 20 {
|
||||
return Err(bad("descritor VMDK grande demais"));
|
||||
}
|
||||
let raw = read_vec(&file, 0, file.size() as usize)?;
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let dir = path.parent().unwrap_or(Path::new("."));
|
||||
parse_descriptor(&text, dir)?
|
||||
let text = String::from_utf8_lossy(&raw).into_owned();
|
||||
(parse_descriptor(&text, dir)?, Some(text))
|
||||
};
|
||||
let parent = match descriptor.as_deref().and_then(parent_ref) {
|
||||
Some(r) => Some(Self::open_parent_of(&r, dir, depth)?),
|
||||
None => None,
|
||||
};
|
||||
let size = extents.iter().map(|e| e.len).sum();
|
||||
Ok(Self { extents, size })
|
||||
Ok(Self {
|
||||
extents,
|
||||
size,
|
||||
parent,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -261,7 +364,13 @@ impl Backend for Vmdk {
|
||||
read_exact(file, offset + rel, &mut buf[..n])?;
|
||||
Ok(n)
|
||||
}
|
||||
Kind::Sparse(s) => s.read(rel, &mut buf[..n]),
|
||||
Kind::Sparse(s) => {
|
||||
let (got, allocated) = s.read(rel, &mut buf[..n])?;
|
||||
if !allocated {
|
||||
read_parent(&self.parent, voff, &mut buf[..got])?;
|
||||
}
|
||||
Ok(got)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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:?}");
|
||||
}
|
||||
@@ -281,22 +281,82 @@ fn qcow2_with_zero_raw_and_compressed_clusters() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qcow2_with_backing_file_or_encryption_is_refused() {
|
||||
fn qcow2_with_encryption_or_a_remote_backing_file_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut img = build_qcow2(&raw());
|
||||
let p = dir.path().join("b.qcow2");
|
||||
be(&mut img, 8, 4096, 8);
|
||||
be(&mut img, 16, 3, 4);
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("arquivo de apoio"), "{e}");
|
||||
|
||||
let mut img = build_qcow2(&raw());
|
||||
be(&mut img, 32, 2, 4);
|
||||
let p = dir.path().join("e.qcow2");
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("LUKS"), "{e}");
|
||||
|
||||
let mut img = build_qcow2(&raw());
|
||||
let name = b"nbd://servidor/disco";
|
||||
img[512..512 + name.len()].copy_from_slice(name);
|
||||
be(&mut img, 8, 512, 8);
|
||||
be(&mut img, 16, name.len() as u64, 4);
|
||||
let p = dir.path().join("r.qcow2");
|
||||
std::fs::write(&p, &img).unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("não é um arquivo local"), "{e}");
|
||||
}
|
||||
|
||||
/// Filho QCOW2 que só aloca o cluster 1 (dados), marca o 2 como zerado e deixa o resto para o pai.
|
||||
fn build_qcow2_child(backing: &str, fmt: Option<&str>, own: &[u8]) -> Vec<u8> {
|
||||
const CS: usize = 1 << 16;
|
||||
let mut img = vec![0u8; 3 * CS];
|
||||
img[..4].copy_from_slice(b"QFI\xfb");
|
||||
be(&mut img, 4, 3, 4);
|
||||
be(&mut img, 16, backing.len() as u64, 4);
|
||||
be(&mut img, 8, 1024, 8);
|
||||
img[1024..1024 + backing.len()].copy_from_slice(backing.as_bytes());
|
||||
be(&mut img, 20, 16, 4);
|
||||
be(&mut img, 24, 16 * CS as u64, 8);
|
||||
be(&mut img, 36, 1, 4);
|
||||
be(&mut img, 40, CS as u64, 8);
|
||||
be(&mut img, 100, 112, 4);
|
||||
if let Some(f) = fmt {
|
||||
// extensão 0xE2792ACA com o nome do formato do apoio, depois o fim das extensões
|
||||
be(&mut img, 112, 0xE279_2ACA, 4);
|
||||
be(&mut img, 116, f.len() as u64, 4);
|
||||
img[120..120 + f.len()].copy_from_slice(f.as_bytes());
|
||||
}
|
||||
be(&mut img, CS, (2 * CS as u64) | (1 << 63), 8);
|
||||
let host = img.len() as u64;
|
||||
img.extend_from_slice(own);
|
||||
img.resize(img.len().div_ceil(CS) * CS, 0);
|
||||
be(&mut img, 2 * CS + 8, host | (1 << 63), 8); // cluster 1
|
||||
be(&mut img, 2 * CS + 16, 1, 8); // cluster 2: zerado de propósito
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn qcow2_backing_file_supplies_what_the_child_did_not_allocate() {
|
||||
const CS: usize = 1 << 16;
|
||||
let base = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("base.raw"), &base).unwrap();
|
||||
let own = vec![0x42u8; CS];
|
||||
let mut expect = base.clone();
|
||||
expect[CS..2 * CS].copy_from_slice(&own);
|
||||
expect[2 * CS..3 * CS].fill(0);
|
||||
for (name, fmt) in [("a.qcow2", Some("raw")), ("b.qcow2", None)] {
|
||||
let p = dir.path().join(name);
|
||||
std::fs::write(&p, build_qcow2_child("base.raw", fmt, &own)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
// com o pai em outro formato: o filho aponta para um QCOW2
|
||||
std::fs::write(dir.path().join("base.qcow2"), build_qcow2(&base)).unwrap();
|
||||
let p = dir.path().join("c.qcow2");
|
||||
std::fs::write(&p, build_qcow2_child("base.qcow2", Some("qcow2"), &own)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
// sem o pai, a abertura diz o que falta
|
||||
std::fs::remove_file(dir.path().join("base.raw")).unwrap();
|
||||
let e = open_path(&dir.path().join("a.qcow2"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("disco pai não encontrado"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VMDK ----------
|
||||
@@ -324,8 +384,114 @@ fn vmdk_descriptor_with_flat_and_zero_extents() {
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
/// VMDK esparso feito à mão: 16 grãos de 64 KiB; `grains[i]` = `None` (não alocado, vem do pai),
|
||||
/// `Some(None)` (zerado de propósito) ou `Some(Some(dados))`.
|
||||
fn build_vmdk_sparse(descriptor: &str, grains: &[Option<Option<Vec<u8>>>]) -> Vec<u8> {
|
||||
const GRAIN: usize = 128; // setores
|
||||
let mut img = vec![0u8; 26 * 512];
|
||||
img[..4].copy_from_slice(b"KDMV");
|
||||
le(&mut img, 4, 1, 4);
|
||||
le(&mut img, 8, 3, 4);
|
||||
le(&mut img, 12, (grains.len() * GRAIN) as u64, 8);
|
||||
le(&mut img, 20, GRAIN as u64, 8);
|
||||
le(&mut img, 28, 1, 8);
|
||||
le(&mut img, 36, 20, 8);
|
||||
le(&mut img, 44, 512, 4);
|
||||
le(&mut img, 56, 21, 8);
|
||||
le(&mut img, 64, 26, 8);
|
||||
img[512..512 + descriptor.len()].copy_from_slice(descriptor.as_bytes());
|
||||
le(&mut img, 21 * 512, 22, 4); // diretório de grãos -> tabela no setor 22
|
||||
for (i, g) in grains.iter().enumerate() {
|
||||
let entry = match g {
|
||||
None => 0,
|
||||
Some(None) => 1,
|
||||
Some(Some(d)) => {
|
||||
let sector = (img.len() / 512) as u64;
|
||||
let mut d = d.clone();
|
||||
d.resize(GRAIN * 512, 0);
|
||||
img.extend_from_slice(&d);
|
||||
sector
|
||||
}
|
||||
};
|
||||
le(&mut img, 22 * 512 + i * 4, entry, 4);
|
||||
}
|
||||
img
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_with_a_parent_is_refused() {
|
||||
fn vmdk_child_reads_the_parent_for_unallocated_grains() {
|
||||
let base = raw();
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("base.vmdk"), build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=11111111\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
|
||||
&(0..16)
|
||||
.map(|i| Some(Some(base[i * 65536..(i + 1) * 65536].to_vec())))
|
||||
.collect::<Vec<_>>(),
|
||||
))
|
||||
.unwrap();
|
||||
let own = vec![0x37u8; 65536];
|
||||
let mut grains: Vec<Option<Option<Vec<u8>>>> = vec![None; 16];
|
||||
grains[3] = Some(Some(own.clone()));
|
||||
grains[5] = Some(None); // zerado de propósito: não consulta o pai
|
||||
let child = build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=22222222\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
|
||||
&grains,
|
||||
);
|
||||
let p = dir.path().join("child.vmdk");
|
||||
std::fs::write(&p, child).unwrap();
|
||||
let mut expect = base.clone();
|
||||
expect[3 * 65536..4 * 65536].copy_from_slice(&own);
|
||||
expect[5 * 65536..6 * 65536].fill(0);
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
/// Snapshot real do VirtualBox: o filho só diz o UUID do pai (`ddb.uuid.parent`), sem nome.
|
||||
#[test]
|
||||
fn virtualbox_snapshot_vmdk_finds_its_parent_by_uuid() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let sub = dir.path().join("Snapshots");
|
||||
std::fs::create_dir(&sub).unwrap();
|
||||
// o pai fica na pasta de cima, como o VirtualBox organiza a máquina
|
||||
std::fs::copy(fx("disk.vmdk"), dir.path().join("disco-base.vmdk")).unwrap();
|
||||
std::fs::copy(fx("vbox-snapshot.vmdk"), sub.join("filho.vmdk")).unwrap();
|
||||
check_content(&sub.join("filho.vmdk"), &raw());
|
||||
// sem o pai por perto, a abertura explica
|
||||
std::fs::remove_file(dir.path().join("disco-base.vmdk")).unwrap();
|
||||
let e = open_path(&sub.join("filho.vmdk"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("identificado só por UUID"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_child_is_refused_when_the_parent_changed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("base.vmdk"),
|
||||
build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=aaaaaaaa\nparentCID=ffffffff\ncreateType=\"monolithicSparse\"\n",
|
||||
&vec![None; 16],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("filho.vmdk"),
|
||||
build_vmdk_sparse(
|
||||
"# Disk DescriptorFile\nversion=1\nCID=bbbbbbbb\nparentCID=11111111\ncreateType=\"monolithicSparse\"\nparentFileNameHint=\"base.vmdk\"\n",
|
||||
&vec![None; 16],
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let e = open_path(&dir.path().join("filho.vmdk"))
|
||||
.err()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert!(e.contains("foi alterado depois do filho"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vmdk_with_a_parent_but_no_name_is_refused() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("c.vmdk");
|
||||
std::fs::write(
|
||||
@@ -334,7 +500,7 @@ fn vmdk_with_a_parent_is_refused() {
|
||||
)
|
||||
.unwrap();
|
||||
let e = open_path(&p).err().expect("deveria recusar").to_string();
|
||||
assert!(e.contains("disco pai"), "{e}");
|
||||
assert!(e.contains("não diz qual"), "{e}");
|
||||
}
|
||||
|
||||
// ---------- VHD ----------
|
||||
@@ -619,8 +785,36 @@ fn dmg_with_every_chunk_type() {
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
/// Chunks `ULFO` com dados compactados pelo compressor de referência da Apple (`lzfse`):
|
||||
/// um bloco `bvx2` (FSE, 64 KiB) e um pequeno (`bvxn`/LZVN).
|
||||
#[test]
|
||||
fn dmg_lzfse_and_encrypted_are_refused_with_a_reason() {
|
||||
fn dmg_lzfse_chunks_from_the_reference_compressor() {
|
||||
let big = std::fs::read(fx("lzfse-64k.bin")).unwrap();
|
||||
let small = std::fs::read(fx("lzfse-small.bin")).unwrap();
|
||||
let mut small_padded = small.clone();
|
||||
small_padded.resize(small.len().div_ceil(512) * 512, 0);
|
||||
let chunks = [
|
||||
DmgChunk {
|
||||
ty: 0x8000_0007,
|
||||
sectors: (big.len() / 512) as u64,
|
||||
payload: std::fs::read(fx("lzfse-64k.bin.lzfse")).unwrap(),
|
||||
},
|
||||
DmgChunk {
|
||||
ty: 0x8000_0007,
|
||||
sectors: (small_padded.len() / 512) as u64,
|
||||
payload: std::fs::read(fx("lzfse-small.bin.lzfse")).unwrap(),
|
||||
},
|
||||
];
|
||||
let mut expect = big.clone();
|
||||
expect.extend_from_slice(&small_padded);
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("l.dmg");
|
||||
std::fs::write(&p, build_dmg(&chunks, expect.len() as u64 / 512)).unwrap();
|
||||
check_content(&p, &expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dmg_with_a_corrupt_lzfse_chunk_and_encrypted_are_refused_with_a_reason() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("l.dmg");
|
||||
let chunks = [DmgChunk {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -55,12 +55,12 @@ As senhas **nunca** vão para o arquivo de sessão. Com `--remember-keys` a sess
|
||||
| Bruta (dd, `.img`) | ✅ | ✅ com mapa de erros de setores ruins |
|
||||
| E01 e Ex01 (Expert Witness) | ✅ | ✅ com metadados do caso e segmentação |
|
||||
| AFF4 | ✅ | ✅ |
|
||||
| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) | ❌ |
|
||||
| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized` e descritores com extensões flat/zero | ❌ |
|
||||
| QCOW2 (QEMU), versões 2 e 3 | ✅ inclusive clusters comprimidos (deflate, zstd) e arquivo de apoio | ❌ |
|
||||
| VMDK (VMware, VirtualBox) | ✅ esparso, `streamOptimized`, descritores com extensões flat/zero e snapshots (pai por nome ou, no VirtualBox, por UUID) | ❌ |
|
||||
| VHD e VHDX (Hyper-V) | ✅ fixo, dinâmico e diferencial (segue a cadeia de pais) | ❌ |
|
||||
| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib e bzip2 | ❌ |
|
||||
| DMG (Apple UDIF) | ✅ chunks zerados, brutos, ADC, zlib, bzip2 e LZFSE | ❌ |
|
||||
|
||||
Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 com arquivo de apoio ou cifrado, VMDK com pai (snapshot) ou em SEsparse, DMG protegido por senha e DMG com chunks LZFSE. O log de transações do VHDX não é reproduzido (nada é gravado na imagem).
|
||||
Os contêineres de disco virtual são abertos como o disco que contêm, sem conversão. Imagens que não podem ser lidas corretamente são recusadas com o motivo: QCOW2 cifrado, QCOW2 cujo apoio não é um arquivo local, VMDK em SEsparse e DMG protegido por senha. Discos diferenciais (VHD, VHDX, QCOW2 com apoio, snapshot de VMDK) leem o pai no que o filho não gravou; o pai é procurado ao lado do filho (VMDK: por nome ou UUID) e, no VMDK, o CID dele precisa bater, senão a imagem é recusada porque o pai mudou. A identidade dos pais de VHD, VHDX e QCOW2 não é conferida. O log de transações do VHDX não é reproduzido (nada é gravado na imagem).
|
||||
|
||||
`guara verify` recalcula o MD5 de uma imagem E01 ou Ex01 e compara com o gravado nela.
|
||||
|
||||
|
||||
+4
-4
@@ -55,12 +55,12 @@ Passwords are **never** written to the session file. With `--remember-keys` the
|
||||
| Raw (dd, `.img`) | ✅ | ✅ with a bad sector error map |
|
||||
| E01 and Ex01 (Expert Witness) | ✅ | ✅ with case metadata and segmentation |
|
||||
| AFF4 | ✅ | ✅ |
|
||||
| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) | ❌ |
|
||||
| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized` and descriptors with flat/zero extents | ❌ |
|
||||
| QCOW2 (QEMU), versions 2 and 3 | ✅ including compressed clusters (deflate, zstd) and backing files | ❌ |
|
||||
| VMDK (VMware, VirtualBox) | ✅ sparse, `streamOptimized`, descriptors with flat/zero extents and snapshots (parent by name or, for VirtualBox, by UUID) | ❌ |
|
||||
| VHD and VHDX (Hyper-V) | ✅ fixed, dynamic and differencing (follows the parent chain) | ❌ |
|
||||
| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib and bzip2 chunks | ❌ |
|
||||
| DMG (Apple UDIF) | ✅ zero, raw, ADC, zlib, bzip2 and LZFSE chunks | ❌ |
|
||||
|
||||
Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: QCOW2 with a backing file or encryption, VMDK with a parent (snapshot) or in SEsparse, password-protected DMG and DMG with LZFSE chunks. The VHDX transaction log is not replayed (nothing is ever written to the image).
|
||||
Virtual disk containers are opened as the disk they hold, with no conversion. Images that cannot be read correctly are refused with the reason: encrypted QCOW2, QCOW2 whose backing file is not a local file, VMDK in SEsparse, and password-protected DMG. Differencing disks (VHD, VHDX, QCOW2 backing file, VMDK snapshot) read the parent for what the child did not write; the parent is found next to the child (VMDK: by name or UUID) and, for VMDK, its CID must match, otherwise the image is refused because the parent changed. The identity of VHD, VHDX and QCOW2 parents is not checked. The VHDX transaction log is not replayed (nothing is ever written to the image).
|
||||
|
||||
`guara verify` recomputes the MD5 of an E01 or Ex01 image and compares it with the one stored in it.
|
||||
|
||||
|
||||
Reference in new issue
Block a user