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 1397Shell 141Markdown 81Rust 802640 Definiciones

⎇ 84e3737e10e17927380ed3e232e50cebfdd39123 crates/takana-core/src/sign.rs Rust · 400 líneas · 14.1 KB Historia Quién tocó qué crudo

takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja b818f5249 · 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: `takana 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        self.sign_raw(&signing_bytes(swm), by)
118    }
119
120    /// Firma bytes canónicos arbitrarios. Es el primitivo que comparten la firma del `.swm` y la
121    /// firma de un índice de repo (release): el llamador decide qué bytes canónicos firmar.
122    pub fn sign_raw(&self, msg: &[u8], by: &str) -> Signature {
123        let sig = self.signing.sign(msg);
124        Signature {
125            by: by.to_string(),
126            alg: ALG.to_string(),
127            sig: b64().encode(sig.to_bytes()),
128        }
129    }
130}
131
132/// Verifica una `Signature` sobre bytes canónicos arbitrarios contra un [`TrustStore`]. El
133/// primitivo compartido por `Swm::verify_signature` y `RepoIndex::verify_signature`.
134pub fn verify_raw(msg: &[u8], sig: &Signature, trust: &TrustStore) -> SigStatus {
135    if sig.alg != ALG {
136        return SigStatus::BadSig {
137            by: sig.by.clone(),
138            reason: format!("alg no soportado: {} (esperado {ALG})", sig.alg),
139        };
140    }
141    let vk = match trust.get(&sig.by) {
142        None => return SigStatus::UnknownKey { by: sig.by.clone() },
143        Some(vk) => vk,
144    };
145    let raw = match b64().decode(sig.sig.trim()) {
146        Ok(r) => r,
147        Err(e) => {
148            return SigStatus::BadSig {
149                by: sig.by.clone(),
150                reason: format!("firma base64: {e}"),
151            }
152        }
153    };
154    let bytes: [u8; 64] = match raw.as_slice().try_into() {
155        Ok(b) => b,
156        Err(_) => {
157            return SigStatus::BadSig {
158                by: sig.by.clone(),
159                reason: format!("firma: se esperaban 64 bytes, hay {}", raw.len()),
160            }
161        }
162    };
163    let signature = ed25519_dalek::Signature::from_bytes(&bytes);
164    match vk.verify(msg, &signature) {
165        Ok(()) => SigStatus::Trusted { by: sig.by.clone() },
166        Err(_) => SigStatus::BadSig {
167            by: sig.by.clone(),
168            reason: "la firma no corresponde al contenido firmado".into(),
169        },
170    }
171}
172
173/// Claves públicas en las que el usuario confía para *autoría*. Ver `docs/09-trust-model.md` §5.
174#[derive(Debug, Default)]
175pub struct TrustStore {
176    keys: BTreeMap<String, VerifyingKey>,
177}
178
179impl TrustStore {
180    pub fn new() -> TrustStore {
181        TrustStore::default()
182    }
183
184    /// Carga todas las claves `*.ed25519.pub` de un directorio (p. ej.
185    /// `/var/lib/hammer/trust/`). El nombre del autor es el del fichero sin sufijo. Un
186    /// directorio inexistente da un store vacío (no es error: nadie confiado todavía).
187    pub fn load(dir: &Path) -> crate::Result<TrustStore> {
188        let mut store = TrustStore::new();
189        let rd = match std::fs::read_dir(dir) {
190            Ok(rd) => rd,
191            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(store),
192            Err(e) => return Err(e.into()),
193        };
194        for entry in rd {
195            let entry = entry?;
196            let fname = entry.file_name();
197            let fname = fname.to_string_lossy();
198            if let Some(name) = fname.strip_suffix(PUB_EXT) {
199                let content = std::fs::read_to_string(entry.path())?;
200                let vk = parse_public_b64(content.trim()).map_err(|e| {
201                    crate::Error::Other(anyhow::anyhow!("{}: {e}", entry.path().display()))
202                })?;
203                store.keys.insert(name.to_string(), vk);
204            }
205        }
206        Ok(store)
207    }
208
209    /// Añade/reemplaza una clave de confianza desde su base64.
210    pub fn insert_b64(&mut self, name: &str, public_b64: &str) -> crate::Result<()> {
211        let vk = parse_public_b64(public_b64.trim())
212            .map_err(|e| crate::Error::Other(anyhow::anyhow!(e)))?;
213        self.keys.insert(name.to_string(), vk);
214        Ok(())
215    }
216
217    pub fn get(&self, name: &str) -> Option<&VerifyingKey> {
218        self.keys.get(name)
219    }
220
221    pub fn len(&self) -> usize {
222        self.keys.len()
223    }
224
225    pub fn is_empty(&self) -> bool {
226        self.keys.is_empty()
227    }
228}
229
230fn parse_public_b64(s: &str) -> Result<VerifyingKey, String> {
231    let bytes = b64().decode(s).map_err(|e| format!("clave pública base64: {e}"))?;
232    let arr: [u8; 32] = bytes
233        .as_slice()
234        .try_into()
235        .map_err(|_| format!("clave pública: se esperaban 32 bytes, hay {}", bytes.len()))?;
236    VerifyingKey::from_bytes(&arr).map_err(|e| format!("clave pública inválida: {e}"))
237}
238
239/// Bytes canónicos firmados: el contenido del manifiesto SIN la firma, en JSON estable.
240/// `base.pins` es un `BTreeMap` (orden por clave) y el resto son campos de orden fijo, así que
241/// firmante y verificador obtienen exactamente los mismos bytes.
242fn signing_bytes(swm: &Swm) -> Vec<u8> {
243    #[derive(Serialize)]
244    struct Signable<'a> {
245        swm_version: u32,
246        base: &'a Base,
247        mutations: &'a [Mutation],
248    }
249    let signable = Signable {
250        swm_version: swm.swm_version,
251        base: &swm.base,
252        mutations: &swm.mutations,
253    };
254    // serde_json sobre tipos sin floats ni mapas no ordenados ⇒ salida determinista.
255    serde_json::to_vec(&signable).expect("Signable siempre serializa")
256}
257
258impl Swm {
259    /// Verifica la firma del manifiesto contra un [`TrustStore`]. Ver SDD 09 §5.
260    pub fn verify_signature(&self, trust: &TrustStore) -> SigStatus {
261        match &self.signature {
262            None => SigStatus::Unsigned,
263            Some(sig) => verify_raw(&signing_bytes(self), sig, trust),
264        }
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use crate::swm::{Base, Mutation, Swm};
272
273    fn demo_swm() -> Swm {
274        Swm {
275            swm_version: 1,
276            base: Base {
277                distro_version: "2026-06-06".into(),
278                pins: [("grep".to_string(), "a1b2".to_string())].into_iter().collect(),
279            },
280            mutations: vec![Mutation::ConfigEdit {
281                file: "/etc/network.conf".into(),
282                inline_diff: "- DHCP=yes\n+ IP=10.0.0.1\n".into(),
283            }],
284            signature: None,
285        }
286    }
287
288    #[test]
289    fn sign_then_verify_trusted() {
290        let kp = KeyPair::generate().unwrap();
291        let mut swm = demo_swm();
292        swm.signature = Some(kp.sign(&swm, "sergio"));
293
294        let mut trust = TrustStore::new();
295        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
296
297        assert_eq!(
298            swm.verify_signature(&trust),
299            SigStatus::Trusted { by: "sergio".into() }
300        );
301    }
302
303    #[test]
304    fn unsigned_is_unsigned() {
305        assert_eq!(demo_swm().verify_signature(&TrustStore::new()), SigStatus::Unsigned);
306    }
307
308    #[test]
309    fn unknown_key() {
310        let kp = KeyPair::generate().unwrap();
311        let mut swm = demo_swm();
312        swm.signature = Some(kp.sign(&swm, "desconocido"));
313        // TrustStore vacío ⇒ autor desconocido.
314        match swm.verify_signature(&TrustStore::new()) {
315            SigStatus::UnknownKey { by } => assert_eq!(by, "desconocido"),
316            other => panic!("esperaba UnknownKey, obtuve {other:?}"),
317        }
318    }
319
320    #[test]
321    fn tampered_content_is_bad_sig() {
322        let kp = KeyPair::generate().unwrap();
323        let mut swm = demo_swm();
324        swm.signature = Some(kp.sign(&swm, "sergio"));
325
326        // Alteramos el contenido DESPUÉS de firmar.
327        if let Mutation::ConfigEdit { inline_diff, .. } = &mut swm.mutations[0] {
328            *inline_diff = "- DHCP=yes\n+ IP=10.0.0.2\n".into(); // .1 → .2
329        }
330
331        let mut trust = TrustStore::new();
332        trust.insert_b64("sergio", &kp.public_b64()).unwrap();
333        assert!(matches!(
334            swm.verify_signature(&trust),
335            SigStatus::BadSig { .. }
336        ));
337    }
338
339    #[test]
340    fn wrong_key_is_bad_sig() {
341        let kp = KeyPair::generate().unwrap();
342        let other = KeyPair::generate().unwrap();
343        let mut swm = demo_swm();
344        swm.signature = Some(kp.sign(&swm, "sergio"));
345
346        // El TrustStore tiene a "sergio" pero con OTRA clave pública.
347        let mut trust = TrustStore::new();
348        trust.insert_b64("sergio", &other.public_b64()).unwrap();
349        assert!(matches!(
350            swm.verify_signature(&trust),
351            SigStatus::BadSig { .. }
352        ));
353    }
354
355    #[test]
356    fn keypair_roundtrip_through_disk() {
357        let dir = tempfile::tempdir().unwrap();
358        let kp = KeyPair::generate().unwrap();
359        kp.write_to(dir.path(), "sergio").unwrap();
360
361        // Recarga privada y verifica que firma con la misma identidad.
362        let priv_s = std::fs::read_to_string(dir.path().join("sergio.ed25519")).unwrap();
363        let loaded = KeyPair::from_private_b64(&priv_s).unwrap();
364        assert_eq!(loaded.public_b64(), kp.public_b64());
365
366        // La pública en disco carga en un TrustStore por nombre de fichero.
367        let trust = TrustStore::load(dir.path()).unwrap();
368        assert_eq!(trust.len(), 1);
369        assert!(trust.get("sergio").is_some());
370
371        // Permisos 0600 en la privada.
372        use std::os::unix::fs::PermissionsExt;
373        let mode = std::fs::metadata(dir.path().join("sergio.ed25519"))
374            .unwrap()
375            .permissions()
376            .mode()
377            & 0o777;
378        assert_eq!(mode, 0o600);
379    }
380
381    #[test]
382    fn load_missing_dir_is_empty() {
383        let t = TrustStore::load(Path::new("/no/existe/aqui")).unwrap();
384        assert!(t.is_empty());
385    }
386
387    #[test]
388    fn signing_bytes_ignore_signature_field() {
389        // Firmar no debe depender del campo signature: dos SWM idénticos salvo signature
390        // producen los mismos bytes firmados.
391        let mut a = demo_swm();
392        let b = demo_swm();
393        a.signature = Some(Signature {
394            by: "x".into(),
395            alg: "ed25519".into(),
396            sig: "AAAA".into(),
397        });
398        assert_eq!(signing_bytes(&a), signing_bytes(&b));
399    }
400}