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