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