gioser

sergio/takana

> Una distribución Linux construida con un laboratorio funcional y hermético en el > sótano, y una terminal mutable, clásica y anárquica en el piso de arriba — diseñada > desde el suelo para que una IA programadora entienda, modifique y co…

TOML 815Rust 56Shell 37Markdown 291229 Definiciones

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 · (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

  • nueva mod sign pub

crates/hammer-core/src/sign.rs

7 archivos · +642 −6

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