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:
co-authored by
Claude Opus 4.8
parent
de036d3666
commit
5ec7a81e87
Generated
+128
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user