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…
⎇ 84e3737e10e17927380ed3e232e50cebfdd39123
crates/takana-core/src/sign.rs
Historia
Quién tocó qué
crudo
takana etapa 5d: comentarios de crates, CLAUDE.md y el skill de granja
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}