diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 38f7e760..9b402bd2 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -738,6 +738,265 @@ pub fn product( Ok(phash) } +// ── Producto ATESTADO: la capa FIRMADA que el gate de arje verifica al boot (I4 / Etapa D) ─────── +// +// `product()` sella el `product-rootfs` con el arje-zero del núcleo + un `/ente/attest.json` de +// AUTO-verificación (esquema paralelo que `hammer attest` valida, pero que el gate de arje NO lee). +// El producto ATESTADO cierra el lazo canónico: hidrata el arje-zero CON gate (`arje-zero-attest`), +// FIRMA la seed con `arje-packager` (una concesión Ed25519 por binario crítico, sobre su BLAKE3, bajo +// una rootkey) y la sella como `product-attested-rootfs`. Booteado, el gate (política `halt`) recomputa +// el BLAKE3 de cada exec crítico y aborta antes de incarnar si algo no casa la concesión firmada. +// +// Esto reemplaza el spike `scripts/attest-boot-test.sh` (que overlayeaba a mano): ahora hammer-bootstrap +// produce el producto atestado de forma estructurada. Reusa la cripto canónica (arje-attest/agora vía el +// packager) — cero criptografía nueva en hammer. La rootkey por defecto es determinista ⇒ las firmas +// Ed25519 (RFC 8032, nonce derivado del mensaje) son reproducibles bit a bit. + +/// Rootkey de desarrollo (32 bytes) con la que hammer firma la atestación por defecto. Determinista ⇒ +/// firmas reproducibles ⇒ `product-attested-rootfs` reproducible. El endurecimiento soberano (rootkey +/// fuera del árbol, `/etc/arje/rootkey.pub` / `ARJE_ATTEST_ROOTKEY_FILE`) es una capa posterior +/// (roadmap Etapa D, hardening #3); por ahora se inyecta como override de [`AttestConfig::rootkey`]. +pub const DEV_ATTEST_ROOTKEY: &[u8; 32] = b"hammer-attest-dev-rootkey-0001!!"; + +/// Recetas de la capa de atestación: el init CON gate (variante SÓLO producto, núcleo intacto) y el +/// firmador (herramienta de build-time, estático musl, corre en el host y NO se embarca). +const GATED_INIT_COMPONENT: &str = "arje-zero-attest"; +const PACKAGER_COMPONENT: &str = "arje-packager"; + +/// Configuración de la firma de atestación del producto. +#[derive(Debug, Clone)] +pub struct AttestConfig { + /// Política que el gate aplica si un binario no atesta: `"halt"` | `"degraded"` | `"warn"`. + pub policy: String, + /// Rootkey de 32 bytes para firmar las concesiones. Determinista ⇒ producto reproducible. + pub rootkey: [u8; 32], +} + +impl Default for AttestConfig { + /// Política `halt` (la integridad comprometida NO levanta el entorno) + la rootkey de desarrollo. + fn default() -> Self { + Self { policy: "halt".into(), rootkey: *DEV_ATTEST_ROOTKEY } + } +} + +/// Identidad del `product-attested-rootfs`: el producto base + el init con gate + el firmador + la +/// política + la rootkey. Cambiar cualquiera re-hashea; como las firmas Ed25519 son deterministas en +/// estos insumos, el árbol sellado es reproducible. +fn product_attested_hash( + base_product: &RootfsHash, + gated_init: &ArtifactHash, + packager: &ArtifactHash, + cfg: &AttestConfig, +) -> RootfsHash { + ArtifactHash::of_inputs(&[ + b"hammer-product-attested-v1", + base_product.as_str().as_bytes(), + gated_init.as_str().as_bytes(), + packager.as_str().as_bytes(), + cfg.policy.as_bytes(), + &cfg.rootkey, + ]) +} + +/// Binarios críticos que el gate atesta, derivados de la PROPIA seed (no una lista fija): el ejecutable +/// de PID 1 (`arje-zero`, el `current_exe` del gate) + el `exec` de cada card `Native` del fractal +/// `genesis` (recursivo), mapeado a su ruta dentro de `staging`. Así, añadir un servicio a la seed lo +/// firma automáticamente. Devuelve `(label, ruta_host)` para alimentar `--bin label=path` del packager — +/// el `label` debe casar el de la card (lo que el gate busca en las concesiones). El orden es estable +/// (PID 1 primero, luego DFS del genesis) ⇒ los flags `--bin` son deterministas. +fn critical_bins_from_seed(seed: &serde_json::Value, staging: &Path) -> Vec<(String, PathBuf)> { + fn walk(card: &serde_json::Value, staging: &Path, out: &mut Vec<(String, PathBuf)>) { + let exec = card + .get("payload") + .and_then(|p| p.get("Native")) + .and_then(|n| n.get("exec")) + .and_then(|e| e.as_str()); + if let (Some(exec), Some(label)) = (exec, card.get("label").and_then(|l| l.as_str())) { + out.push((label.to_string(), staging.join(exec.trim_start_matches('/')))); + } + if let Some(g) = card.get("genesis").and_then(|g| g.as_array()) { + for c in g { + walk(c, staging, out); + } + } + } + let mut out = vec![("arje-zero".to_string(), staging.join("usr/bin/arje-zero"))]; + if let Some(g) = seed.get("genesis").and_then(|g| g.as_array()) { + for c in g { + walk(c, staging, &mut out); + } + } + out +} + +/// Ensambla el producto ATESTADO en `staging`: hidrata el `product-rootfs` base, PISA `/usr/bin/arje-zero` +/// con el init CON gate, fija `attest_policy` en la seed, la FIRMA con el packager (una concesión por +/// binario crítico sobre su BLAKE3) y regenera `/ente/attest.json` (la auto-verificación de hammer, ahora +/// coherente con el arje-zero nuevo). El packager corre en el host (estático musl, sin loader, como +/// busybox/bwrap). La firma se valida E2E en QEMU (`scripts/attest-boot-test.sh`). +fn assemble_attested_product_rootfs( + store: &Store, + base_product: &RootfsHash, + gated_init_bin: &Path, + packager_bin: &Path, + cfg: &AttestConfig, + staging: &Path, +) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + + // 1) producto base hidratado (hardlinks read-only del árbol sellado). + let base_dir = store.path_of(base_product, "product-rootfs"); + if !base_dir.is_dir() { + return Err(Error::Other(format!( + "product-rootfs base no sellado en {}", + base_dir.display() + ))); + } + hammer_build::run_hydrate(&base_dir, staging, hammer_core::LinkMode::Static, None)?; + let _ = std::fs::remove_dir_all(staging.join(".hammer")); + + // 2) PISAR /usr/bin/arje-zero con el init CON gate. Es un hardlink read-only al store ⇒ romper el + // hardlink (remove + copy), nunca `chmod` (mutaría el inodo del store). + let init_dst = staging.join("usr/bin/arje-zero"); + let _ = std::fs::remove_file(&init_dst); + std::fs::copy(gated_init_bin, &init_dst)?; + std::fs::set_permissions(&init_dst, std::fs::Permissions::from_mode(0o755))?; + + // 3) seed de producto: fijar `attest_policy` como ENTRADA del firmador. La mutamos y la dejamos en un + // tmp FUERA del árbol a sellar (el seed-out lo escribe el packager sobre /ente/seed.card.json). + let seed_path = staging.join("ente/seed.card.json"); + let raw = std::fs::read_to_string(&seed_path) + .map_err(|e| Error::Other(format!("leer seed de producto: {e}")))?; + let mut seed: serde_json::Value = + serde_json::from_str(&raw).map_err(|e| Error::Other(format!("seed inválida: {e}")))?; + seed["attest_policy"] = serde_json::Value::String(cfg.policy.clone()); + + let tmp = staging.join(".attest-build"); // se borra antes de sellar (no entra al árbol). + std::fs::create_dir_all(&tmp)?; + let seed_in = tmp.join("seed-in.json"); + std::fs::write( + &seed_in, + serde_json::to_string_pretty(&seed) + .map_err(|e| Error::Other(format!("serializar seed-in: {e}")))?, + )?; + let rootkey_path = tmp.join("rootkey.bin"); + std::fs::write(&rootkey_path, cfg.rootkey)?; + std::fs::set_permissions(&rootkey_path, std::fs::Permissions::from_mode(0o600))?; + + // 4) bins críticos derivados de la seed → un --bin por exec Native + el PID 1. + let bins = critical_bins_from_seed(&seed, staging); + + // 5) FIRMAR: el packager escribe el seed firmado (Card::attest + attest_rootkey + attest_policy) + // directamente sobre /ente/seed.card.json. El fichero hidratado es un HARDLINK read-only al + // store ⇒ el packager no puede abrirlo para escribir (EACCES): romper el hardlink primero. + let _ = std::fs::remove_file(&seed_path); + let mut cmd = Command::new(packager_bin); + cmd.arg("--seed") + .arg(&seed_in) + .arg("--rootkey") + .arg(&rootkey_path) + .arg("--seed-out") + .arg(&seed_path); + for (label, path) in &bins { + cmd.arg("--bin").arg(format!("{label}={}", path.display())); + } + let out = cmd + .output() + .map_err(|e| Error::Other(format!("spawn arje-packager: {e}")))?; + if !out.status.success() { + return Err(Error::Other(format!( + "arje-packager falló (exit {:?}): {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr).trim() + ))); + } + + // 6) regenerar /ente/attest.json: el arje-zero cambió ⇒ su BLAKE3 también, así que el manifiesto de + // auto-verificación de hammer (`hammer attest`) debe casar el árbol nuevo. El producto base lo + // trae hidratado (hardlink read-only): romperlo antes de reescribir. + let attest = build_attestation(staging)?; + let attest_json = serde_json::to_string_pretty(&attest) + .map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?; + let attest_path = staging.join("ente/attest.json"); + let _ = std::fs::remove_file(&attest_path); + std::fs::write(&attest_path, attest_json)?; + + // 7) limpiar el tmp de build (no debe quedar en el árbol sellado). + let _ = std::fs::remove_dir_all(&tmp); + Ok(()) +} + +/// **Producto atestado** — toma el rootfs base verificado (`base`, el `stage1-rootfs` del 4/4), sella el +/// `product-rootfs` (vía [`product`], idempotente) y produce el `product-attested-rootfs`: init CON gate +/// + seed FIRMADA, listo para que el gate de arje lo verifique al boot (A2). Construye/cachea las recetas +/// de la capa de atestación (`arje-zero-attest`, `arje-packager`). Idempotente. El núcleo y el producto +/// base quedan intactos (Separación Mecanismo/Política): el atestado es un sello APARTE. +pub fn product_attested( + base: &RootfsHash, + base_cfg: &hammer_build::BuildConfig, + recipes_dir: &Path, + store: &Store, + cfg: &AttestConfig, +) -> Result { + // 0) el producto base (idempotente: si ya está sellado, sale cacheado). + let base_product = product(base, base_cfg, recipes_dir, store)?; + + // 1) recetas de la capa de atestación (idempotentes/cacheadas por el lab). + let gated = build_components(&[GATED_INIT_COMPONENT], recipes_dir, base_cfg, store)? + .pop() + .map(|(_, h)| h) + .ok_or_else(|| Error::Other("build de arje-zero-attest no devolvió hash".into()))?; + let packager = build_components(&[PACKAGER_COMPONENT], recipes_dir, base_cfg, store)? + .pop() + .map(|(_, h)| h) + .ok_or_else(|| Error::Other("build de arje-packager no devolvió hash".into()))?; + + let gated_bin = store.path_of(&gated, GATED_INIT_COMPONENT).join("usr/bin/arje-zero"); + let packager_bin = store.path_of(&packager, PACKAGER_COMPONENT).join("usr/bin/arje-packager"); + if !gated_bin.is_file() { + return Err(Error::Other(format!( + "arje-zero (con gate) ausente en {}", + gated_bin.display() + ))); + } + if !packager_bin.is_file() { + return Err(Error::Other(format!( + "arje-packager ausente en {}", + packager_bin.display() + ))); + } + + // 2) identidad + ensamblado + sellado (si no estaba ya). + let phash = product_attested_hash(&base_product, &gated, &packager, cfg); + let store_name = "product-attested-rootfs"; + if !store.has(&phash, store_name) { + let staging = store + .root() + .join(".bootstrap-tmp") + .join(phash.store_dir_name(store_name)); + let _ = std::fs::remove_dir_all(&staging); + std::fs::create_dir_all(&staging)?; + let result = (|| -> Result<()> { + assemble_attested_product_rootfs( + store, + &base_product, + &gated_bin, + &packager_bin, + cfg, + &staging, + )?; + store.seal(&staging, &phash, store_name)?; + Ok(()) + })(); + let _ = std::fs::remove_dir_all(&staging); + result?; + tracing::info!(hash = %phash, "product: rootfs ATESTADO (init con gate + seed firmada) sellado"); + } else { + tracing::info!(hash = %phash, "product: rootfs atestado ya sellado (idempotente)"); + } + Ok(phash) +} + // ── Stage 2: rebuild nativo + verificación de auto-alojamiento (SDD 11 §3) ────────────────────── // // El corte del cordón: dentro del rootfs de Stage 1 se reconstruyen Stage 0' y Stage 1' usando @@ -1820,6 +2079,134 @@ mod tests { assert!(rep3.missing.contains(&"/usr/sbin/sshd".to_string()) && !rep3.passed()); } + #[test] + fn assemble_attested_swaps_gate_signs_seed_and_regenerates_attest() { + // Hermético: un `arje-packager` SINTÉTICO (script python) simula la firma — añade `attest`/ + // `attest_rootkey` al seed-out. Valida el CABLEADO de hammer (pisar el init, romper hardlinks, + // fijar attest_policy, derivar los --bin de la seed, regenerar /ente/attest.json), sin la cripto + // real (eso lo cubre E2E `scripts/attest-boot-test.sh` en QEMU). + use std::os::unix::fs::PermissionsExt; + let tmp = tempfile::tempdir().unwrap(); + let store = Store::open(tmp.path().join("store")).unwrap(); + + // product-rootfs base sellado: seed de producto + /ente/attest.json + los bins críticos. + let base = seal_component(&store, "product-rootfs", "9a07", |w| { + std::fs::create_dir_all(w.join("usr/bin")).unwrap(); + std::fs::create_dir_all(w.join("bin")).unwrap(); + std::fs::create_dir_all(w.join("ente")).unwrap(); + std::fs::write(w.join("usr/bin/arje-zero"), b"\x7fELF-arje-zero-CORE").unwrap(); + std::fs::write(w.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap(); + std::fs::write(w.join("bin/busybox"), b"\x7fELF-busybox").unwrap(); + std::fs::write(w.join("ente/seed.card.json"), product_seed_card().unwrap()).unwrap(); + // attest.json del producto base (con el arje-zero del NÚCLEO) — debe regenerarse. + let a = build_attestation(w).unwrap(); + std::fs::write(w.join("ente/attest.json"), serde_json::to_string(&a).unwrap()).unwrap(); + }); + + // init CON gate (bytes distintos al del núcleo) y packager sintético, fuera del store. + let gated_bin = tmp.path().join("arje-zero-gated"); + std::fs::write(&gated_bin, b"\x7fELF-arje-zero-GATED-bigger").unwrap(); + let pkgr = tmp.path().join("fake-packager"); + std::fs::write(&pkgr, FAKE_PACKAGER).unwrap(); + std::fs::set_permissions(&pkgr, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let staging = store.root().join(".bootstrap-tmp/test-attested"); + std::fs::create_dir_all(&staging).unwrap(); + let cfg = AttestConfig { policy: "halt".into(), rootkey: [3u8; 32] }; + assemble_attested_product_rootfs(&store, &base, &gated_bin, &pkgr, &cfg, &staging).unwrap(); + + // 1) /usr/bin/arje-zero ahora es el del gate (hardlink roto, no mutó el inodo del store). + assert_eq!( + std::fs::read(staging.join("usr/bin/arje-zero")).unwrap(), + b"\x7fELF-arje-zero-GATED-bigger", + "el init con gate pisó al del núcleo" + ); + // El sello del store NO se tocó (su arje-zero sigue siendo el del núcleo). + assert_eq!( + std::fs::read(store.path_of(&base, "product-rootfs").join("usr/bin/arje-zero")).unwrap(), + b"\x7fELF-arje-zero-CORE", + "romper el hardlink no mutó el store" + ); + // 2) seed firmada: el packager recibió attest_policy=halt y los 4 --bin derivados de la seed. + let seed: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(staging.join("ente/seed.card.json")).unwrap()).unwrap(); + assert_eq!(seed["attest_policy"], "halt"); + assert!(seed["attest"].is_array(), "el packager firmó (Card::attest)"); + let bins_seen = std::fs::read_to_string(staging.join("..").join("bins-seen.txt")).unwrap(); + for label in ["arje-zero", "hammerd", "console-getty", "sshd"] { + assert!(bins_seen.contains(&format!("{label}=")), "--bin {label} pasado al packager:\n{bins_seen}"); + } + // 3) /ente/attest.json regenerado: pasa con el arje-zero NUEVO (el del núcleo divergiría). + assert!(verify_attestation(&staging).unwrap().passed(), "attest.json regenerado y coherente"); + // 4) el tmp de build se limpió. + assert!(!staging.join(".attest-build").exists()); + } + + /// `arje-packager` sintético para el test hermético: copia `--seed`→`--seed-out` inyectando + /// `attest`/`attest_rootkey` (simula la firma) y vuelca los `--bin` vistos a `../bins-seen.txt`. + const FAKE_PACKAGER: &str = r#"#!/usr/bin/env python3 +import json, sys +seed=out=None; bins=[] +a=sys.argv[1:] +while a: + f=a.pop(0) + if f=="--seed": seed=a.pop(0) + elif f=="--seed-out": out=a.pop(0) + elif f=="--rootkey": a.pop(0) + elif f=="--bin": bins.append(a.pop(0)) + else: pass +d=json.load(open(seed)) +d["attest"]=[{"label":b.split("=",1)[0]} for b in bins] +d["attest_rootkey"]="deadbeef" +json.dump(d, open(out,"w"), indent=2) +import os +open(os.path.join(os.path.dirname(out),"..","..","bins-seen.txt"),"w").write("\n".join(bins)) +"#; + + #[test] + fn product_attested_hash_is_deterministic_and_sensitive() { + let base = ArtifactHash::from_hex("ab"); + let gated = ArtifactHash::from_hex("cd"); + let pkgr = ArtifactHash::from_hex("ef"); + let cfg = AttestConfig::default(); + let a = product_attested_hash(&base, &gated, &pkgr, &cfg); + assert_eq!(a, product_attested_hash(&base, &gated, &pkgr, &cfg), "determinista"); + // Cada insumo re-hashea. + assert_ne!(a, product_attested_hash(&ArtifactHash::from_hex("11"), &gated, &pkgr, &cfg), "base"); + assert_ne!(a, product_attested_hash(&base, &ArtifactHash::from_hex("22"), &pkgr, &cfg), "gate"); + assert_ne!(a, product_attested_hash(&base, &gated, &ArtifactHash::from_hex("33"), &cfg), "packager"); + let other_policy = AttestConfig { policy: "warn".into(), ..AttestConfig::default() }; + assert_ne!(a, product_attested_hash(&base, &gated, &pkgr, &other_policy), "política"); + let other_key = AttestConfig { rootkey: [7u8; 32], ..AttestConfig::default() }; + assert_ne!(a, product_attested_hash(&base, &gated, &pkgr, &other_key), "rootkey"); + // El producto atestado es un sello DISTINTO del producto base (no colisionan). + assert_ne!(a.as_str(), base.as_str(), "el atestado no es el producto base"); + } + + #[test] + fn critical_bins_are_derived_from_the_seed_native_execs() { + // De la seed de producto real: PID 1 (arje-zero, el current_exe del gate) + un --bin por cada + // exec Native del genesis (hammerd, console-getty, sshd). Los labels deben casar los de las cards + // (lo que el gate busca en las concesiones firmadas) y las rutas resolver dentro del staging. + let staging = Path::new("/tmp/fake-staging"); + let seed: serde_json::Value = + serde_json::from_str(&product_seed_card().unwrap()).unwrap(); + let bins = critical_bins_from_seed(&seed, staging); + let labels: Vec<&str> = bins.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!( + labels, + vec!["arje-zero", "hammerd", "console-getty", "sshd"], + "PID 1 primero, luego DFS del genesis" + ); + // El exec de cada card se mapea a su ruta absoluta dentro del staging. + let by_label = |l: &str| bins.iter().find(|(x, _)| x == l).map(|(_, p)| p.clone()).unwrap(); + assert_eq!(by_label("arje-zero"), staging.join("usr/bin/arje-zero")); + assert_eq!(by_label("hammerd"), staging.join("usr/bin/hammerd")); + // console-getty y sshd ejecutan ambos /bin/busybox ⇒ misma ruta, labels distintos. + assert_eq!(by_label("console-getty"), staging.join("bin/busybox")); + assert_eq!(by_label("sshd"), staging.join("bin/busybox")); + } + #[test] fn shipped_product_recipes_parse_and_hash() { // Las recetas de producto reales (userland Rust + servicios) cargan y hashean — viven fuera diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 5d0764e6..c0fed1ba 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -283,6 +283,17 @@ enum BootstrapCmd { /// Directorio de recetas (`openssh.toml`, `netup.toml`, …). #[arg(long, default_value = "recipes")] recipes: String, + /// Produce además el producto ATESTADO (`product-attested-rootfs`): hidrata el init CON gate + /// (`arje-zero-attest`) y FIRMA la seed con `arje-packager`. Imprime el hash del atestado. + #[arg(long)] + attest: bool, + /// Política del gate de atestación: `halt` (default) | `degraded` | `warn`. Sólo con `--attest`. + #[arg(long, default_value = "halt")] + policy: String, + /// Fichero con la rootkey de 32 bytes raw para firmar. Sin él se usa la rootkey de desarrollo + /// determinista (firmas reproducibles). Sólo con `--attest`. + #[arg(long)] + rootkey: Option, }, /// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad /// (anota la línea del manifiesto). Con `--verify ` compara contra un rebuild `stage1'` @@ -616,18 +627,25 @@ fn main() -> anyhow::Result<()> { let hash = hammer_bootstrap::stage1(&spec, &base_cfg, &store)?; println!("{hash}"); } - BootstrapCmd::Product { rootfs, recipes } => { + BootstrapCmd::Product { rootfs, recipes, attest, policy, rootkey } => { let store = hammer_core::Store::open(&cli.store)?; // El zig del lab (no la semilla): los servicios usan su `zig_version` propia // (openssh ⇒ 0.13.0, hermano del default), y si ya están sellados, salen cacheados. let base_cfg = hammer_build::BuildConfig::from_env_or_defaults(store.root()); let base = hammer_core::ArtifactHash::from_hex(rootfs.trim_start_matches("b3:")); - let hash = hammer_bootstrap::product( - &base, - &base_cfg, - std::path::Path::new(&recipes), - &store, - )?; + let recipes_dir = std::path::Path::new(&recipes); + let hash = if attest { + let mut cfg = hammer_bootstrap::AttestConfig { policy, ..Default::default() }; + if let Some(path) = rootkey { + let bytes = std::fs::read(&path)?; + cfg.rootkey = bytes.as_slice().try_into().map_err(|_| { + anyhow::anyhow!("la rootkey {path} debe ser exactamente 32 bytes (son {})", bytes.len()) + })?; + } + hammer_bootstrap::product_attested(&base, &base_cfg, recipes_dir, &store, &cfg)? + } else { + hammer_bootstrap::product(&base, &base_cfg, recipes_dir, &store)? + }; println!("{hash}"); } BootstrapCmd::Stage2 { rootfs, verify } => { diff --git a/scripts/attest-boot-test.sh b/scripts/attest-boot-test.sh index d0e18c42..273227de 100755 --- a/scripts/attest-boot-test.sh +++ b/scripts/attest-boot-test.sh @@ -5,12 +5,14 @@ # - TAMPER=1: se altera 1 byte de un binario crítico DESPUÉS de firmar ⇒ el gate (política Halt) # aborta y cae a la shell de rescate (sin SSH) — la integridad comprometida NO levanta el entorno. # -# Es la contraparte de validación de la integración canónica firmada (capa de producto, baseline núcleo -# intacto). No ensambla el producto "de verdad" — overlaya el arje-zero con gate + el seed firmado sobre -# una copia del product-rootfs sellado, como spike, antes de integrarlo en hammer-bootstrap. -# -# Uso: PRODUCT= ./scripts/attest-boot-test.sh # caso íntegro (espera SSH OK) -# PRODUCT= TAMPER=1 ./scripts/attest-boot-test.sh # caso comprometido (espera HALT) +# DOS MODOS: +# (A) REAL (preferido) — bootea el `product-attested-rootfs` que produce `hammer bootstrap product +# --attest` (init con gate + seed firmada ya hidratados). Es el camino de PRODUCCIÓN, no un spike. +# Uso: PRODUCT_ATTESTED= ./scripts/attest-boot-test.sh +# (B) SPIKE (fallback) — overlaya a mano el arje-zero con gate + firma el seed sobre una copia del +# `product-rootfs`. Útil cuando aún no se selló el atestado. +# Uso: PRODUCT= ./scripts/attest-boot-test.sh +# Con TAMPER=1 (en cualquier modo) se altera 1 byte de un binario crítico DESPUÉS de firmar ⇒ HALT. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)"; cd "$ROOT" KERNEL="${KERNEL:-$(ls store/*-linux/boot/bzImage 2>/dev/null | head -1)}" @@ -18,35 +20,37 @@ MEM="${MEM:-2048}"; KVM="${KVM:-1}"; PORT="${PORT:-2226}"; DEADLINE="${DEADLINE: WORK="$ROOT/work/attest-boot"; RFS="$WORK/rootfs"; LOG="$WORK/console.log" pick(){ ls -dt store/*-"$1" 2>/dev/null | head -1; } -if [ -n "${PRODUCT:-}" ]; then P=$(ls -d store/"${PRODUCT#b3:}"*-product-rootfs 2>/dev/null | head -1); else P=$(pick product-rootfs); fi -GATED=$(pick arje-zero-attest); PKGR=$(pick arje-packager) -[ -n "$P" ] && [ -d "$P" ] || { echo "falta product-rootfs"; exit 1; } -[ -n "$GATED" ] && [ -e "$GATED/usr/bin/arje-zero" ] || { echo "falta arje-zero-attest (construí recipes/arje-zero-attest.toml)"; exit 1; } -[ -n "$PKGR" ] && [ -e "$PKGR/usr/bin/arje-packager" ] || { echo "falta arje-packager"; exit 1; } -echo "==> product $P"; echo "==> gated PID1 $GATED"; echo "==> packager $PKGR" +rm -rf "$WORK"; mkdir -p "$WORK" -# copia escribible -rm -rf "$WORK"; mkdir -p "$WORK"; cp -a "$P"/. "$RFS"/; chmod -R u+w "$RFS"; rm -rf "$RFS/.hammer" "$RFS/ente/attest.json" - -# override del PID1 por el arje-zero CON gate (romper hardlink) -rm -f "$RFS/usr/bin/arje-zero"; cp "$GATED/usr/bin/arje-zero" "$RFS/usr/bin/arje-zero"; chmod 0755 "$RFS/usr/bin/arje-zero" - -# seed con attest_policy=halt como ENTRADA del firmador -python3 - "$RFS/ente/seed.card.json" "$WORK/seed-in.json" <<'PY' +if [ -n "${PRODUCT_ATTESTED:-}" ] || { [ -z "${PRODUCT:-}" ] && PA=$(pick product-attested-rootfs) && [ -n "$PA" ]; }; then + # ── MODO A (REAL): el product-attested-rootfs ya trae el init con gate + el seed firmado ────────── + if [ -n "${PRODUCT_ATTESTED:-}" ]; then PA=$(ls -d store/"${PRODUCT_ATTESTED#b3:}"*-product-attested-rootfs 2>/dev/null | head -1); fi + [ -n "${PA:-}" ] && [ -d "$PA" ] || { echo "falta product-attested-rootfs (corré: hammer bootstrap product --rootfs --attest)"; exit 1; } + echo "==> MODO REAL · product-attested $PA" + cp -a "$PA"/. "$RFS"/; chmod -R u+w "$RFS"; rm -rf "$RFS/.hammer" + echo "==> init con gate + seed firmada ya hidratados por hammer-bootstrap (sin overlay manual)" +else + # ── MODO B (SPIKE): overlay manual del gate + firma sobre el product-rootfs ─────────────────────── + if [ -n "${PRODUCT:-}" ]; then P=$(ls -d store/"${PRODUCT#b3:}"*-product-rootfs 2>/dev/null | head -1); else P=$(pick product-rootfs); fi + GATED=$(pick arje-zero-attest); PKGR=$(pick arje-packager) + [ -n "$P" ] && [ -d "$P" ] || { echo "falta product-rootfs"; exit 1; } + [ -n "$GATED" ] && [ -e "$GATED/usr/bin/arje-zero" ] || { echo "falta arje-zero-attest (construí recipes/arje-zero-attest.toml)"; exit 1; } + [ -n "$PKGR" ] && [ -e "$PKGR/usr/bin/arje-packager" ] || { echo "falta arje-packager"; exit 1; } + echo "==> MODO SPIKE · product $P · gated $GATED · packager $PKGR" + cp -a "$P"/. "$RFS"/; chmod -R u+w "$RFS"; rm -rf "$RFS/.hammer" "$RFS/ente/attest.json" + rm -f "$RFS/usr/bin/arje-zero"; cp "$GATED/usr/bin/arje-zero" "$RFS/usr/bin/arje-zero"; chmod 0755 "$RFS/usr/bin/arje-zero" + python3 - "$RFS/ente/seed.card.json" "$WORK/seed-in.json" <<'PY' import json,sys d=json.load(open(sys.argv[1])); d["attest_policy"]="halt" json.dump(d,open(sys.argv[2],"w"),indent=2) PY - -# rootkey fija (firmas Ed25519 deterministas ⇒ reproducible) -printf 'hammer-attest-dev-rootkey-0001!!' > "$WORK/rootkey.bin" - -# FIRMAR: concesiones sobre el BLAKE3 de los binarios críticos REALES del staging -"$PKGR/usr/bin/arje-packager" --seed "$WORK/seed-in.json" --rootkey "$WORK/rootkey.bin" \ - --bin arje-zero="$RFS/usr/bin/arje-zero" --bin hammerd="$RFS/usr/bin/hammerd" \ - --bin console-getty="$RFS/bin/busybox" --bin sshd="$RFS/bin/busybox" \ - --seed-out "$RFS/ente/seed.card.json" 2>&1 | sed 's/^/ /' -echo "==> seed firmado inyectado en /ente/seed.card.json (attest_policy=halt)" + printf 'hammer-attest-dev-rootkey-0001!!' > "$WORK/rootkey.bin" # rootkey fija ⇒ firmas deterministas + "$PKGR/usr/bin/arje-packager" --seed "$WORK/seed-in.json" --rootkey "$WORK/rootkey.bin" \ + --bin arje-zero="$RFS/usr/bin/arje-zero" --bin hammerd="$RFS/usr/bin/hammerd" \ + --bin console-getty="$RFS/bin/busybox" --bin sshd="$RFS/bin/busybox" \ + --seed-out "$RFS/ente/seed.card.json" 2>&1 | sed 's/^/ /' + echo "==> seed firmado inyectado en /ente/seed.card.json (attest_policy=halt)" +fi # TAMPER: alterar 1 byte de un binario crítico DESPUÉS de firmar (su hash ya no casa la concesión) if [ "${TAMPER:-0}" = 1 ]; then