atestación de integridad al arranque — mitad de hammer (I4 / Etapa D)

Por la regla de oro del plan arje↔hammer (PLAN-ATESTACION-Y-HAMMER.md §B.1):
hammer es dueño del expected_hash + TrustStore; arje del gate al boot. Esta
es la mitad de hammer — PRODUCIR y auto-verificar el manifiesto de hashes
esperados que el gate A2 de arje consumirá ("el expected_hash de un .swm ES
el BLAKE3 que arje atesta").

- hammer-core: ArtifactHash::of_file = BLAKE3 CRUDO del fichero (sin framing),
  el mismo que computa arje-cas::blake3_of ⇒ casa con quien recompute el hash.
- hammer-bootstrap: el producto emite /ente/attest.json con el BLAKE3 esperado
  de los binarios críticos (ATTEST_PATHS: arje-zero PID1, hammerd, busybox,
  sshd, netup, coreutils). Fichero APARTE de la seed card ⇒ no toca el schema
  de card-core y el producto bootea igual con el arje-zero actual (ignora A2).
  verify_attestation() recomputa y compara (ok/diverge/falta). product hash v3.
- CLI: `hammer attest --rootfs <dir>` — el gate de integridad hecho hoy por
  hammer ("reproducir, no confiar"); exit≠0 si algo diverge/falta.
- 37 tests verde (manifiesto + verify + tamper + missing).

Validado: el producto emite attest.json con los 6 binarios críticos; `hammer
attest` ✓ los 6; alterar 1 byte de coreutils ⇒ "✗ DIVERGE" exit 1; el producto
con attest.json sigue booteando + SSH (arje ignora el fichero). El gate al boot
(A2) es la mitad de tawasuyu/arje-zero (cross-repo, fuera de este commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:45:14 -04:00
co-authored by Claude Opus 4.8
parent 2d18ed6dcd
commit 0d11d68b44
3 changed files with 191 additions and 1 deletions
+146 -1
View File
@@ -445,6 +445,101 @@ const PRODUCT_GROUP: &str = "root:x:0:\nsshd:x:74:\n";
/// arje monta), authorized_keys provisionadas en deploy.
const SSHD_CONFIG: &str = "Port 22\nListenAddress 0.0.0.0\nPermitRootLogin prohibit-password\nPubkeyAuthentication yes\nPasswordAuthentication no\nAuthorizedKeysFile /root/.ssh/authorized_keys\nPidFile /run/sshd.pid\nSubsystem sftp /usr/libexec/sftp-server\n";
// ── Atestación de integridad al arranque (I4 / Etapa D, mitad de HAMMER) ─────────────────────────
//
// Por la regla de oro del [plan arje↔hammer] (`PLAN-ATESTACION-Y-HAMMER.md` §B.1): hammer es dueño del
// **`expected_hash` + firma + TrustStore**; arje es dueño del **gate al boot** (computa `blake3` de cada
// binario crítico ANTES de incarnar y, si no casa, `halt`). Esta es la mitad de hammer: PRODUCIR el
// manifiesto de hashes esperados (`/ente/attest.json`) que el gate A2 de arje verifica — "el
// `expected_hash` de un `.swm` ES el BLAKE3 que arje atesta". El hash es `blake3` CRUDO del fichero
// (`ArtifactHash::of_file`), el mismo que computa `arje-cas::blake3_of`. La firma con la rootkey del
// seed (A1, `arje-packager`) es una capa posterior; el manifiesto ya es verificable hoy por `hammer
// attest` (auto-validación: "reproducir, no confiar"). Es un fichero APARTE de la seed card ⇒ no toca
// el schema de `card-core` y el producto bootea igual con el arje-zero actual (que aún ignora A2).
/// Binarios críticos cuyo BLAKE3 hammer atesta (la raíz-de-confianza-ejecutable del producto): PID 1,
/// el diario, el shell/getty, el daemon de red expuesto, el configurador de red y el userland multicall.
const ATTEST_PATHS: &[&str] = &[
"usr/bin/arje-zero", // PID 1 — la raíz
"usr/bin/hammerd", // diario + bus
"bin/busybox", // shell/getty + exec de varios cards
"usr/sbin/sshd", // daemon SSH (superficie de red)
"usr/bin/netup", // configurador de red
"usr/bin/coreutils", // userland Rust multicall
];
/// Una entrada del manifiesto de atestación: ruta (relativa a la raíz) → BLAKE3 esperado del fichero.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AttestEntry {
pub path: String,
pub b3: String,
}
/// Manifiesto de atestación que hammer emite en `/ente/attest.json`. `algo` fijo a `blake3` (crudo).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AttestManifest {
pub schema_version: u32,
pub algo: String,
pub entries: Vec<AttestEntry>,
}
/// Veredicto de [`verify_attestation`]: qué binarios casaron, cuáles divergieron y cuáles faltan.
#[derive(Debug, Clone, Default)]
pub struct AttestReport {
pub ok: Vec<String>,
pub mismatched: Vec<String>,
pub missing: Vec<String>,
}
impl AttestReport {
/// Pasa sólo si todo binario listado existe y su BLAKE3 casa.
pub fn passed(&self) -> bool {
self.mismatched.is_empty() && self.missing.is_empty()
}
}
/// Construye el manifiesto de atestación de un árbol `root`: para cada ruta crítica PRESENTE, su BLAKE3
/// crudo. Determinista (función pura de los bytes del binario).
fn build_attestation(root: &Path) -> Result<AttestManifest> {
let mut entries = Vec::new();
for rel in ATTEST_PATHS {
let p = root.join(rel);
if p.is_file() {
let h = ArtifactHash::of_file(&p).map_err(Error::Io)?;
entries.push(AttestEntry { path: format!("/{rel}"), b3: h.as_str().to_string() });
}
}
Ok(AttestManifest { schema_version: 1, algo: "blake3".into(), entries })
}
/// Verifica un árbol FHS (`rootfs_dir`, p.ej. un product-rootfs hidratado) contra su propio
/// `/ente/attest.json`: recomputa el BLAKE3 de cada binario listado y lo compara. Es lo que el gate de
/// arje hará al boot, hecho hoy por hammer (auto-validación de integridad del producto).
pub fn verify_attestation(rootfs_dir: &Path) -> Result<AttestReport> {
let manifest_path = rootfs_dir.join("ente/attest.json");
let raw = std::fs::read_to_string(&manifest_path).map_err(|e| {
Error::Other(format!("no pude leer {}: {e}", manifest_path.display()))
})?;
let manifest: AttestManifest = serde_json::from_str(&raw)
.map_err(|e| Error::Other(format!("attest.json inválido: {e}")))?;
let mut report = AttestReport::default();
for entry in &manifest.entries {
let rel = entry.path.trim_start_matches('/');
let p = rootfs_dir.join(rel);
if !p.is_file() {
report.missing.push(entry.path.clone());
continue;
}
let got = ArtifactHash::of_file(&p).map_err(Error::Io)?;
if got.as_str() == entry.b3 {
report.ok.push(entry.path.clone());
} else {
report.mismatched.push(entry.path.clone());
}
}
Ok(report)
}
/// Seed de producto = la seed base con los cards de servicio APENDADOS al `genesis`. Composición vía
/// `serde_json` (hammer sigue autocontenido: no depende de `card-core`). Determinista ⇒ el
/// `product-rootfs` es reproducible. El núcleo (hammerd + getty) se preserva tal cual.
@@ -471,13 +566,14 @@ fn product_rootfs_hash(
seed: &str,
) -> RootfsHash {
let mut inputs: Vec<Vec<u8>> = vec![
b"hammer-product-rootfs-v2".to_vec(),
b"hammer-product-rootfs-v3".to_vec(), // v3: + manifiesto de atestación (/ente/attest.json)
base.as_str().as_bytes().to_vec(),
];
for (name, h) in userland.iter().chain(services.iter()) {
inputs.push(name.as_bytes().to_vec());
inputs.push(h.as_str().as_bytes().to_vec());
}
inputs.push(ATTEST_PATHS.join(",").into_bytes()); // el set atestado entra al hash
inputs.push(seed.as_bytes().to_vec());
inputs.push(PRODUCT_PASSWD.as_bytes().to_vec());
inputs.push(PRODUCT_GROUP.as_bytes().to_vec());
@@ -569,6 +665,14 @@ fn assemble_product_rootfs(
// la imagen monta vda3→/store y vda4→/var/lib/hammer). `/var/lib/hammer` ya viene de la base.
std::fs::create_dir_all(staging.join("store"))?;
std::fs::create_dir_all(staging.join("var/lib/hammer"))?;
// Manifiesto de atestación (I4): BLAKE3 esperado de los binarios críticos ya hidratados ⇒ el gate
// de arje lo verifica al boot. Se computa al final, sobre el árbol ya ensamblado. Fichero aparte de
// la seed card (no toca el schema de card-core).
let attest = build_attestation(staging)?;
let attest_json = serde_json::to_string_pretty(&attest)
.map_err(|e| Error::Other(format!("serializar attest.json: {e}")))?;
std::fs::write(staging.join("ente/attest.json"), attest_json)?;
Ok(())
}
@@ -1673,6 +1777,47 @@ mod tests {
// Mountpoints de disco (el producto es disk-ready): /store + /var/lib/hammer existen vacíos.
assert!(staging.join("store").is_dir() && staging.join("var/lib/hammer").is_dir(),
"/store y /var/lib/hammer como mountpoints del disco de producto");
// Atestación: el producto emite /ente/attest.json y se auto-verifica íntegro.
assert!(staging.join("ente/attest.json").is_file(), "manifiesto de atestación emitido");
let att = verify_attestation(&staging).unwrap();
assert!(att.passed(), "el product-rootfs recién ensamblado pasa su propia atestación");
// cubre los binarios presentes del set crítico (hammerd, busybox, sshd, netup, coreutils).
assert!(att.ok.iter().any(|p| p == "/usr/sbin/sshd") && att.ok.iter().any(|p| p == "/usr/bin/coreutils"));
}
#[test]
fn attestation_manifest_emits_and_verifies_and_catches_tamper() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("rootfs");
for d in ["usr/bin", "bin", "usr/sbin", "ente"] {
std::fs::create_dir_all(root.join(d)).unwrap();
}
std::fs::write(root.join("usr/bin/hammerd"), b"\x7fELF-hammerd").unwrap();
std::fs::write(root.join("bin/busybox"), b"\x7fELF-busybox").unwrap();
std::fs::write(root.join("usr/sbin/sshd"), b"\x7fELF-sshd").unwrap();
// arje-zero/netup/coreutils ausentes ⇒ no se listan (sólo binarios presentes).
let manifest = build_attestation(&root).unwrap();
assert_eq!(manifest.algo, "blake3");
let paths: Vec<&str> = manifest.entries.iter().map(|e| e.path.as_str()).collect();
assert!(paths.contains(&"/usr/bin/hammerd") && paths.contains(&"/bin/busybox") && paths.contains(&"/usr/sbin/sshd"));
assert!(!paths.contains(&"/usr/bin/arje-zero"), "binario ausente no se atesta");
std::fs::write(root.join("ente/attest.json"), serde_json::to_string_pretty(&manifest).unwrap()).unwrap();
// verificación íntegra: pasa.
let rep = verify_attestation(&root).unwrap();
assert!(rep.passed() && rep.ok.len() == manifest.entries.len());
// manipular un binario ⇒ DIVERGE (el gate de arje haría halt).
std::fs::write(root.join("bin/busybox"), b"\x7fELF-TAMPERED-longer").unwrap();
let rep2 = verify_attestation(&root).unwrap();
assert!(!rep2.passed());
assert_eq!(rep2.mismatched, vec!["/bin/busybox".to_string()]);
// borrar un binario crítico ⇒ FALTA.
std::fs::remove_file(root.join("usr/sbin/sshd")).unwrap();
let rep3 = verify_attestation(&root).unwrap();
assert!(rep3.missing.contains(&"/usr/sbin/sshd".to_string()) && !rep3.passed());
}
#[test]
+33
View File
@@ -33,6 +33,15 @@ enum Cmd {
/// Ruta a la receta TOML.
recipe: String,
},
/// [Atestación] Verifica un árbol FHS (p.ej. un product-rootfs hidratado) contra su
/// `/ente/attest.json`: recomputa el BLAKE3 de cada binario crítico y lo compara. Es el gate de
/// integridad que arje aplicará al boot, hecho hoy por hammer ("reproducir, no confiar"). Exit≠0
/// si algún binario diverge o falta.
Attest {
/// Directorio raíz del rootfs a verificar (contiene `ente/attest.json`).
#[arg(long)]
rootfs: String,
},
/// [Fase 1] Proyecta un artefacto del store al FHS (real o de overlay).
Hydrate {
/// Hash del artefacto (acepta prefijos cortos; con o sin `b3:`).
@@ -415,6 +424,30 @@ fn main() -> anyhow::Result<()> {
let hash = hammer_build::build(&recipe, &cfg, &store)?;
println!("{hash}");
}
Cmd::Attest { rootfs } => {
let report = hammer_bootstrap::verify_attestation(std::path::Path::new(&rootfs))?;
for p in &report.ok {
println!("{p}");
}
for p in &report.missing {
println!(" ✗ FALTA {p}");
}
for p in &report.mismatched {
println!(" ✗ DIVERGE {p}");
}
if report.passed() {
println!(
"✓ ATESTACIÓN OK: {} binarios críticos casan su BLAKE3 esperado",
report.ok.len()
);
} else {
anyhow::bail!(
"✗ ATESTACIÓN FALLÓ: {} diverge(n), {} falta(n) — integridad comprometida",
report.mismatched.len(),
report.missing.len()
);
}
}
Cmd::Hydrate { hash, into, link } => {
let store = hammer_core::Store::open(&cli.store)?;
let mode = match link.as_str() {
+12
View File
@@ -85,6 +85,18 @@ impl ArtifactHash {
}
Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex())))
}
/// BLAKE3 **crudo** del contenido de un fichero (sin framing): exactamente `blake3(bytes)`. Es lo
/// que computa el `blake3_of` de `arje-cas` y el `expected_hash` de un `.swm`, así que sirve para
/// la atestación de integridad al arranque (el gate que arje aplica antes de incarnar). A diferencia
/// de [`of_inputs`](Self::of_inputs) (length-prefijado) y [`of_tree`](Self::of_tree) (árbol con
/// rutas/modo), aquí el hash es del binario tal cual ⇒ casa con quien recompute `blake3` del fichero.
pub fn of_file(path: &Path) -> std::io::Result<ArtifactHash> {
let mut f = std::fs::File::open(path)?;
let mut hasher = blake3::Hasher::new();
std::io::copy(&mut f, &mut hasher)?;
Ok(ArtifactHash(format!("b3:{}", hasher.finalize().to_hex())))
}
}
/// Recorre `root` recursivamente acumulando rutas **relativas a `root`** en `out`. No sigue