From 5ec7a81e876d4669ecc29c1e857a1ce3532569cf Mon Sep 17 00:00:00 2001 From: Sergio Date: Wed, 10 Jun 2026 19:28:32 +0000 Subject: [PATCH] Fase 4: firma Ed25519 del .swm + TrustStore local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cierra el item de firma del modelo de confianza (SDD 09 §3, §5). hammer-core/src/sign.rs: - KeyPair: genera (getrandom), carga/serializa claves base64, escribe .ed25519 (0600) + .ed25519.pub, y firma un Swm. - TrustStore::load(dir): lee *.ed25519.pub; dir ausente ⇒ store vacío. - Swm::verify_signature(&trust) → SigStatus {Trusted|UnknownKey|BadSig| Unsigned}. Bytes firmados = JSON canónico de (swm_version, base, mutations) sin la firma; deterministas (pins en BTreeMap). - 8 tests: sign/verify, tamper, wrong-key, roundtrip en disco, dir ausente. CLI: - hammer keygen [--out DIR] - hammer swm-sign --key K [--by N] [-o OUT] - hammer swm-verify --trust DIR (reporta trusted/unknown-key/ bad-sig/unsigned; bad-sig aborta, lo demás informa). La firma da autoría + integridad pero NO autoriza promover: apply sigue reproduciendo y comparando. Verificado e2e por el CLI (keygen→sign→verify en los 4 estados). 22 binarios de test verdes. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 128 +++++++++++ Cargo.toml | 2 + crates/hammer-cli/src/main.rs | 117 +++++++++- crates/hammer-core/Cargo.toml | 2 + crates/hammer-core/src/lib.rs | 2 + crates/hammer-core/src/sign.rs | 390 +++++++++++++++++++++++++++++++++ docs/10-roadmap.md | 7 +- 7 files changed, 642 insertions(+), 6 deletions(-) create mode 100644 crates/hammer-core/src/sign.rs diff --git a/Cargo.lock b/Cargo.lock index b844b643..b0681267 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,6 +91,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bitflags" version = "2.13.0" @@ -188,6 +194,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -222,6 +234,43 @@ dependencies = [ "typenum", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "digest" version = "0.10.7" @@ -243,6 +292,30 @@ dependencies = [ "syn", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -265,6 +338,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -377,6 +456,8 @@ dependencies = [ "anyhow", "base64", "blake3", + "ed25519-dalek", + "getrandom 0.2.17", "serde", "serde_json", "serde_yaml", @@ -700,6 +781,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -743,6 +834,15 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + [[package]] name = "regex-automata" version = "0.4.14" @@ -774,6 +874,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -925,12 +1034,31 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" diff --git a/Cargo.toml b/Cargo.toml index b8af6db6..a5adeb37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,8 @@ blake3 = "1" sha2 = "0.10" hex = "0.4" base64 = "0.22" +ed25519-dalek = "2" +getrandom = "0.2" nix = { version = "0.30", default-features = false, features = ["fanotify", "fs", "user"] } tempfile = "3" clap = { version = "4", features = ["derive"] } diff --git a/crates/hammer-cli/src/main.rs b/crates/hammer-cli/src/main.rs index 297edcec..7d945ec8 100644 --- a/crates/hammer-cli/src/main.rs +++ b/crates/hammer-cli/src/main.rs @@ -111,12 +111,37 @@ enum Cmd { #[arg(long)] state_root: Option, }, - /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), y opcionalmente el - /// hash de cada `file_drop` inline. + /// [Fase 4] Verifica un .swm: schema, base (si se da `--base-ref`), el hash de cada + /// `file_drop` inline y, con `--trust DIR`, la firma Ed25519 contra el TrustStore local. SwmVerify { file: String, #[arg(long)] base_ref: Option, + /// Directorio de claves de confianza (`*.ed25519.pub`). Default: + /// /var/lib/hammer/trust. La firma reporta estado, NO autoriza promover (ver SDD 09). + #[arg(long)] + trust: Option, + }, + /// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `.ed25519` + /// (privada, 0600) y `.ed25519.pub` en `--out` (default: /var/lib/hammer/trust). + Keygen { + /// Nombre de la identidad (autoría). Será el `by` de las firmas. + name: String, + #[arg(long)] + out: Option, + }, + /// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado. + SwmSign { + file: String, + /// Ruta a la clave privada `.ed25519`. + #[arg(long)] + key: PathBuf, + /// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo. + #[arg(long)] + by: Option, + /// Destino. Si se omite, sobrescribe el propio `file` in-place. + #[arg(long, short)] + out: Option, }, /// [Fase 4] Exporta el diario actual como manifiesto .swm a stdout. /// Mutaciones trazables ⇒ file_drop con content_b64 y hash. Mutaciones opacas ⇒ @@ -360,8 +385,14 @@ fn main() -> anyhow::Result<()> { state_root.as_deref(), )?; } - Cmd::SwmVerify { file, base_ref } => { - run_swm_verify(&file, base_ref.as_deref())?; + Cmd::SwmVerify { file, base_ref, trust } => { + run_swm_verify(&file, base_ref.as_deref(), trust.as_deref())?; + } + Cmd::Keygen { name, out } => { + run_keygen(&name, out.as_deref())?; + } + Cmd::SwmSign { file, key, by, out } => { + run_swm_sign(&file, &key, by.as_deref(), out.as_deref())?; } Cmd::Export { base_ref, journal, since } => { run_export(base_ref.as_deref(), &journal, since.as_deref(), &cli.store)?; @@ -439,7 +470,11 @@ fn print_base_compat(c: &hammer_core::BaseCompat) { } } -fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Result<()> { +fn run_swm_verify( + file: &str, + base_ref: Option<&std::path::Path>, + trust: Option<&std::path::Path>, +) -> anyhow::Result<()> { let swm = load_swm(file)?; swm.verify_schema() .map_err(|e| anyhow::anyhow!("schema inválido: {e}"))?; @@ -458,6 +493,78 @@ fn run_swm_verify(file: &str, base_ref: Option<&std::path::Path>) -> anyhow::Res } else { eprintln!("verify_base: omitido (sin --base-ref)"); } + print_sig_status(&swm, trust)?; + Ok(()) +} + +const DEFAULT_TRUST_DIR: &str = "/var/lib/hammer/trust"; + +/// Carga el TrustStore (de `--trust` o el default) y reporta el estado de la firma. La firma +/// informa autoría/integridad; NO autoriza promover (eso lo decide la reproducción + commit +/// humano). Ver `docs/09-trust-model.md`. +fn print_sig_status( + swm: &hammer_core::Swm, + trust: Option<&std::path::Path>, +) -> anyhow::Result<()> { + let trust_dir = trust.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR)); + let store = hammer_core::TrustStore::load(trust_dir)?; + let status = swm.verify_signature(&store); + match &status { + hammer_core::SigStatus::Trusted { by } => { + println!("firma: trusted (by {by})"); + } + hammer_core::SigStatus::UnknownKey { by } => { + println!( + "firma: unknown-key (by {by}) — añade su clave a {} para confiar en la autoría", + trust_dir.display() + ); + } + hammer_core::SigStatus::BadSig { by, reason } => { + eprintln!("firma: BAD-SIG (by {by}): {reason}"); + anyhow::bail!("la firma del .swm no valida"); + } + hammer_core::SigStatus::Unsigned => { + println!("firma: unsigned"); + } + } + Ok(()) +} + +fn run_keygen(name: &str, out: Option<&std::path::Path>) -> anyhow::Result<()> { + let dir = out.unwrap_or_else(|| std::path::Path::new(DEFAULT_TRUST_DIR)); + let kp = hammer_core::KeyPair::generate()?; + let pub_path = kp.write_to(dir, name)?; + println!("clave pública: {}", pub_path.display()); + println!("clave privada: {} (modo 0600 — guárdala)", dir.join(format!("{name}.ed25519")).display()); + println!("pub b64: {}", kp.public_b64()); + Ok(()) +} + +fn run_swm_sign( + file: &str, + key: &std::path::Path, + by: Option<&str>, + out: Option<&std::path::Path>, +) -> anyhow::Result<()> { + let mut swm = load_swm(file)?; + swm.verify_schema() + .map_err(|e| anyhow::anyhow!("no firmo un .swm con schema inválido: {e}"))?; + 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)?; + + // `by` por defecto: basename de la clave sin el sufijo `.ed25519`. + 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()) + }); + + swm.signature = Some(kp.sign(&swm, &by)); + let yaml = swm.to_yaml()?; + let dest = out.unwrap_or_else(|| std::path::Path::new(file)); + std::fs::write(dest, yaml)?; + println!("firmado por {by} → {}", dest.display()); Ok(()) } diff --git a/crates/hammer-core/Cargo.toml b/crates/hammer-core/Cargo.toml index e8f17d2f..aa5d189f 100644 --- a/crates/hammer-core/Cargo.toml +++ b/crates/hammer-core/Cargo.toml @@ -16,6 +16,8 @@ serde_yaml.workspace = true toml.workspace = true blake3.workspace = true base64.workspace = true +ed25519-dalek.workspace = true +getrandom.workspace = true [dev-dependencies] tempfile.workspace = true diff --git a/crates/hammer-core/src/lib.rs b/crates/hammer-core/src/lib.rs index e882df4c..06c0aa98 100644 --- a/crates/hammer-core/src/lib.rs +++ b/crates/hammer-core/src/lib.rs @@ -9,11 +9,13 @@ pub mod hash; pub mod proto; pub mod query; pub mod recipe; +pub mod sign; pub mod store; pub mod swm; pub use hash::ArtifactHash; pub use recipe::{Compiler, LinkMode, Phases, Recipe, Source, SourceKind}; +pub use sign::{KeyPair, SigStatus, TrustStore}; pub use store::Store; pub use swm::{Base, BaseCompat, BaseRef, Mutation, PinDiff, Signature, Swm, SwmBuild}; diff --git a/crates/hammer-core/src/sign.rs b/crates/hammer-core/src/sign.rs new file mode 100644 index 00000000..346f7b07 --- /dev/null +++ b/crates/hammer-core/src/sign.rs @@ -0,0 +1,390 @@ +//! 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)); + } +} diff --git a/docs/10-roadmap.md b/docs/10-roadmap.md index 876877ec..c539bd45 100644 --- a/docs/10-roadmap.md +++ b/docs/10-roadmap.md @@ -72,7 +72,12 @@ pre-requisito de validación. agrupa eventos por `artifact_hash` y emite UN `source_patch` por grupo cuya receta sea recuperable; lo demás cae al fallback `file_drop`. Source `git` modelado; `tarball` cae a file_drop con warning (pendiente extender SourcePatch). -- [ ] Firma `signature` (ed25519) y `TrustStore` local. +- [x] Firma `signature` (ed25519) y `TrustStore` local. `hammer_core::sign`: `KeyPair` + (genera/carga/escribe claves), `TrustStore::load(dir)` (lee `*.ed25519.pub`), + `Swm::verify_signature(&trust) → SigStatus` (`trusted`/`unknown-key`/`bad-sig`/ + `unsigned`) sobre bytes canónicos JSON del manifiesto sin la firma. CLI: + `hammer keygen`, `hammer swm-sign`, y `hammer swm-verify --trust DIR`. La firma reporta + autoría/integridad; NO autoriza promover (sigue mandando reproducir + commit). SDD 09 §3,§5. - **Hecho cuando:** exportas un cambio, lo aplicas en otra máquina y reproduce idéntico. ✅ Demostrado en `crates/hammer-cli/tests/swm_roundtrip.rs` para `config_edit` + `file_drop`. `source_patch` reusa el camino de Fase 0/1 (gated en