Fase 4: firma Ed25519 del .swm + TrustStore local
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
<name>.ed25519 (0600) + <name>.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 <name> [--out DIR]
- hammer swm-sign <file> --key K [--by N] [-o OUT]
- hammer swm-verify <file> --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) <noreply@anthropic.com>
5ec7a81e876d4669ecc29c1e857a1ce3532569cf · por Sergio · 2026-06-10T19:28:32Z (hace 4 meses) · padre de036d366 · ver el árbol en este commit
Delta semántico · 43
crates/hammer-cli/src/main.rs
crates/hammer-core/src/lib.rs
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<PathBuf>,
},
- /// [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<PathBuf>,
+ /// 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<PathBuf>,
+ },
+ /// [Fase 4] Genera un par de claves Ed25519 para firmar `.swm`. Escribe `<name>.ed25519`
+ /// (privada, 0600) y `<name>.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<PathBuf>,
+ },
+ /// [Fase 4] Firma un `.swm` con una clave privada y escribe el manifiesto firmado.
+ SwmSign {
+ file: String,
+ /// Ruta a la clave privada `<name>.ed25519`.
+ #[arg(long)]
+ key: PathBuf,
+ /// Autoría declarada (`signature.by`). Default: el basename de la clave sin sufijo.
+ #[arg(long)]
+ by: Option<String>,
+ /// Destino. Si se omite, sobrescribe el propio `file` in-place.
+ #[arg(long, short)]
+ out: Option<PathBuf>,
},
/// [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: `<name>.ed25519` → base64(semilla de 32 bytes), modo 0600
+//! - clave pública: `<name>.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<KeyPair> {
+ 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 `<name>.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 `<name>.ed25519`).
+ pub fn from_private_b64(s: &str) -> crate::Result<KeyPair> {
+ 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`: `<name>.ed25519` (0600) y `<name>.ed25519.pub`.
+ /// Devuelve la ruta de la clave pública.
+ pub fn write_to(&self, dir: &Path, name: &str) -> crate::Result<std::path::PathBuf> {
+ 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<String, VerifyingKey>,
+}
+
+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<TrustStore> {
+ 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<VerifyingKey, String> {
+ 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<u8> {
+ #[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