tawasuyu

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 1572Shell 203Rust 109Markdown 993728 Definitions

Etapa F paquetería #4: firma del release (índice del repo firmado)

Cierra el hueco de seguridad: hoy se firmaba cada .swm (autoría del paquete) pero NO el
catálogo ⇒ un atacante podía añadir/quitar/intercambiar entradas del index.json. Firmar el
release ancla qué paquetes/versiones/hashes existen.

- hammer-core/sign.rs: extraídos `KeyPair::sign_raw` + `verify_raw` genéricos (bytes canónicos
  arbitrarios); Swm::{sign,verify_signature} ahora los reusan (DRY, sin cambio de comportamiento).
- hammer-core/repo.rs: `RepoIndex.signature` (Ed25519 sobre la lista de paquetes canónica,
  excluye la propia firma) + `sign`/`verify_signature`. `upsert` INVALIDA la firma (cualquier
  cambio al catálogo ⇒ re-firmar). 4 tests (sign→verify, survive save/load, upsert-invalida,
  tamper→BadSig).
- hammer-cli: `repo sign --key` / `repo verify`; `install` VERIFICA el release antes de resolver
  (BadSig ⇒ aborta "el índice fue manipulado"); `repo list` muestra si está firmado.
- Validado E2E host: sin firmar→firmar→trusted→install lo ve; MANIPULAR el índice sin re-firmar
  ⇒ install ABORTA; re-publicar invalida la firma. 31 suites verde.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

977f07ca6c98fa7fb0297bca0c6da353186f9c11 · by sergio · (4 months ago) · parent b89e2dba5 · browse the tree at this commit

Semantic delta · 19

crates/hammer-cli/src/main.rs

crates/hammer-core/src/repo.rs

crates/hammer-core/src/sign.rs

4 files · +251 −42

diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs
index 8296ddb9..7d78b04e 100644
--- a/crates/hammer-cli/src/main.rs
+++ b/crates/hammer-cli/src/main.rs
@@ -337,6 +337,26 @@ enum RepoCmd {
         #[arg(long, default_value = DEFAULT_REPO)]
         repo: PathBuf,
     },
+    /// Firma el ÍNDICE entero (release): ancla qué paquetes/versiones/hashes existen. Re-firmá
+    /// tras publicar (cada `pack --repo` invalida la firma previa).
+    Sign {
+        #[arg(long, default_value = DEFAULT_REPO)]
+        repo: PathBuf,
+        /// Clave privada Ed25519 con la que firmar el release.
+        #[arg(long)]
+        key: PathBuf,
+        /// Autor de la firma. Default: basename de la clave sin `.ed25519`.
+        #[arg(long)]
+        by: Option<String>,
+    },
+    /// Verifica la firma del release del repositorio contra las claves de confianza.
+    Verify {
+        #[arg(long, default_value = DEFAULT_REPO)]
+        repo: PathBuf,
+        /// Directorio de claves de confianza. Default `/var/lib/hammer/trust`.
+        #[arg(long)]
+        trust: Option<PathBuf>,
+    },
 }
 
 #[derive(Subcommand)]
