//! Firma Ed25519 del `.swm` y `TrustStore` local. Ver `docs/09-trust-model.md` §3 y §5. //! //! La firma cubre **el contenido del manifiesto** (`swm_version` + `base` + `mutations`), no la //! propia firma. Sirve para autoría e integridad en tránsito — **no** sustituye a la //! verificación reproducible: `hammer apply` siempre reproduce y compara, firme o no. //! //! Formato en disco (texto, una línea base64 cada uno): //! - clave privada: `.ed25519` → base64(semilla de 32 bytes), modo 0600 //! - clave pública: `.ed25519.pub` → base64(clave pública de 32 bytes) use std::collections::BTreeMap; use std::path::Path; use base64::Engine; use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH}; use serde::Serialize; use crate::swm::{Base, Mutation, Signature, Swm}; const ALG: &str = "ed25519"; const PRIV_EXT: &str = ".ed25519"; const PUB_EXT: &str = ".ed25519.pub"; fn b64() -> base64::engine::GeneralPurpose { base64::engine::general_purpose::STANDARD } /// Estado de la firma de un `.swm` respecto a un [`TrustStore`]. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SigStatus { /// Firma válida y la clave del autor está en el TrustStore. Trusted { by: String }, /// Firma presente pero el autor no está entre las claves de confianza locales. UnknownKey { by: String }, /// Hay firma pero no valida (contenido alterado, clave equivocada, o `alg` no soportado). BadSig { by: String, reason: String }, /// El `.swm` no trae firma. Unsigned, } impl SigStatus { /// ¿La firma es válida y de un autor confiado? Recuerda: NO implica permiso para promover /// (eso depende de la verificación reproducible + el `commit` humano). pub fn is_trusted(&self) -> bool { matches!(self, SigStatus::Trusted { .. }) } /// Etiqueta corta para UI/logs. pub fn label(&self) -> &'static str { match self { SigStatus::Trusted { .. } => "trusted", SigStatus::UnknownKey { .. } => "unknown-key", SigStatus::BadSig { .. } => "bad-sig", SigStatus::Unsigned => "unsigned", } } } /// Par de claves Ed25519 envoltorio fino sobre `ed25519-dalek`. pub struct KeyPair { signing: SigningKey, } impl KeyPair { /// Genera un par nuevo con entropía del sistema (`getrandom`). pub fn generate() -> crate::Result { let mut seed = [0u8; SECRET_KEY_LENGTH]; getrandom::getrandom(&mut seed) .map_err(|e| crate::Error::Other(anyhow::anyhow!("getrandom: {e}")))?; Ok(KeyPair { signing: SigningKey::from_bytes(&seed), }) } /// La clave pública en base64 (lo que va a `.ed25519.pub`). pub fn public_b64(&self) -> String { b64().encode(self.signing.verifying_key().to_bytes()) } /// La clave privada (semilla) en base64. Trátala como secreto. pub fn private_b64(&self) -> String { b64().encode(self.signing.to_bytes()) } /// Carga una clave privada desde su forma base64 (contenido de `.ed25519`). pub fn from_private_b64(s: &str) -> crate::Result { let bytes = b64() .decode(s.trim()) .map_err(|e| crate::Error::Other(anyhow::anyhow!("clave privada base64: {e}")))?; let seed: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().map_err(|_| { crate::Error::Other(anyhow::anyhow!( "clave privada: se esperaban {SECRET_KEY_LENGTH} bytes, hay {}", bytes.len() )) })?; Ok(KeyPair { signing: SigningKey::from_bytes(&seed), }) } /// Escribe ambos ficheros en `dir`: `.ed25519` (0600) y `.ed25519.pub`. /// Devuelve la ruta de la clave pública. pub fn write_to(&self, dir: &Path, name: &str) -> crate::Result { std::fs::create_dir_all(dir)?; let priv_path = dir.join(format!("{name}{PRIV_EXT}")); let pub_path = dir.join(format!("{name}{PUB_EXT}")); std::fs::write(&priv_path, format!("{}\n", self.private_b64()))?; std::fs::write(&pub_path, format!("{}\n", self.public_b64()))?; // La privada es secreta: rwx para el dueño solamente. use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600))?; Ok(pub_path) } /// 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); Signature { by: by.to_string(), alg: ALG.to_string(), sig: b64().encode(sig.to_bytes()), } } } /// 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 { keys: BTreeMap, } impl TrustStore { pub fn new() -> TrustStore { TrustStore::default() } /// Carga todas las claves `*.ed25519.pub` de un directorio (p. ej. /// `/var/lib/hammer/trust/`). El nombre del autor es el del fichero sin sufijo. Un /// directorio inexistente da un store vacío (no es error: nadie confiado todavía). pub fn load(dir: &Path) -> crate::Result { let mut store = TrustStore::new(); let rd = match std::fs::read_dir(dir) { Ok(rd) => rd, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(store), Err(e) => return Err(e.into()), }; for entry in rd { let entry = entry?; let fname = entry.file_name(); let fname = fname.to_string_lossy(); if let Some(name) = fname.strip_suffix(PUB_EXT) { let content = std::fs::read_to_string(entry.path())?; let vk = parse_public_b64(content.trim()).map_err(|e| { crate::Error::Other(anyhow::anyhow!("{}: {e}", entry.path().display())) })?; store.keys.insert(name.to_string(), vk); } } Ok(store) } /// Añade/reemplaza una clave de confianza desde su base64. pub fn insert_b64(&mut self, name: &str, public_b64: &str) -> crate::Result<()> { let vk = parse_public_b64(public_b64.trim()) .map_err(|e| crate::Error::Other(anyhow::anyhow!(e)))?; self.keys.insert(name.to_string(), vk); Ok(()) } pub fn get(&self, name: &str) -> Option<&VerifyingKey> { self.keys.get(name) } pub fn len(&self) -> usize { self.keys.len() } pub fn is_empty(&self) -> bool { self.keys.is_empty() } } fn parse_public_b64(s: &str) -> Result { let bytes = b64().decode(s).map_err(|e| format!("clave pública base64: {e}"))?; let arr: [u8; 32] = bytes .as_slice() .try_into() .map_err(|_| format!("clave pública: se esperaban 32 bytes, hay {}", bytes.len()))?; VerifyingKey::from_bytes(&arr).map_err(|e| format!("clave pública inválida: {e}")) } /// Bytes canónicos firmados: el contenido del manifiesto SIN la firma, en JSON estable. /// `base.pins` es un `BTreeMap` (orden por clave) y el resto son campos de orden fijo, así que /// firmante y verificador obtienen exactamente los mismos bytes. fn signing_bytes(swm: &Swm) -> Vec { #[derive(Serialize)] struct Signable<'a> { swm_version: u32, base: &'a Base, mutations: &'a [Mutation], } let signable = Signable { swm_version: swm.swm_version, base: &swm.base, mutations: &swm.mutations, }; // serde_json sobre tipos sin floats ni mapas no ordenados ⇒ salida determinista. serde_json::to_vec(&signable).expect("Signable siempre serializa") } 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(), }, } } } #[cfg(test)] mod tests { use super::*; use crate::swm::{Base, Mutation, Swm}; fn demo_swm() -> Swm { Swm { swm_version: 1, base: Base { distro_version: "2026-06-06".into(), pins: [("grep".to_string(), "a1b2".to_string())].into_iter().collect(), }, mutations: vec![Mutation::ConfigEdit { file: "/etc/network.conf".into(), inline_diff: "- DHCP=yes\n+ IP=10.0.0.1\n".into(), }], signature: None, } } #[test] fn sign_then_verify_trusted() { let kp = KeyPair::generate().unwrap(); let mut swm = demo_swm(); swm.signature = Some(kp.sign(&swm, "sergio")); let mut trust = TrustStore::new(); trust.insert_b64("sergio", &kp.public_b64()).unwrap(); assert_eq!( swm.verify_signature(&trust), SigStatus::Trusted { by: "sergio".into() } ); } #[test] fn unsigned_is_unsigned() { assert_eq!(demo_swm().verify_signature(&TrustStore::new()), SigStatus::Unsigned); } #[test] fn unknown_key() { let kp = KeyPair::generate().unwrap(); let mut swm = demo_swm(); swm.signature = Some(kp.sign(&swm, "desconocido")); // TrustStore vacío ⇒ autor desconocido. match swm.verify_signature(&TrustStore::new()) { SigStatus::UnknownKey { by } => assert_eq!(by, "desconocido"), other => panic!("esperaba UnknownKey, obtuve {other:?}"), } } #[test] fn tampered_content_is_bad_sig() { let kp = KeyPair::generate().unwrap(); let mut swm = demo_swm(); swm.signature = Some(kp.sign(&swm, "sergio")); // Alteramos el contenido DESPUÉS de firmar. if let Mutation::ConfigEdit { inline_diff, .. } = &mut swm.mutations[0] { *inline_diff = "- DHCP=yes\n+ IP=10.0.0.2\n".into(); // .1 → .2 } let mut trust = TrustStore::new(); trust.insert_b64("sergio", &kp.public_b64()).unwrap(); assert!(matches!( swm.verify_signature(&trust), SigStatus::BadSig { .. } )); } #[test] fn wrong_key_is_bad_sig() { let kp = KeyPair::generate().unwrap(); let other = KeyPair::generate().unwrap(); let mut swm = demo_swm(); swm.signature = Some(kp.sign(&swm, "sergio")); // El TrustStore tiene a "sergio" pero con OTRA clave pública. let mut trust = TrustStore::new(); trust.insert_b64("sergio", &other.public_b64()).unwrap(); assert!(matches!( swm.verify_signature(&trust), SigStatus::BadSig { .. } )); } #[test] fn keypair_roundtrip_through_disk() { let dir = tempfile::tempdir().unwrap(); let kp = KeyPair::generate().unwrap(); kp.write_to(dir.path(), "sergio").unwrap(); // Recarga privada y verifica que firma con la misma identidad. let priv_s = std::fs::read_to_string(dir.path().join("sergio.ed25519")).unwrap(); let loaded = KeyPair::from_private_b64(&priv_s).unwrap(); assert_eq!(loaded.public_b64(), kp.public_b64()); // La pública en disco carga en un TrustStore por nombre de fichero. let trust = TrustStore::load(dir.path()).unwrap(); assert_eq!(trust.len(), 1); assert!(trust.get("sergio").is_some()); // Permisos 0600 en la privada. use std::os::unix::fs::PermissionsExt; let mode = std::fs::metadata(dir.path().join("sergio.ed25519")) .unwrap() .permissions() .mode() & 0o777; assert_eq!(mode, 0o600); } #[test] fn load_missing_dir_is_empty() { let t = TrustStore::load(Path::new("/no/existe/aqui")).unwrap(); assert!(t.is_empty()); } #[test] fn signing_bytes_ignore_signature_field() { // Firmar no debe depender del campo signature: dos SWM idénticos salvo signature // producen los mismos bytes firmados. let mut a = demo_swm(); let b = demo_swm(); a.signature = Some(Signature { by: "x".into(), alg: "ed25519".into(), sig: "AAAA".into(), }); assert_eq!(signing_bytes(&a), signing_bytes(&b)); } }