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, + }, + /// 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, + }, } #[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, + /// 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, } /// 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 { + // 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 { + serde_json::to_vec(&self.packages).expect("Vec 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 { 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 (`/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}`).