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…
⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21
crates/hammer-core/src/sign.rs
History
Who touched what
raw
Fase 4: firma Ed25519 del .swm + TrustStore local
1//! Firma Ed25519 del `.swm` y `TrustStore` local. Ver `docs/09-trust-model.md` §3 y §5.
2//!
3//! La firma cubre **el contenido del manifiesto** (`swm_version` + `base` + `mutations`), no la
4//! propia firma. Sirve para autoría e integridad en tránsito — **no** sustituye a la
5//! verificación reproducible: `hammer apply` siempre reproduce y compara, firme o no.
6//!
7//! Formato en disco (texto, una línea base64 cada uno):
8//! - clave privada: `<name>.ed25519` → base64(semilla de 32 bytes), modo 0600
9//! - clave pública: `<name>.ed25519.pub` → base64(clave pública de 32 bytes)
10
11use std::collections::BTreeMap;
12use std::path::Path;
13
14use base64::Engine;
15use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH};
16use serde::Serialize;
17
18use crate::swm::{Base, Mutation, Signature, Swm};
19
20const ALG: &str = "ed25519";
21const PRIV_EXT: &str = ".ed25519";
22const PUB_EXT: &str = ".ed25519.pub";
23
24fn b64() -> base64::engine::GeneralPurpose {
25 base64::engine::general_purpose::STANDARD
26}
27
28/// Estado de la firma de un `.swm` respecto a un [`TrustStore`].
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum SigStatus {
31 /// Firma válida y la clave del autor está en el TrustStore.
32 Trusted { by: String },
33 /// Firma presente pero el autor no está entre las claves de confianza locales.
34 UnknownKey { by: String },
35 /// Hay firma pero no valida (contenido alterado, clave equivocada, o `alg` no soportado).
36 BadSig { by: String, reason: String },
37 /// El `.swm` no trae firma.
38 Unsigned,
39}
40
41impl SigStatus {
42 /// ¿La firma es válida y de un autor confiado? Recuerda: NO implica permiso para promover
43 /// (eso depende de la verificación reproducible + el `commit` humano).
44 pub fn is_trusted(&self) -> bool {
45 matches!(self, SigStatus::Trusted { .. })
46 }
47
48 /// Etiqueta corta para UI/logs.
49 pub fn label(&self) -> &'static str {
50 match self {
51 SigStatus::Trusted { .. } => "trusted",
52 SigStatus::UnknownKey { .. } => "unknown-key",
53 SigStatus::BadSig { .. } => "bad-sig",
54 SigStatus::Unsigned => "unsigned",
55 }
56 }
57}
58
59/// Par de claves Ed25519 envoltorio fino sobre `ed25519-dalek`.
60pub struct KeyPair {
61 signing: SigningKey,
62}
63
64impl KeyPair {
65 /// Genera un par nuevo con entropía del sistema (`getrandom`).
66 pub fn generate() -> crate::Result<KeyPair> {
67 let mut seed = [0u8; SECRET_KEY_LENGTH];
68 getrandom::getrandom(&mut seed)
69 .map_err(|e| crate::Error::Other(anyhow::anyhow!("getrandom: {e}")))?;
70 Ok(KeyPair {
71 signing: SigningKey::from_bytes(&seed),
72 })
73 }
74
75 /// La clave pública en base64 (lo que va a `<name>.ed25519.pub`).
76 pub fn public_b64(&self) -> String {
77 b64().encode(self.signing.verifying_key().to_bytes())
78 }
79
80 /// La clave privada (semilla) en base64. Trátala como secreto.
81 pub fn private_b64(&self) -> String {
82 b64().encode(self.signing.to_bytes())
83 }
84
85 /// Carga una clave privada desde su forma base64 (contenido de `<name>.ed25519`).
86 pub fn from_private_b64(s: &str) -> crate::Result<KeyPair> {
87 let bytes = b64()
88 .decode(s.trim())
89 .map_err(|e| crate::Error::Other(anyhow::anyhow!("clave privada base64: {e}")))?;
90 let seed: [u8; SECRET_KEY_LENGTH] = bytes.as_slice().try_into().map_err(|_| {
91 crate::Error::Other(anyhow::anyhow!(
92 "clave privada: se esperaban {SECRET_KEY_LENGTH} bytes, hay {}",
93 bytes.len()
94 ))
95 })?;
96 Ok(KeyPair {
97 signing: SigningKey::from_bytes(&seed),
98 })
99 }
100
101 /// Escribe ambos ficheros en `dir`: `<name>.ed25519` (0600) y `<name>.ed25519.pub`.
102 /// Devuelve la ruta de la clave pública.
103 pub fn write_to(&self, dir: &Path, name: &str) -> crate::Result<std::path::PathBuf> {
104 std::fs::create_dir_all(dir)?;
105 let priv_path = dir.join(format!("{name}{PRIV_EXT}"));
106 let pub_path = dir.join(format!("{name}{PUB_EXT}"));
107 std::fs::write(&priv_path, format!("{}\n", self.private_b64()))?;
108 std::fs::write(&pub_path, format!("{}\n", self.public_b64()))?;
109 // La privada es secreta: rwx para el dueño solamente.
110 use std::os::unix::fs::PermissionsExt;
111 std::fs::set_permissions(&priv_path, std::fs::Permissions::from_mode(0o600))?;
112 Ok(pub_path)
113 }
114
115 /// Firma un `.swm` y devuelve la `Signature` (autoría `by`).
116 pub fn sign(&self, swm: &Swm, by: &str) -> Signature {
117 let msg = signing_bytes(swm);
118 let sig = self.signing.sign(&msg);
119 Signature {
120 by: by.to_string(),
121 alg: ALG.to_string(),
122 sig: b64().encode(sig.to_bytes()),
123 }
124 }
125}
126
127/// Claves públicas en las que el usuario confía para *autoría*. Ver `docs/09-trust-model.md` §5.
128#[derive(Debug, Default)]
129pub struct TrustStore {
130 keys: BTreeMap<String, VerifyingKey>,
131}
132
133impl TrustStore {
134 pub fn new() -> TrustStore {
135 TrustStore::default()
136 }
137
138 /// Carga todas las claves `*.ed25519.pub` de un directorio (p. ej.
139 /// `/var/lib/hammer/trust/`). El nombre del autor es el del fichero sin sufijo. Un
140 /// directorio inexistente da un store vacío (no es error: nadie confiado todavía).
141 pub fn load(dir: &Path) -> crate::Result<TrustStore> {
142 let mut store = TrustStore::new();
143 let rd = match std::fs::read_dir(dir) {
144 Ok(rd) => rd,
145 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(store),
146 Err(e) => return Err(e.into()),
147 };
148 for entry in rd {
149 let entry = entry?;
150 let fname = entry.file_name();
151 let fname = fname.to_string_lossy();
152 if let Some(name) = fname.strip_suffix(PUB_EXT) {
153 let content = std::fs::read_to_string(entry.path())?;
154 let vk = parse_public_b64(content.trim()).map_err(|e| {
155 crate::Error::Other(anyhow::anyhow!("{}: {e}", entry.path().display()))
156 })?;
157 store.keys.insert(name.to_string(), vk);
158 }
159 }
160 Ok(store)
161 }
162
163 /// Añade/reemplaza una clave de confianza desde su base64.
164 pub fn insert_b64(&mut self, name: &str, public_b64: &str) -> crate::Result<()> {
165 let vk = parse_public_b64(public_b64.trim())
166 .map_err(|e| crate::Error::Other(anyhow::anyhow!(e)))?;
167 self.keys.insert(name.to_string(), vk);
168 Ok(())
169 }
170
171 pub fn get(&self, name: &str) -> Option<&VerifyingKey> {
172 self.keys.get(name)
173 }
174
175 pub fn len(&self) -> usize {
176 self.keys.len()
177 }
178
179 pub fn is_empty(&self) -> bool {
180 self.keys.is_empty()
181 }
182}
183
184fn parse_public_b64(s: &str) -> Result<VerifyingKey, String> {
185 let bytes = b64().decode(s).map_err(|e| format!("clave pública base64: {e}"))?;
186 let arr: [u8; 32] = bytes
187 .as_slice()
188 .try_into()
189 .map_err(|_| format!("clave pública: se esperaban 32 bytes, hay {}", bytes.len()))?;
190 VerifyingKey::from_bytes(&arr).map_err(|e| format!("clave pública inválida: {e}"))
191}
192
193/// Bytes canónicos firmados: el contenido del manifiesto SIN la firma, en JSON estable.
194/// `base.pins` es un `BTreeMap` (orden por clave) y el resto son campos de orden fijo, así que
195/// firmante y verificador obtienen exactamente los mismos bytes.
196fn signing_bytes(swm: &Swm) -> Vec<u8> {
197 #[derive(Serialize)]
198 struct Signable<'a> {
199 swm_version: u32,
200 base: &'a Base,
201 mutations: &'a [Mutation],
202 }
203 let signable = Signable {
204 swm_version: swm.swm_version,
205 base: &swm.base,
206 mutations: &swm.mutations,
207 };
208 // serde_json sobre tipos sin floats ni mapas no ordenados ⇒ salida determinista.
209 serde_json::to_vec(&signable).expect("Signable siempre serializa")
210}
211
212impl Swm {
213 /// Verifica la firma del manifiesto contra un [`TrustStore`]. Ver SDD 09 §5.
214 pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
215 let sig = match &self.signature {
216 None => return SigStatus::Unsigned,
217 Some(s) => s,
218 };
219 if sig.alg != ALG {
220 return SigStatus::BadSig {
221 by: sig.by.clone(),
222 reason: format!("alg no soportado: {} (esperado {ALG})", sig.alg),
223 };
224 }
225 let vk = match trust.get(&sig.by) {
226 None => return SigStatus::UnknownKey { by: sig.by.clone() },
227 Some(vk) => vk,
228 };
229 let raw = match b64().decode(sig.sig.trim()) {
230 Ok(r) => r,
231 Err(e) => {
232 return SigStatus::BadSig {
233 by: sig.by.clone(),
234 reason: format!("firma base64: {e}"),
235 }
236 }
237 };
238 let bytes: [u8; 64] = match raw.as_slice().try_into() {
239 Ok(b) => b,
240 Err(_) => {
241 return SigStatus::BadSig {
242 by: sig.by.clone(),
243 reason: format!("firma: se esperaban 64 bytes, hay {}", raw.len()),
244 }
245 }
246 };
247 let signature = ed25519_dalek::Signature::from_bytes(&bytes);
248 match vk.verify(&signing_bytes(self), &signature) {
249 Ok(()) => SigStatus::Trusted { by: sig.by.clone() },
250 Err(_) => SigStatus::BadSig {
251 by: sig.by.clone(),
252 reason: "la firma no corresponde al contenido del manifiesto".into(),
253 },
254 }
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 use crate::swm::{Base, Mutation, Swm};
262
263 fn demo_swm() -> Swm {
264 Swm {
265 swm_version: 1,
266 base: Base {
267 distro_version: "2026-06-06".into(),
268 pins: [("grep".to_string(), "a1b2".to_string())].into_iter().collect(),
269 },
270 mutations: vec![Mutation::ConfigEdit {
271 file: "/etc/network.conf".into(),
272 inline_diff: "- DHCP=yes\n+ IP=10.0.0.1\n".into(),
273 }],
274 signature: None,
275 }
276 }
277
278 #[test]
279 fn sign_then_verify_trusted() {
280 let kp = KeyPair::generate().unwrap();
281 let mut swm = demo_swm();
282 swm.signature = Some(kp.sign(&swm, "sergio"));
283
284 let mut trust = TrustStore::new();
285 trust.insert_b64("sergio", &kp.public_b64()).unwrap();
286
287 assert_eq!(
288 swm.verify_signature(&trust),
289 SigStatus::Trusted { by: "sergio".into() }
290 );
291 }
292
293 #[test]
294 fn unsigned_is_unsigned() {
295 assert_eq!(demo_swm().verify_signature(&TrustStore::new()), SigStatus::Unsigned);
296 }
297
298 #[test]
299 fn unknown_key() {
300 let kp = KeyPair::generate().unwrap();
301 let mut swm = demo_swm();
302 swm.signature = Some(kp.sign(&swm, "desconocido"));
303 // TrustStore vacío ⇒ autor desconocido.
304 match swm.verify_signature(&TrustStore::new()) {
305 SigStatus::UnknownKey { by } => assert_eq!(by, "desconocido"),
306 other => panic!("esperaba UnknownKey, obtuve {other:?}"),
307 }
308 }
309
310 #[test]
311 fn tampered_content_is_bad_sig() {
312 let kp = KeyPair::generate().unwrap();
313 let mut swm = demo_swm();
314 swm.signature = Some(kp.sign(&swm, "sergio"));
315
316 // Alteramos el contenido DESPUÉS de firmar.
317 if let Mutation::ConfigEdit { inline_diff, .. } = &mut swm.mutations[0] {
318 *inline_diff = "- DHCP=yes\n+ IP=10.0.0.2\n".into(); // .1 → .2
319 }
320
321 let mut trust = TrustStore::new();
322 trust.insert_b64("sergio", &kp.public_b64()).unwrap();
323 assert!(matches!(
324 swm.verify_signature(&trust),
325 SigStatus::BadSig { .. }
326 ));
327 }
328
329 #[test]
330 fn wrong_key_is_bad_sig() {
331 let kp = KeyPair::generate().unwrap();
332 let other = KeyPair::generate().unwrap();
333 let mut swm = demo_swm();
334 swm.signature = Some(kp.sign(&swm, "sergio"));
335
336 // El TrustStore tiene a "sergio" pero con OTRA clave pública.
337 let mut trust = TrustStore::new();
338 trust.insert_b64("sergio", &other.public_b64()).unwrap();
339 assert!(matches!(
340 swm.verify_signature(&trust),
341 SigStatus::BadSig { .. }
342 ));
343 }
344
345 #[test]
346 fn keypair_roundtrip_through_disk() {
347 let dir = tempfile::tempdir().unwrap();
348 let kp = KeyPair::generate().unwrap();
349 kp.write_to(dir.path(), "sergio").unwrap();
350
351 // Recarga privada y verifica que firma con la misma identidad.
352 let priv_s = std::fs::read_to_string(dir.path().join("sergio.ed25519")).unwrap();
353 let loaded = KeyPair::from_private_b64(&priv_s).unwrap();
354 assert_eq!(loaded.public_b64(), kp.public_b64());
355
356 // La pública en disco carga en un TrustStore por nombre de fichero.
357 let trust = TrustStore::load(dir.path()).unwrap();
358 assert_eq!(trust.len(), 1);
359 assert!(trust.get("sergio").is_some());
360
361 // Permisos 0600 en la privada.
362 use std::os::unix::fs::PermissionsExt;
363 let mode = std::fs::metadata(dir.path().join("sergio.ed25519"))
364 .unwrap()
365 .permissions()
366 .mode()
367 & 0o777;
368 assert_eq!(mode, 0o600);
369 }
370
371 #[test]
372 fn load_missing_dir_is_empty() {
373 let t = TrustStore::load(Path::new("/no/existe/aqui")).unwrap();
374 assert!(t.is_empty());
375 }
376
377 #[test]
378 fn signing_bytes_ignore_signature_field() {
379 // Firmar no debe depender del campo signature: dos SWM idénticos salvo signature
380 // producen los mismos bytes firmados.
381 let mut a = demo_swm();
382 let b = demo_swm();
383 a.signature = Some(Signature {
384 by: "x".into(),
385 alg: "ed25519".into(),
386 sig: "AAAA".into(),
387 });
388 assert_eq!(signing_bytes(&a), signing_bytes(&b));
389 }
390}