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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user