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>
This commit is contained in:
Sergio
2026-06-10 19:28:32 +00:00
co-authored by Claude Opus 4.8
parent de036d3666
commit 5ec7a81e87
7 changed files with 642 additions and 6 deletions
Generated
+128
View File
@@ -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"
+2
View File
@@ -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"] }
+112 -5
View File
@@ -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(())
}
+2
View File
@@ -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
+2
View File
@@ -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};
+390
View File
@@ -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));
}
}
+6 -1
View File
@@ -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