From 0d11d68b44f16ba9c2eee6fbe859c2dd5f7302ad Mon Sep 17 00:00:00 2001 From: sergio Date: Sat, 20 Jun 2026 18:45:14 -0400 Subject: [PATCH] =?UTF-8?q?atestaci=C3=B3n=20de=20integridad=20al=20arranq?= =?UTF-8?q?ue=20=E2=80=94=20mitad=20de=20hammer=20(I4=20/=20Etapa=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Por la regla de oro del plan arje↔hammer (PLAN-ATESTACION-Y-HAMMER.md §B.1): hammer es dueño del expected_hash + TrustStore; arje del gate al boot. Esta es la mitad de hammer — PRODUCIR y auto-verificar el manifiesto de hashes esperados que el gate A2 de arje consumirá ("el expected_hash de un .swm ES el BLAKE3 que arje atesta"). - hammer-core: ArtifactHash::of_file = BLAKE3 CRUDO del fichero (sin framing), el mismo que computa arje-cas::blake3_of ⇒ casa con quien recompute el hash. - hammer-bootstrap: el producto emite /ente/attest.json con el BLAKE3 esperado de los binarios críticos (ATTEST_PATHS: arje-zero PID1, hammerd, busybox, sshd, netup, coreutils). Fichero APARTE de la seed card ⇒ no toca el schema de card-core y el producto bootea igual con el arje-zero actual (ignora A2). verify_attestation() recomputa y compara (ok/diverge/falta). product hash v3. - CLI: `hammer attest --rootfs ` — el gate de integridad hecho hoy por hammer ("reproducir, no confiar"); exit≠0 si algo diverge/falta. - 37 tests verde (manifiesto + verify + tamper + missing). Validado: el producto emite attest.json con los 6 binarios críticos; `hammer attest` ✓ los 6; alterar 1 byte de coreutils ⇒ "✗ DIVERGE" exit 1; el producto con attest.json sigue booteando + SSH (arje ignora el fichero). El gate al boot (A2) es la mitad de tawasuyu/arje-zero (cross-repo, fuera de este commit). Co-Authored-By: Claude Opus 4.8 --- crates/hammer-bootstrap/src/lib.rs | 147 ++++++++++++++++++++++++++++- crates/hammer-cli/src/main.rs | 33 +++++++ crates/hammer-core/src/hash.rs | 12 +++ 3 files changed, 191 insertions(+), 1 deletion(-) diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index b3483ec1..38f7e760 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -445,6 +445,101 @@ const PRODUCT_GROUP: &str = "root:x:0:\nsshd:x:74:\n"; /// arje monta), authorized_keys provisionadas en deploy. const SSHD_CONFIG: &str = "Port 22\nListenAddress 0.0.0.0\nPermitRootLogin prohibit-password\nPubkeyAuthentication yes\nPasswordAuthentication no\nAuthorizedKeysFile /root/.ssh/authorized_keys\nPidFile /run/sshd.pid\nSubsystem sftp /usr/libexec/sftp-server\n"; +// ── Atestación de integridad al arranque (I4 / Etapa D, mitad de HAMMER) ───────────────────────── +// +// Por la regla de oro del [plan arje↔hammer] (`PLAN-ATESTACION-Y-HAMMER.md` §B.1): hammer es dueño del +// **`expected_hash` + firma + TrustStore**; arje es dueño del **gate al boot** (computa `blake3` de cada +// binario crítico ANTES de incarnar y, si no casa, `halt`). Esta es la mitad de hammer: PRODUCIR el +// manifiesto de hashes esperados (`/ente/attest.json`) que el gate A2 de arje verifica — "el +// `expected_hash` de un `.swm` ES el BLAKE3 que arje atesta". El hash es `blake3` CRUDO del fichero +// (`ArtifactHash::of_file`), el mismo que computa `arje-cas::blake3_of`. La firma con la rootkey del +// seed (A1, `arje-packager`) es una capa posterior; el manifiesto ya es verificable hoy por `hammer +// attest` (auto-validación: "reproducir, no confiar"). Es un fichero APARTE de la seed card ⇒ no toca +// el schema de `card-core` y el producto bootea igual con el arje-zero actual (que aún ignora A2). + +/// Binarios críticos cuyo BLAKE3 hammer atesta (la raíz-de-confianza-ejecutable del producto): PID 1, +/// el diario, el shell/getty, el daemon de red expuesto, el configurador de red y el userland multicall. +const ATTEST_PATHS: &[&str] = &[ + "usr/bin/arje-zero", // PID 1 — la raíz + "usr/bin/hammerd", // diario + bus + "bin/busybox", // shell/getty + exec de varios cards + "usr/sbin/sshd", // daemon SSH (superficie de red) + "usr/bin/netup", // configurador de red + "usr/bin/coreutils", // userland Rust multicall +]; + +/// Una entrada del manifiesto de atestación: ruta (relativa a la raíz) → BLAKE3 esperado del fichero. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AttestEntry { + pub path: String, + pub b3: String, +} + +/// Manifiesto de atestación que hammer emite en `/ente/attest.json`. `algo` fijo a `blake3` (crudo). +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct AttestManifest { + pub schema_version: u32, + pub algo: String, + pub entries: Vec, +} + +/// Veredicto de [`verify_attestation`]: qué binarios casaron, cuáles divergieron y cuáles faltan. +#[derive(Debug, Clone, Default)] +pub struct AttestReport { + pub ok: Vec, + pub mismatched: Vec, + pub missing: Vec, +} + +impl AttestReport { + /// Pasa sólo si todo binario listado existe y su BLAKE3 casa. + pub fn passed(&self) -> bool { + self.mismatched.is_empty() && self.missing.is_empty() + } +} + +/// Construye el manifiesto de atestación de un árbol `root`: para cada ruta crítica PRESENTE, su BLAKE3 +/// crudo. Determinista (función pura de los bytes del binario). +fn build_attestation(root: &Path) -> Result { + let mut entries = Vec::new(); + for rel in ATTEST_PATHS { + let p = root.join(rel); + if p.is_file() { + let h = ArtifactHash::of_file(&p).map_err(Error::Io)?; + entries.push(AttestEntry { path: format!("/{rel}"), b3: h.as_str().to_string() }); + } + } + Ok(AttestManifest { schema_version: 1, algo: "blake3".into(), entries }) +} + +/// Verifica un árbol FHS (`rootfs_dir`, p.ej. un product-rootfs hidratado) contra su propio +/// `/ente/attest.json`: recomputa el BLAKE3 de cada binario listado y lo compara. Es lo que el gate de +/// arje hará al boot, hecho hoy por hammer (auto-validación de integridad del producto). +pub fn verify_attestation(rootfs_dir: &Path) -> Result { + let manifest_path = rootfs_dir.join("ente/attest.json"); + let raw = std::fs::read_to_string(&manifest_path).map_err(|e| { + Error::Other(format!("no pude leer {}: {e}", manifest_path.display())) + })?; + let manifest: AttestManifest = serde_json::from_str(&raw) + .map_err(|e| Error::Other(format!("attest.json inválido: {e}")))?; + let mut report = AttestReport::default(); + for entry in &manifest.entries { + let rel = entry.path.trim_start_matches('/'); + let p = rootfs_dir.join(rel); + if !p.is_file() { + report.missing.push(entry.path.clone()); + continue; + } + let got = ArtifactHash::of_file(&p).map_err(Error::Io)?; + if got.as_str() == entry.b3 { + report.ok.push(entry.path.clone()); + } else { + report.mismatched.push(entry.path.clone()); + } + } + Ok(report) +} + /// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía /// `serde_json` (hammer sigue autocontenido: no depende de `card-core`). Determinista ⇒ el /// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual. @@ -471,13 +566,14 @@ fn product_rootfs_hash( seed: &str, ) -> RootfsHash { let mut inputs: Vec> = vec![ - b"hammer-product-rootfs-v2".to_vec(), + b"hammer-product-rootfs-v3".to_vec(), // v3: + manifiesto de atestación (/ente/attest.json) base.as_str().as_bytes().to_vec(), ]; for (name, h) in userland.iter().chain(services.iter()) { inputs.push(name.as_bytes().to_vec()); inputs.push(h.as_str().as_bytes().to_vec()); } + inputs.push(ATTEST_PATHS.join(",").into_bytes()); // el set atestado entra al hash inputs.push(seed.as_bytes().to_vec()); inputs.push(PRODUCT_PASSWD.as_bytes().to_vec()); inputs.push(PRODUCT_GROUP.as_bytes().to_vec()); @@ -569,6 +665,14 @@ fn assemble_product_rootfs( // la imagen monta vda3→/store y vda4→/var/lib/hammer). `/var/lib/hammer` ya viene de la base. std::fs::create_dir_all(staging.join("store"))?; std::fs::create_dir_all(staging.join("var/lib/hammer"))?; + + // Manifiesto de atestación (I4): BLAKE3 esperado de los binarios críticos ya hidratados ⇒ el gate + // de arje lo verifica al boot. Se computa al final, sobre el árbol ya ensamblado. Fichero aparte de + // la seed card (no toca el schema de card-core). + let attest = build_attestation(staging)?; + let attest_json = serde_json::to_string_pretty(&attest) + .map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?; + std::fs::write(staging.join("ente/attest.json"), attest_json)?; Ok(()) } @@ -1673,6 +1777,47 @@ mod tests { // Mountpoints de disco (el producto es disk-ready): /store + /var/lib/hammer existen vacíos. assert!(staging.join("store").is_dir() && staging.join("var/lib/hammer").is_dir(), "/store y /var/lib/hammer como mountpoints del disco de producto"); + // Atestación: el producto emite /ente/attest.json y se auto-verifica íntegro. + assert!(staging.join("ente/attest.json").is_file(), "manifiesto de atestación emitido"); + let att = verify_attestation(&staging).unwrap(); + assert!(att.passed(), "el product-rootfs recién ensamblado pasa su propia atestación"); + // cubre los binarios presentes del set crítico (hammerd, busybox, sshd, netup, coreutils). + assert!(att.ok.iter().any(|p| p == "/usr/sbin/sshd") && att.ok.iter().any(|p| p == "/usr/bin/coreutils")); + } + + #[test] + fn attestation_manifest_emits_and_verifies_and_catches_tamper() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("rootfs"); + for d in ["usr/bin", "bin", "usr/sbin", "ente"] { + std::fs::create_dir_all(root.join(d)).unwrap(); + } + std::fs::write(root.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap(); + std::fs::write(root.join("bin/busybox"), b"\x7fELF-busybox").unwrap(); + std::fs::write(root.join("usr/sbin/sshd"), b"\x7fELF-sshd").unwrap(); + // arje-zero/netup/coreutils ausentes ⇒ no se listan (sólo binarios presentes). + + let manifest = build_attestation(&root).unwrap(); + assert_eq!(manifest.algo, "blake3"); + let paths: Vec<&str> = manifest.entries.iter().map(|e| e.path.as_str()).collect(); + assert!(paths.contains(&"/usr/bin/hammerd") && paths.contains(&"/bin/busybox") && paths.contains(&"/usr/sbin/sshd")); + assert!(!paths.contains(&"/usr/bin/arje-zero"), "binario ausente no se atesta"); + std::fs::write(root.join("ente/attest.json"), serde_json::to_string_pretty(&manifest).unwrap()).unwrap(); + + // verificación íntegra: pasa. + let rep = verify_attestation(&root).unwrap(); + assert!(rep.passed() && rep.ok.len() == manifest.entries.len()); + + // manipular un binario ⇒ DIVERGE (el gate de arje haría halt). + std::fs::write(root.join("bin/busybox"), b"\x7fELF-TAMPERED-longer").unwrap(); + let rep2 = verify_attestation(&root).unwrap(); + assert!(!rep2.passed()); + assert_eq!(rep2.mismatched, vec!["/bin/busybox".to_string()]); + + // borrar un binario crítico ⇒ FALTA. + std::fs::remove_file(root.join("usr/sbin/sshd")).unwrap(); + let rep3 = verify_attestation(&root).unwrap(); + assert!(rep3.missing.contains(&"/usr/sbin/sshd".to_string()) && !rep3.passed()); } #[test] diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 8a5aea3d..5d0764e6 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -33,6 +33,15 @@ enum Cmd { /// Ruta a la receta TOML. recipe: String, }, + /// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su + /// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de + /// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0 + /// si algún binario diverge o falta. + Attest { + /// Directorio raíz del rootfs a verificar (contiene `ente/attest.json`). + #[arg(long)] + rootfs: String, + }, /// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay). Hydrate { /// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`). @@ -415,6 +424,30 @@ fn main() -> anyhow::Result<()> { let hash = hammer_build::build(&recipe, &cfg, &store)?; println!("{hash}"); } + Cmd::Attest { rootfs } => { + let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?; + for p in &report.ok { + println!(" ✓ {p}"); + } + for p in &report.missing { + println!(" ✗ FALTA {p}"); + } + for p in &report.mismatched { + println!(" ✗ DIVERGE {p}"); + } + if report.passed() { + println!( + "✓ ATESTACIÓN OK: {} binarios críticos casan su BLAKE3 esperado", + report.ok.len() + ); + } else { + anyhow::bail!( + "✗ ATESTACIÓN FALLÓ: {} diverge(n), {} falta(n) — integridad comprometida", + report.mismatched.len(), + report.missing.len() + ); + } + } Cmd::Hydrate { hash, into, link } => { let store = hammer_core::Store::open(&cli.store)?; let mode = match link.as_str() { diff --git a/crates/hammer-core/src/hash.rs b/crates/hammer-core/src/hash.rs index 65f2b730..d3ae0dc9 100644 --- a/crates/hammer-core/src/hash.rs +++ b/crates/hammer-core/src/hash.rs @@ -85,6 +85,18 @@ impl ArtifactHash { } Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))) } + + /// BLAKE3 **crudo** del contenido de un fichero (sin framing): exactamente `blake3(bytes)`. Es lo + /// que computa el `blake3_of` de `arje-cas` y el `expected_hash` de un `.swm`, así que sirve para + /// la atestación de integridad al arranque (el gate que arje aplica antes de incarnar). A diferencia + /// de [`of_inputs`](Self::of_inputs) (length-prefijado) y [`of_tree`](Self::of_tree) (árbol con + /// rutas/modo), aquí el hash es del binario tal cual ⇒ casa con quien recompute `blake3` del fichero. + pub fn of_file(path: &Path) -> std::io::Result { + let mut f = std::fs::File::open(path)?; + let mut hasher = blake3::Hasher::new(); + std::io::copy(&mut f, &mut hasher)?; + Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex()))) + } } /// Recorre `root` recursivamente acumulando rutas **relativas a `root`** en `out`. No sigue