> 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…
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>
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}`).