@@ -767,6 +787,8 @@ fn main() -> anyhow::Result<()> {
         }
         Cmd::Repo { sub } => match sub {
             RepoCmd::List { repo } => run_repo_list(&repo)?,
+            RepoCmd::Sign { repo, key, by } => run_repo_sign(&repo, &key, by.as_deref())?,
+            RepoCmd::Verify { repo, trust } => run_repo_verify(&repo, trust.as_deref())?,
         },
         Cmd::Ai {
             intent,
@@ -1319,6 +1341,25 @@ fn run_install(
     state_root: Option<&std::path::Path>,
 ) -> anyhow::Result<()> {
     let index = hammer_core::RepoIndex::load(repo_dir)?;
+
+    // Verificación del RELEASE (firma del catálogo entero): si el índice está firmado, una firma
+    // mala = índice manipulado ⇒ abortamos antes de tocar nada. Sin firma se informa; con --trust
+    // se valida la autoría. Esto es ortogonal a la firma de cada `.swm`.
+    {
+        let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
+        let store = hammer_core::TrustStore::load(trust_dir)?;
+        match index.verify_signature(&store) {
+            hammer_core::SigStatus::Trusted { by } => eprintln!("release: trusted (by {by})"),
+            hammer_core::SigStatus::UnknownKey { by } => {
+                eprintln!("release: unknown-key (by {by}) — autoría del catálogo no confiada")
+            }
+            hammer_core::SigStatus::BadSig { by, reason } => {
+                anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado")
+            }
+            hammer_core::SigStatus::Unsigned => eprintln!("release: sin firmar"),
+        }
+    }
+
     let entry = index.find(name).ok_or_else(|| {
         let avail: Vec<&str> = index.packages.iter().map(|p| p.name.as_str()).collect();
         anyhow::anyhow!(
@@ -1410,6 +1451,64 @@ fn source_patch_of(swm: &hammer_core::Swm) -> Option<&hammer_core::Mutation> {
         .find(|m| matches!(m, hammer_core::Mutation::SourcePatch { .. }))
 }
 
+/// Firma el índice de un repo (release). Re-firmá tras publicar.
+fn run_repo_sign(
+    repo_dir: &std::path::Path,
+    key: &std::path::Path,
+    by: Option<&str>,
+) -> anyhow::Result<()> {
+    let mut index = hammer_core::RepoIndex::load(repo_dir)?;
+    if index.packages.is_empty() {
+        anyhow::bail!("repo {} vacío: nada que firmar", repo_dir.display());
+    }
+    let priv_b64 = std::fs::read_to_string(key)
+        .map_err(|e| anyhow::anyhow!("no pude leer la clave {}: {e}", key.display()))?;
+    let kp = hammer_core::KeyPair::from_private_b64(&priv_b64)?;
+    let by = by.map(|s| s.to_string()).unwrap_or_else(|| {
+        key.file_name()
+            .map(|f| f.to_string_lossy().trim_end_matches(".ed25519").to_string())
+            .unwrap_or_else(|| "anon".into())
+    });
+    index.sign(&kp, &by);
+    index.save(repo_dir)?;
+    println!(
+        "release firmado por {by} — {} paquete(s) en {}",
+        index.packages.len(),
+        repo_dir.display()
+    );
+    Ok(())
+}
+
+/// Verifica la firma del release de un repo contra el TrustStore.
+fn run_repo_verify(
+    repo_dir: &std::path::Path,
+    trust: Option<&std::path::Path>,
+) -> anyhow::Result<()> {
+    let index = hammer_core::RepoIndex::load(repo_dir)?;
+    let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR));
+    let store = hammer_core::TrustStore::load(trust_dir)?;
+    match index.verify_signature(&store) {
+        hammer_core::SigStatus::Trusted { by } => {
+            println!("release: trusted (by {by}) — {} paquete(s)", index.packages.len());
+            Ok(())
+        }
+        hammer_core::SigStatus::UnknownKey { by } => {
+            println!(
+                "release: unknown-key (by {by}) — añadí su clave a {} para confiar",
+                trust_dir.display()
+            );
+            Ok(())
+        }
+        hammer_core::SigStatus::BadSig { by, reason } => {
+            anyhow::bail!("release: BAD-SIG (by {by}): {reason} — el índice fue manipulado");
+        }
+        hammer_core::SigStatus::Unsigned => {
+            println!("release: sin firmar (firmá con `hammer repo sign`)");
+            Ok(())
+        }
+    }
+}
+
 /// Lista los paquetes publicados en un repositorio.
 fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> {
     let index = hammer_core::RepoIndex::load(repo_dir)?;
@@ -1417,7 +1516,11 @@ fn run_repo_list(repo_dir: &std::path::Path) -> anyhow::Result<()> {
         println!("repo {} vacío (sin index.json o sin paquetes)", repo_dir.display());
         return Ok(());
     }
-    println!("repo {} — {} paquete(s):", repo_dir.display(), index.packages.len());
+    let rel = match &index.signature {
+        Some(s) => format!(" [release firmado por {}]", s.by),
+        None => String::new(),
+    };
+    println!("repo {} — {} paquete(s){}:", repo_dir.display(), index.packages.len(), rel);
     for p in &index.packages {
         let sig = p.signed_by.as_deref().map(|b| format!(" [firmado por {b}]")).unwrap_or_default();
         let anchor = p
diff --git a/crates/hammer-core/src/repo.rs b/crates/hammer-core/src/repo.rs
index fe06b357..c1a4771d 100644
--- a/crates/hammer-core/src/repo.rs
+++ b/crates/hammer-core/src/repo.rs
@@ -15,6 +15,9 @@ use std::path::{Path, PathBuf};
 
 use serde::{Deserialize, Serialize};
 
+use crate::sign::{verify_raw, KeyPair, SigStatus, TrustStore};
+use crate::swm::Signature;
+
 /// Nombre canónico del índice dentro del directorio del repo.
 pub const INDEX_FILE: &str = "index.json";
 
@@ -24,6 +27,12 @@ pub const INDEX_FILE: &str = "index.json";
 pub struct RepoIndex {
     #[serde(default)]
     pub packages: Vec<PackageEntry>,
+    /// Firma del CATÁLOGO entero (release): cubre la lista de paquetes, no cada `.swm` por
+    /// separado. Firmar el índice ancla qué paquetes existen, sus versiones y sus hashes — un
+    /// atacante no puede añadir/quitar/intercambiar entradas sin invalidar la firma. La firma de
+    /// cada `.swm` (autoría del paquete) es ortogonal y sigue existiendo.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub signature: Option<Signature>,
 }
 
 /// Una entrada del índice: la identidad de un paquete + el puntero a su `.swm` y los metadatos
@@ -83,6 +92,8 @@ impl RepoIndex {
     /// nueva versión del mismo paquete sustituye a la anterior en el índice). Devuelve el
     /// `.swm` viejo que quedó huérfano, si la ruta cambió (para que el caller lo borre).
     pub fn upsert(&mut self, entry: PackageEntry) -> Option<String> {
+        // Cualquier cambio al catálogo invalida una firma de release previa: hay que re-firmar.
+        self.signature = None;
         if let Some(slot) = self.packages.iter_mut().find(|p| p.name == entry.name) {
             let old_file = slot.file.clone();
             let orphan = (old_file != entry.file).then_some(old_file);
@@ -100,6 +111,27 @@ impl RepoIndex {
         repo_dir.as_ref().join(&entry.file)
     }
 
+    /// Bytes canónicos que firma el release: SÓLO la lista de paquetes (excluye la propia firma).
+    /// `PackageEntry` es todo strings/vecs en orden fijo y `packages` está ordenado por nombre
+    /// (ver `upsert`), así que firmante y verificador obtienen los mismos bytes.
+    fn signing_bytes(&self) -> Vec<u8> {
+        serde_json::to_vec(&self.packages).expect("Vec<PackageEntry> siempre serializa")
+    }
+
+    /// Firma el catálogo con `kp` (autoría `by`). Reemplaza cualquier firma previa.
+    pub fn sign(&mut self, kp: &KeyPair, by: &str) {
+        let sig = kp.sign_raw(&self.signing_bytes(), by);
+        self.signature = Some(sig);
+    }
+
+    /// Verifica la firma del catálogo contra un [`TrustStore`]. `Unsigned` si no hay firma.
+    pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
+        match &self.signature {
+            None => SigStatus::Unsigned,
+            Some(sig) => verify_raw(&self.signing_bytes(), sig, trust),
+        }
+    }
+
     /// Resuelve el cierre transitivo de build-deps de `name` en orden TOPOLÓGICO (cada dep antes
     /// que quien la necesita; `name` queda al final). Es el orden en que `install` debe procesar:
     /// poblar el catálogo con las deps antes de construir el dependiente. Error si una dep falta
@@ -257,4 +289,58 @@ mod tests {
         let err = idx.resolve_closure("a").unwrap_err().to_string();
         assert!(err.contains("ciclo"), "{err}");
     }
+
+    #[test]
+    fn release_sign_then_verify_trusted() {
+        let kp = crate::sign::KeyPair::generate().unwrap();
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("rg", "14.1.1"));
+        idx.upsert(entry("findutils", "0.9.1"));
+        idx.sign(&kp, "sergio");
+
+        let mut trust = TrustStore::new();
+        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
+        assert_eq!(idx.verify_signature(&trust), SigStatus::Trusted { by: "sergio".into() });
+    }
+
+    #[test]
+    fn release_signature_survives_save_load() {
+        let d = tempfile::tempdir().unwrap();
+        let kp = crate::sign::KeyPair::generate().unwrap();
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("rg", "14.1.1"));
+        idx.sign(&kp, "sergio");
+        idx.save(d.path()).unwrap();
+
+        let back = RepoIndex::load(d.path()).unwrap();
+        let mut trust = TrustStore::new();
+        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
+        assert!(back.verify_signature(&trust).is_trusted());
+    }
+
+    #[test]
+    fn upsert_invalidates_release_signature() {
+        let kp = crate::sign::KeyPair::generate().unwrap();
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("rg", "1"));
+        idx.sign(&kp, "sergio");
+        assert!(idx.signature.is_some());
+        // Publicar otro paquete invalida la firma (catálogo cambió ⇒ re-firmar).
+        idx.upsert(entry("fd", "1"));
+        assert!(idx.signature.is_none(), "upsert debe invalidar la firma de release");
+    }
+
+    #[test]
+    fn release_tamper_is_bad_sig() {
+        let kp = crate::sign::KeyPair::generate().unwrap();
+        let mut idx = RepoIndex::default();
+        idx.upsert(entry("rg", "1"));
+        idx.sign(&kp, "sergio");
+        // Manipular una entrada SIN re-firmar (p. ej. cambiar el hash esperado de un paquete).
+        idx.packages[0].version = "666".into();
+
+        let mut trust = TrustStore::new();
+        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
+        assert!(matches!(idx.verify_signature(&trust), SigStatus::BadSig { .. }));
+    }
 }
diff --git a/crates/hammer-core/src/sign.rs b/crates/hammer-core/src/sign.rs
index 346f7b07..35e72de3 100644
--- a/crates/hammer-core/src/sign.rs
+++ b/crates/hammer-core/src/sign.rs
@@ -114,8 +114,13 @@ impl KeyPair {
 
     /// Firma un `.swm` y devuelve la `Signature` (autoría `by`).
     pub fn sign(&self, swm: &Swm, by: &str) -> Signature {
-        let msg = signing_bytes(swm);
-        let sig = self.signing.sign(&msg);
+        self.sign_raw(&signing_bytes(swm), by)
+    }
+
+    /// Firma bytes canónicos arbitrarios. Es el primitivo que comparten la firma del `.swm` y la
+    /// firma de un índice de repo (release): el llamador decide qué bytes canónicos firmar.
+    pub fn sign_raw(&self, msg: &[u8], by: &str) -> Signature {
+        let sig = self.signing.sign(msg);
         Signature {
             by: by.to_string(),
             alg: ALG.to_string(),
@@ -124,6 +129,47 @@ impl KeyPair {
     }
 }
 
+/// Verifica una `Signature` sobre bytes canónicos arbitrarios contra un [`TrustStore`]. El
+/// primitivo compartido por `Swm::verify_signature` y `RepoIndex::verify_signature`.
+pub fn verify_raw(msg: &[u8], sig: &Signature, trust: &TrustStore) -> SigStatus {
+    if sig.alg != ALG {
+        return SigStatus::BadSig {
+            by: sig.by.clone(),
+            reason: format!("alg no soportado: {} (esperado {ALG})", sig.alg),
+        };
+    }
+    let vk = match trust.get(&sig.by) {
+        None => return SigStatus::UnknownKey { by: sig.by.clone() },
+        Some(vk) => vk,
+    };
+    let raw = match b64().decode(sig.sig.trim()) {
+        Ok(r) => r,
+        Err(e) => {
+            return SigStatus::BadSig {
+                by: sig.by.clone(),
+                reason: format!("firma base64: {e}"),
+            }
+        }
+    };
+    let bytes: [u8; 64] = match raw.as_slice().try_into() {
+        Ok(b) => b,
+        Err(_) => {
+            return SigStatus::BadSig {
+                by: sig.by.clone(),
+                reason: format!("firma: se esperaban 64 bytes, hay {}", raw.len()),
+            }
+        }
+    };
+    let signature = ed25519_dalek::Signature::from_bytes(&bytes);
+    match vk.verify(msg, &signature) {
+        Ok(()) => SigStatus::Trusted { by: sig.by.clone() },
+        Err(_) => SigStatus::BadSig {
+            by: sig.by.clone(),
+            reason: "la firma no corresponde al contenido firmado".into(),
+        },
+    }
+}
+
 /// Claves públicas en las que el usuario confía para *autoría*. Ver `docs/09-trust-model.md` §5.
 #[derive(Debug, Default)]
 pub struct TrustStore {
@@ -212,45 +258,9 @@ fn signing_bytes(swm: &Swm) -> Vec<u8> {
 impl Swm {
     /// Verifica la firma del manifiesto contra un [`TrustStore`]. Ver SDD 09 §5.
     pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
-        let sig = match &self.signature {
-            None => return SigStatus::Unsigned,
-            Some(s) => s,
-        };
-        if sig.alg != ALG {
-            return SigStatus::BadSig {
-                by: sig.by.clone(),
-                reason: format!("alg no soportado: {} (esperado {ALG})", sig.alg),
-            };
-        }
-        let vk = match trust.get(&sig.by) {
-            None => return SigStatus::UnknownKey { by: sig.by.clone() },
-            Some(vk) => vk,
-        };
-        let raw = match b64().decode(sig.sig.trim()) {
-            Ok(r) => r,
-            Err(e) => {
-                return SigStatus::BadSig {
-                    by: sig.by.clone(),
-                    reason: format!("firma base64: {e}"),
-                }
-            }
-        };
-        let bytes: [u8; 64] = match raw.as_slice().try_into() {
-            Ok(b) => b,
-            Err(_) => {
-                return SigStatus::BadSig {
-                    by: sig.by.clone(),
-                    reason: format!("firma: se esperaban 64 bytes, hay {}", raw.len()),
-                }
-            }
-        };
-        let signature = ed25519_dalek::Signature::from_bytes(&bytes);
-        match vk.verify(&signing_bytes(self), &signature) {
-            Ok(()) => SigStatus::Trusted { by: sig.by.clone() },
-            Err(_) => SigStatus::BadSig {
-                by: sig.by.clone(),
-                reason: "la firma no corresponde al contenido del manifiesto".into(),
-            },
+        match &self.signature {
+            None => SigStatus::Unsigned,
+            Some(sig) => verify_raw(&signing_bytes(self), sig, trust),
         }
     }
 }
diff --git a/docs/06-swm-format.md b/docs/06-swm-format.md
index 9ad2c9b6..aa91e47a 100644
--- a/docs/06-swm-format.md
+++ b/docs/06-swm-format.md
@@ -144,6 +144,9 @@ CLI:
   materializadas en el sandbox, + hidrata). Nunca corre un binario ajeno. `--prefix`/
   `--skip-source-patch` para staging y dry-run de schema.
 - `hammer repo list [--repo DIR]` — lista el catálogo (`<repo>/index.json`).
+- `hammer repo sign --repo DIR --key KEY` — firma el ÍNDICE entero (release). Re-firmá tras
+  publicar (cada `pack --repo` invalida la firma del release).
+- `hammer repo verify --repo DIR [--trust DIR]` — verifica la firma del release.
 
 ## 7. Repositorio de paquetes (Etapa F)
 
@@ -165,3 +168,10 @@ hay ciclo) y, para reproducir, **reconstruye un catálogo de recetas** efímero:
 paquete con deps (p. ej. `bwrap`→`libcap`, `openssh`→`zlib,openssl`) se reproduce desde fuente
 bit-idéntico — verificado: `install bwrap` reproduce el artefacto cacheado exacto resolviendo
 `libcap` del catálogo.
+
+**Firma del release.** Además de la firma de cada `.swm` (autoría del paquete), el ÍNDICE entero
+se puede firmar como release (`RepoIndex::signature`, Ed25519 sobre la lista de paquetes
+canónica). Ancla qué paquetes/versiones/hashes existen: un atacante no puede añadir, quitar ni
+intercambiar entradas sin invalidar la firma. Cualquier `upsert` (publicar) la invalida ⇒
+re-firmar tras publicar. `install` verifica el release antes de resolver: una firma mala aborta
+("el índice fue manipulado"). Mismo primitivo Ed25519 que el `.swm` (`sign::{sign_raw,verify_raw}`).