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>
This commit is contained in:
2026-06-21 06:53:31 -04:00
co-authored by Claude Opus 4.8
parent b89e2dba51
commit 977f07ca6c
4 changed files with 251 additions and 42 deletions
+104 -1
View File
@@ -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
+86
View File
@@ -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 { .. }));
}
}
+51 -41
View File
@@ -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),
}
}
}
+10
View File
@@ -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}`).