diff --git a/crates/hammer-bootstrap/src/lib.rs b/crates/hammer-bootstrap/src/lib.rs index 9b402bd2..f7c8e224 100644 --- a/crates/hammer-bootstrap/src/lib.rs +++ b/crates/hammer-bootstrap/src/lib.rs @@ -789,7 +789,7 @@ fn product_attested_hash( cfg: &AttestConfig, ) -> RootfsHash { ArtifactHash::of_inputs(&[ - b"hammer-product-attested-v1", + b"hammer-product-attested-v2", // v2: + ancla soberana externa /etc/arje/rootkey.pub (hardening #3) base_product.as_str().as_bytes(), gated_init.as_str().as_bytes(), packager.as_str().as_bytes(), @@ -831,9 +831,10 @@ fn critical_bins_from_seed(seed: &serde_json::Value, staging: &Path) -> Vec<(Str /// 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`). +/// binario crítico sobre su BLAKE3), ancla la pubkey en `/etc/arje/rootkey.pub` (ancla soberana externa, +/// hardening #3) 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, @@ -911,6 +912,30 @@ fn assemble_attested_product_rootfs( ))); } + // 5b) ANCLA SOBERANA EXTERNA (hardening #3): escribir la pubkey de la rootkey en /etc/arje/rootkey.pub + // (32 bytes raw). El gate (`ancla_externa()` en attest_gate.rs) la prefiere sobre la + // `attest_rootkey` declarada en el seed ⇒ un seed reescrito con OTRA rootkey cae en AutorNoConfiable + // (las concesiones no las firmó el ancla). Cierra el hueco que el WARN "sin ancla soberana externa" + // señala. La pubkey se LEE del seed ya firmado (`attest_rootkey`, lo que el packager computó de + // nuestra rootkey privada) ⇒ cero criptografía nueva en hammer. + let signed_raw = std::fs::read_to_string(&seed_path) + .map_err(|e| Error::Other(format!("releer seed firmado: {e}")))?; + let signed: serde_json::Value = serde_json::from_str(&signed_raw) + .map_err(|e| Error::Other(format!("seed firmado inválido: {e}")))?; + let pubkey: Vec = signed + .get("attest_rootkey") + .and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|x| x.as_u64().map(|n| n as u8)).collect::>()) + .ok_or_else(|| Error::Other("seed firmado sin attest_rootkey de 32 bytes".into()))?; + if pubkey.len() != 32 { + return Err(Error::Other(format!( + "attest_rootkey del seed firmado no es de 32 bytes (es {})", + pubkey.len() + ))); + } + std::fs::create_dir_all(staging.join("etc/arje"))?; + std::fs::write(staging.join("etc/arje/rootkey.pub"), &pubkey)?; + // 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. @@ -2136,9 +2161,13 @@ mod tests { 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). + // 3) ANCLA SOBERANA EXTERNA: /etc/arje/rootkey.pub = la pubkey del seed firmado (32 bytes raw). + let anchor = std::fs::read(staging.join("etc/arje/rootkey.pub")).unwrap(); + assert_eq!(anchor.len(), 32, "rootkey.pub son 32 bytes raw"); + assert_eq!(anchor, vec![7u8; 32], "la pubkey del seed firmado anclada fuera de la Card"); + // 4) /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ó. + // 5) el tmp de build se limpió. assert!(!staging.join(".attest-build").exists()); } @@ -2157,7 +2186,7 @@ while a: else: pass d=json.load(open(seed)) d["attest"]=[{"label":b.split("=",1)[0]} for b in bins] -d["attest_rootkey"]="deadbeef" +d["attest_rootkey"]=[7]*32 # pubkey de 32 bytes (como la del packager real) 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)) diff --git a/scripts/attest-boot-test.sh b/scripts/attest-boot-test.sh index 273227de..4aa38baf 100755 --- a/scripts/attest-boot-test.sh +++ b/scripts/attest-boot-test.sh @@ -58,6 +58,27 @@ if [ "${TAMPER:-0}" = 1 ]; then echo "==> TAMPER: /usr/bin/hammerd alterado tras la firma — el gate debe HALT" fi +# SEED_REWRITE: el ataque que el ANCLA SOBERANA (#3) cierra. Un atacante reescribe el seed firmando los +# binarios con SU rootkey y declara su pubkey en attest_rootkey (seed auto-consistente). Pero /etc/arje/ +# rootkey.pub (la pubkey LEGÍTIMA, fuera de la Card) queda intacta ⇒ el gate exige firmas del ancla ⇒ las +# concesiones del atacante caen en AutorNoConfiable ⇒ Halt. Sin ancla, este seed arrancaría (hueco de A2). +if [ "${SEED_REWRITE:-0}" = 1 ]; then + PKGR=$(pick arje-packager) + [ -n "$PKGR" ] && [ -e "$PKGR/usr/bin/arje-packager" ] || { echo "falta arje-packager para SEED_REWRITE"; exit 1; } + [ -e "$RFS/etc/arje/rootkey.pub" ] || { echo "el product-attested-rootfs no trae /etc/arje/rootkey.pub (¿v2?)"; exit 1; } + python3 - "$RFS/ente/seed.card.json" "$WORK/rewrite-in.json" <<'PY' +import json,sys +d=json.load(open(sys.argv[1])); d.pop("attest",None); d.pop("attest_rootkey",None); d["attest_policy"]="halt" +json.dump(d,open(sys.argv[2],"w"),indent=2) +PY + printf 'hammer-EVIL-rootkey-attacker-001' > "$WORK/evil.bin" # 32 bytes, ≠ la rootkey legítima + "$PKGR/usr/bin/arje-packager" --seed "$WORK/rewrite-in.json" --rootkey "$WORK/evil.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" >/dev/null 2>&1 + echo "==> SEED_REWRITE: seed re-firmado con rootkey ATACANTE; /etc/arje/rootkey.pub legítima intacta — el gate debe HALT" +fi + # authorized_keys de prueba + empaquetar (root-owned) KG=$(ls store/*-openssh/usr/bin/ssh-keygen|head -1); "$KG" -t ed25519 -N '' -f "$WORK/clientkey" -q install -d -m 0700 "$RFS/root/.ssh"; cat "$WORK/clientkey.pub" > "$RFS/root/.ssh/authorized_keys"; chmod 0600 "$RFS/root/.ssh/authorized_keys" @@ -80,11 +101,12 @@ sleep 2; echo "---- consola: líneas de atestación ----"; grep -iE "atestaci|at # veredicto echo "========================================" -if [ "${TAMPER:-0}" = 1 ]; then +if [ "${TAMPER:-0}" = 1 ] || [ "${SEED_REWRITE:-0}" = 1 ]; then + caso=$([ "${SEED_REWRITE:-0}" = 1 ] && echo "SEED_REWRITE" || echo "TAMPER") if [ "$ssh_ok" = 0 ] && grep -qiE "ATESTACIÓN FALLÓ|atestación al arranque falló|ARRANQUE FALLIDO|rescate" "$LOG"; then - echo "*** TAMPER OK — el gate detectó la manipulación y HALTeó (sin SSH) ***"; exit 0 + echo "*** $caso OK — el gate detectó el ataque y HALTeó (sin SSH) ***"; exit 0 else - echo "*** TAMPER FALLÓ — el sistema arrancó pese al binario alterado (ssh_ok=$ssh_ok) ***"; exit 1 + echo "*** $caso FALLÓ — el sistema arrancó pese al ataque (ssh_ok=$ssh_ok) ***"; exit 1 fi else oks=$(grep -c "atestación ✓" "$LOG" 2>/dev/null || echo 0)