tawasuyu

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 1534Shell 200Rust 109Markdown 993713 Sutichaykuna

⎇ 98f0b2d228ffebb06941dfc1f436167ab40d8f21 crates/hammer-core/src/sign.rs Rust · 390 siq'ikuna · 13.7 KB Ñawpa willay Pi imata llamkarqan ch'uya

Fase 4: firma Ed25519 del .swm + TrustStore local 5ec7a81e8 · Sergio ·

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}