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
+25 -7
View File
@@ -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<String>,
},
/// [Stage 2] Ancla el content-hash del rootfs de Stage 1 como referencia de reproducibilidad
/// (anota la línea del manifiesto). Con `--verify <hash>` 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 } => {
+34 -30
View File
@@ -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=<hash> ./scripts/attest-boot-test.sh # caso íntegro (espera SSH OK)
# PRODUCT=<hash> 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=<hash> ./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=<hash> ./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 <base> --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