hammer-bootstrap: producto atestado cableado en product() (I4 / Etapa D, estructurar bien #1)

`product_attested()` + CLI `hammer bootstrap product --attest [--policy] [--rootkey]`:
estructura el spike attest-boot-test.sh dentro de hammer-bootstrap. Hidrata el init
CON gate (arje-zero-attest) sobre /usr/bin/arje-zero, fija attest_policy, deriva los
--bin label=path de la PROPIA seed (PID1 + execs Native, DFS) y FIRMA con arje-packager
(host, static musl) → seed firmada en /ente/seed.card.json; regenera /ente/attest.json
coherente con el init nuevo y sella product-attested-rootfs APARTE (núcleo+product base
intactos, Separación Mecanismo/Política).

- AttestConfig{policy, rootkey:[u8;32]}; DEV_ATTEST_ROOTKEY determinista ⇒ firmas
  Ed25519 reproducibles ⇒ árbol sellado reproducible. product_attested_hash v1.
- GOTCHA hardlinks read-only del store: romper /ente/seed.card.json antes de que el
  packager escriba (EACCES) y /ente/attest.json antes de reescribir; tmp de firma en
  staging/.attest-build se limpia antes de sellar.
- critical_bins_from_seed: getty+sshd comparten /bin/busybox ⇒ 3 concesiones por hash.
- Validado en host contra los sellos reales (3 concesiones, init 13MB, hammer attest ✓).
- Test hermético assemble_attested_swaps_gate_signs_seed_and_regenerates_attest (packager
  sintético). 40 tests verde.
- scripts/attest-boot-test.sh: MODO A REAL (PRODUCT_ATTESTED=<hash> bootea el artefacto
  real) + MODO B SPIKE fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 20:01:41 -04:00
co-authored by Claude Opus 4.8
parent a44d05e7e2
commit 83d589a952
3 changed files with 446 additions and 37 deletions
+387
View File
@@ -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<RootfsHash> {
// 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