From b02bbcb7d66939bc53776223bafe4ff0e3b7c0f1 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Mon, 19 Oct 2026 00:08:47 -0300 Subject: [PATCH] =?UTF-8?q?CLI:=20senhas,=20chaves,=20membros=20de=20RAID,?= =?UTF-8?q?=20refer=C3=AAncias=20de=20v=C3=ADdeo,=20imagem=20E01=20e=20ver?= =?UTF-8?q?ify?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- crates/guara-cli/Cargo.toml | 1 + crates/guara-cli/src/main.rs | 185 ++++++++++++++++++++++++++++- crates/guara-core/src/assemble.rs | 6 +- crates/guara-core/src/image.rs | 63 ++++++++++ crates/guara-core/src/partition.rs | 2 +- crates/guara-core/src/service.rs | 57 ++++++++- 6 files changed, 300 insertions(+), 14 deletions(-) diff --git a/crates/guara-cli/Cargo.toml b/crates/guara-cli/Cargo.toml index 0c70941..4ae170f 100644 --- a/crates/guara-cli/Cargo.toml +++ b/crates/guara-cli/Cargo.toml @@ -18,3 +18,4 @@ serde_json = "1" tiny_http = "0.12" getrandom = "0.2" ctrlc = "3" +rpassword = "7" diff --git a/crates/guara-cli/src/main.rs b/crates/guara-cli/src/main.rs index 5ffaa57..f05e6b6 100644 --- a/crates/guara-cli/src/main.rs +++ b/crates/guara-cli/src/main.rs @@ -31,6 +31,8 @@ enum Cmd { path: String, #[arg(long)] json: bool, + #[command(flatten)] + asm: AsmArgs, }, /// Saúde do disco (SMART): estado, temperatura, uso e tabela de atributos Health { @@ -56,7 +58,36 @@ enum Cmd { filter: FilterArgs, }, /// Cria uma imagem do disco (tolerante a setores ruins) para trabalhar na cópia - Image { path: String, dest: String }, + Image { + path: String, + dest: String, + /// Formato da imagem: raw (padrão) ou e01 (Expert Witness, com hash e metadados) + #[arg(long, default_value = "raw")] + format: String, + /// E01: número do caso + #[arg(long, default_value = "")] + case: String, + /// E01: examinador + #[arg(long, default_value = "")] + examiner: String, + /// E01: descrição da evidência + #[arg(long, default_value = "")] + description: String, + /// E01: número da evidência + #[arg(long, default_value = "")] + evidence: String, + /// E01: observações + #[arg(long, default_value = "")] + notes: String, + /// E01: tamanho máximo de cada segmento em MiB (0 = um arquivo só) + #[arg(long, default_value_t = 1500)] + segment_mib: u64, + /// E01: compressão 0 (nenhuma) a 9 + #[arg(long, default_value_t = 1)] + compression: u32, + }, + /// Confere uma imagem E01/Ex01: recalcula o MD5 e compara com o gravado na própria imagem + Verify { path: String }, /// Exporta os achados de uma sessão para CSV (abre no Excel) Export { session: String, @@ -140,6 +171,111 @@ struct OutArgs { json: bool, } +/// Montagem do disco: volumes cifrados, RAID e LVM. +#[derive(clap::Args, Clone, Default)] +struct AsmArgs { + /// Senha de volume cifrado (BitLocker, LUKS, FileVault 2). Prefira --ask-password ou GUARA_PASSWORD + #[arg(long = "password")] + passwords: Vec, + /// Pergunta a senha no terminal, sem mostrar o que é digitado + #[arg(long)] + ask_password: bool, + /// Chave de recuperação numérica do BitLocker (8 grupos de 6 dígitos) + #[arg(long = "recovery-key")] + recovery_keys: Vec, + /// Arquivo de chave (.BEK do BitLocker ou keyfile do LUKS) + #[arg(long = "key-file")] + key_files: Vec, + /// Chave do volume em hexadecimal (a que o Guará mostra ao abrir um volume) + #[arg(long = "volume-key")] + volume_keys: Vec, + /// Outro disco ou imagem do mesmo conjunto (RAID/LVM). Pode repetir; "-" marca um disco ausente + #[arg(long = "member")] + members: Vec, + /// Os discos (alvo + --member, nesta ordem) formam um RAID sem metadados: descobre sozinho nível, faixa e layout + #[arg(long)] + raid: bool, + /// Fixa o nível do RAID: 0, 5, 6, 1 + #[arg(long)] + raid_level: Option, + /// Fixa o tamanho da faixa do RAID em KiB + #[arg(long)] + raid_chunk_kb: Option, + /// Fixa o layout: left-symmetric, left-asymmetric, right-symmetric, right-asymmetric + #[arg(long)] + raid_layout: Option, + /// Ordem dos discos no RAID (índices; 0 = alvo) + #[arg(long, value_delimiter = ',')] + raid_order: Vec, + /// Guarda na sessão as chaves dos volumes abertos (a senha nunca é guardada) + #[arg(long)] + remember_keys: bool, + /// Vídeo íntegro da mesma câmera/drone para refazer o índice de MP4 sem moov (pode repetir) + #[arg(long = "reference")] + references: Vec, +} + +impl AsmArgs { + fn assembly(&self) -> Result { + use guara_core::assemble::{Assembly, RaidSpec, SecretInput}; + let mut secrets = vec![]; + for p in &self.passwords { + secrets.push(SecretInput::Password { value: p.clone() }); + } + if let Ok(p) = std::env::var("GUARA_PASSWORD") { + if !p.is_empty() { + secrets.push(SecretInput::Password { value: p }); + } + } + if self.ask_password { + let p = rpassword::prompt_password("Senha do volume: ").map_err(s)?; + secrets.push(SecretInput::Password { value: p }); + } + for r in &self.recovery_keys { + secrets.push(SecretInput::Recovery { value: r.clone() }); + } + for k in &self.key_files { + secrets.push(SecretInput::Keyfile { path: k.clone() }); + } + for k in &self.volume_keys { + secrets.push(SecretInput::Key { hex: k.clone() }); + } + let raid = if self.raid || self.raid_level.is_some() || self.raid_chunk_kb.is_some() || self.raid_layout.is_some() || !self.raid_order.is_empty() { + let level = match self.raid_level.as_deref() { + None => None, + Some("0") => Some(guara_core::raid::Level::R0), + Some("1") => Some(guara_core::raid::Level::R1), + Some("5") => Some(guara_core::raid::Level::R5), + Some("6") => Some(guara_core::raid::Level::R6), + Some(x) => return Err(format!("nível de RAID não suportado: {x} (use 0, 1, 5 ou 6)")), + }; + let layout = match self.raid_layout.as_deref() { + None => None, + Some("left-symmetric") => Some(guara_core::raid::Layout::LeftSymmetric), + Some("left-asymmetric") => Some(guara_core::raid::Layout::LeftAsymmetric), + Some("right-symmetric") => Some(guara_core::raid::Layout::RightSymmetric), + Some("right-asymmetric") => Some(guara_core::raid::Layout::RightAsymmetric), + Some(x) => return Err(format!("layout desconhecido: {x}")), + }; + Some(RaidSpec { + level, + chunk_kb: self.raid_chunk_kb, + layout, + order: (!self.raid_order.is_empty()).then(|| self.raid_order.clone()), + }) + } else { + None + }; + Ok(Assembly { + raid, + members: self.members.iter().filter(|m| *m != "-").cloned().collect(), + secrets, + remember_keys: self.remember_keys, + keys: vec![], + }) + } +} + #[derive(clap::Args)] struct ScanArgs { /// Dispositivo ou imagem @@ -154,6 +290,8 @@ struct ScanArgs { #[arg(long)] recover: Option, #[command(flatten)] + asm: AsmArgs, + #[command(flatten)] filter: FilterArgs, #[command(flatten)] out: OutArgs, @@ -204,8 +342,9 @@ fn run(cli: Cli) -> Result<(), String> { } Ok(()) } - Cmd::Inspect { path, json } => { - let v = eng.rpc("inspect", json!({ "path": path })).map_err(s)?; + Cmd::Inspect { path, json, asm } => { + let a = asm.assembly()?; + let v = eng.rpc("inspect", json!({ "path": path, "assembly": serde_json::to_value(&a).map_err(s)?})).map_err(s)?; if json { println!("{}", serde_json::to_string_pretty(&v).unwrap()); } else { @@ -231,6 +370,8 @@ fn run(cli: Cli) -> Result<(), String> { }, include_allocated: a.include_allocated, categories: parse_cats(&a.filter.category)?, + assembly: a.asm.assembly()?, + references: a.asm.references.clone(), ..Default::default() }; let job = eng.start_scan(&a.path, opts).map_err(s)?; @@ -272,8 +413,41 @@ fn run(cli: Cli) -> Result<(), String> { eng.save_session(&job, &session).map_err(s)?; Ok(()) } - Cmd::Image { path, dest } => { - let job = eng.start_image(&path, &dest).map_err(s)?; + Cmd::Verify { path } => { + let e = guara_core::ewf::EwfSource::open(std::path::Path::new(&path)).map_err(s)?; + println!("Formato: {}", if e.info.version == 2 { "Ex01 (EnCase 7)" } else { "E01 (EnCase)" }); + println!("Segmentos: {}", e.info.segments); + for (k, v) in &e.info.fields { + println!("{k}: {v}"); + } + eprintln!("Recalculando o hash..."); + match e.verify().map_err(s)? { + Some(true) => { + println!("MD5 gravado e recalculado conferem: {}", e.info.md5.clone().unwrap_or_default()); + Ok(()) + } + Some(false) => Err("O MD5 recalculado difere do gravado na imagem: ela está corrompida ou foi alterada.".into()), + None => { + println!("Esta imagem não traz hash gravado para conferir."); + Ok(()) + } + } + } + Cmd::Image { path, dest, format, case, examiner, description, evidence, notes, segment_mib, compression } => { + let fmt = match format.as_str() { + "raw" => guara_core::image::ImageFormat::Raw, + "e01" => guara_core::image::ImageFormat::E01 { + case_number: case, + description, + evidence_number: evidence, + examiner, + notes, + segment_mib, + compression, + }, + x => return Err(format!("formato de imagem desconhecido: {x} (use raw ou e01)")), + }; + let job = eng.start_image_as(&path, &dest, &fmt).map_err(s)?; let st = wait(&eng, &job, None)?; if st["status"] == "error" { return Err(st["error"].as_str().unwrap_or("falha").to_string()); @@ -813,3 +987,4 @@ fn print_health(v: &Value) { println!("\n{}", adv.as_str().unwrap_or("")); } } + diff --git a/crates/guara-core/src/assemble.rs b/crates/guara-core/src/assemble.rs index 2dacc77..292f1c2 100644 --- a/crates/guara-core/src/assemble.rs +++ b/crates/guara-core/src/assemble.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use std::path::Path; use std::sync::Arc; -#[derive(Clone, Debug, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "lowercase")] pub enum SecretInput { Password { value: String }, @@ -50,8 +50,8 @@ pub struct Assembly { /// Outros discos ou imagens do mesmo conjunto (RAID/LVM), além do alvo principal. #[serde(default)] pub members: Vec, - /// Senhas e chaves: nunca vão para o disco junto com a sessão. - #[serde(default, skip_serializing)] + /// Senhas e chaves: o `snapshot` da sessão as descarta antes de gravar. + #[serde(default)] pub secrets: Vec, /// Guarda as chaves de volume derivadas na sessão, para reabrir sem pedir a senha de novo. #[serde(default)] diff --git a/crates/guara-core/src/image.rs b/crates/guara-core/src/image.rs index 1cea459..1bd3934 100644 --- a/crates/guara-core/src/image.rs +++ b/crates/guara-core/src/image.rs @@ -24,6 +24,69 @@ pub struct ImageReport { const BLOCK: usize = 1 << 20; const SECTOR: usize = 512; +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum ImageFormat { + #[default] + Raw, + /// Expert Witness (EnCase 6): `.E01`, dividido em segmentos. + E01 { + #[serde(default)] + case_number: String, + #[serde(default)] + description: String, + #[serde(default)] + evidence_number: String, + #[serde(default)] + examiner: String, + #[serde(default)] + notes: String, + /// Tamanho máximo de cada segmento em MiB (0 = um arquivo só). + #[serde(default = "default_segment_mib")] + segment_mib: u64, + /// 0 = sem compressão, 1 a 9 = deflate. + #[serde(default = "default_compression")] + compression: u32, + }, +} + +fn default_segment_mib() -> u64 { + 1500 +} +fn default_compression() -> u32 { + 1 +} + +pub fn create_image_as( + src: &dyn Source, + dest: &Path, + format: &ImageFormat, + cancelled: &(dyn Fn() -> bool + Sync), + progress: &mut dyn FnMut(u64, u64), +) -> Result { + match format { + ImageFormat::Raw => create_image(src, dest, cancelled, progress), + ImageFormat::E01 { case_number, description, evidence_number, examiner, notes, segment_mib, compression } => { + let opts = crate::ewf::E01Options { + case_number: case_number.clone(), + description: description.clone(), + evidence_number: evidence_number.clone(), + examiner: examiner.clone(), + notes: notes.clone(), + segment_size: segment_mib * 1024 * 1024, + compression: *compression, + ..Default::default() + }; + // O destino pode vir com ou sem a extensão .E01. + let base = match dest.extension().and_then(|e| e.to_str()) { + Some(e) if e.eq_ignore_ascii_case("e01") => dest.with_extension(""), + _ => dest.to_path_buf(), + }; + crate::ewf::write_e01(src, &base, &opts, cancelled, progress) + } + } +} + pub fn create_image( src: &dyn Source, dest: &Path, diff --git a/crates/guara-core/src/partition.rs b/crates/guara-core/src/partition.rs index b72aba3..9c0f4bb 100644 --- a/crates/guara-core/src/partition.rs +++ b/crates/guara-core/src/partition.rs @@ -248,7 +248,7 @@ pub fn analyze(src: &dyn Source, search_lost: bool) -> DiskLayout { for p in &layout.partitions { if let Some(e) = &p.encrypted { layout.warnings.push(format!( - "Partição {} está cifrada com {}: o Guará só detecta cifragem na v1 e não varre esse volume sem a chave.", + "Partição {} está cifrada com {}: informe a senha, a chave de recuperação ou o arquivo de chave para abrir e varrer o conteúdo.", p.index + 1, e )); diff --git a/crates/guara-core/src/service.rs b/crates/guara-core/src/service.rs index e352e86..bb38e21 100644 --- a/crates/guara-core/src/service.rs +++ b/crates/guara-core/src/service.rs @@ -548,6 +548,12 @@ impl Engine { // ---------------------------------------------------------------- imagem pub fn start_image(&self, path: &str, dest: &str) -> Result { + self.start_image_as(path, dest, &image::ImageFormat::Raw) + } + + /// Cria a imagem em bruto ou em outro formato forense (E01 ou AFF4). + pub fn start_image_as(&self, path: &str, dest: &str, format: &image::ImageFormat) -> Result { + let format = format.clone(); let src = open_path(Path::new(path)).map_err(|e| open_error(path, e))?; recover::check_destination( Some(Path::new(path)), @@ -570,7 +576,7 @@ impl Engine { total: t, }; }; - let res = image::create_image(src.as_ref(), &dest, &cancelled, &mut prog); + let res = image::create_image_as(src.as_ref(), &dest, &format, &cancelled, &mut prog); let mut s = j2.st.lock().unwrap(); match res { Ok(rep) => { @@ -990,8 +996,35 @@ impl Engine { "devices" => json!(device::list_devices()), "inspect" => { let path = s("path")?; - let src = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?; - json!(partition::analyze(src.as_ref(), false)) + let asm: crate::assemble::Assembly = p + .get("assembly") + .cloned() + .map(serde_json::from_value) + .transpose()? + .unwrap_or_default(); + let raw = open_path(Path::new(&path)).map_err(|e| open_error(&path, e))?; + let a = crate::assemble::assemble_source(raw, &asm)?; + let mut layout = partition::analyze(a.source.as_ref(), false); + if a.skip_primary { + layout.partitions.clear(); + layout.gaps.clear(); + layout.table = "membro de RAID".into(); + } + for pi in &a.extra_parts { + if !layout.partitions.iter().any(|q| q.start == pi.start) { + let mut pi = pi.clone(); + pi.index = layout.partitions.len(); + layout.partitions.push(pi); + } + } + layout.warnings.extend(a.notes.iter().cloned()); + for u in &a.unlocked { + layout.warnings.push(format!("Volume aberto em {}: {}.", u.start, u.description)); + } + for l in &a.locked { + layout.warnings.push(format!("Volume cifrado em {} continua fechado: {}.", l.start, l.reason)); + } + json!(layout) } "scan_start" => { let opts: ScanOptions = p @@ -1124,7 +1157,15 @@ impl Engine { .unwrap_or_default(); json!({ "job": self.start_recover(&s("job")?, ids, filter, &s("dest")?, layout)? }) } - "image_start" => json!({ "job": self.start_image(&s("path")?, &s("dest")?)? }), + "image_start" => { + let format: image::ImageFormat = p + .get("format") + .cloned() + .map(serde_json::from_value) + .transpose()? + .unwrap_or(image::ImageFormat::Raw); + json!({ "job": self.start_image_as(&s("path")?, &s("dest")?, &format)? }) + } "session_save" => { self.save_session(&s("job")?, &s("path")?)?; json!({}) @@ -1546,10 +1587,16 @@ fn new_session_path(target: &str) -> PathBuf { fn snapshot(j: &Job) -> SessionFile { let s = j.st.lock().unwrap(); + // Senhas e chaves nunca vão para o arquivo de sessão (só as chaves de volume, se o usuário pediu). + let mut options = j.opts.clone(); + options.assembly.secrets.clear(); + if !options.assembly.remember_keys { + options.assembly.keys.clear(); + } SessionFile { version: 1, target: j.target.clone(), - options: j.opts.clone(), + options, found: s.found.clone(), checkpoint: s.checkpoint.clone(), layout: s.layout.clone(),