//! Persistencia de la keypair Ed25519 de identidad libp2p de Arje. //! //! El `peer_id` que Arje presenta en la malla `brahman-net` deriva de //! esta keypair. Si se regenera en cada arranque, el peer_id cambia //! y los nodos remotos pierden la referencia. Persistir el secret a //! disco (32 bytes raw, permisos 0o600) garantiza identidad estable. //! //! ## Path //! //! Por orden de precedencia: //! 1. `BRAHMAN_KEYPAIR_PATH` env var (override explícito). //! 2. Si PID 1 / root: `/var/lib/brahman/init-keypair.bin`. //! 3. Si dev mode: `$XDG_DATA_HOME/brahman/init-keypair.bin`, fallback //! a `$HOME/.local/share/brahman/init-keypair.bin`, último recurso //! `/tmp/brahman-init-keypair.bin` (sin persistencia útil pero al //! menos no rompe en CI minimalista). //! //! ## Formato //! //! 32 bytes raw del secret Ed25519. Sin header, sin metadata. La //! public key se deriva determinísticamente al cargar. Esto evita //! depender de un schema de serialización (postcard, json) que //! pudiera bumpear y romper compat de identidad. use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use brahman_net::Keypair; /// Tamaño exacto del secret Ed25519. const SECRET_LEN: usize = 32; /// Carga la keypair desde `path` si existe, o genera una nueva, /// la persiste y la devuelve. Devuelve también si fue cargada (true) /// o generada (false), para logging. pub fn load_or_generate(path: &Path) -> Result<(Keypair, bool)> { if path.exists() { let bytes = std::fs::read(path) .with_context(|| format!("leer keypair de {}", path.display()))?; if bytes.len() != SECRET_LEN { bail!( "keypair en {} tiene {} bytes, esperaba {}", path.display(), bytes.len(), SECRET_LEN ); } let mut secret = [0u8; SECRET_LEN]; secret.copy_from_slice(&bytes); let kp = Keypair::ed25519_from_bytes(secret) .with_context(|| format!("decodificar keypair en {}", path.display()))?; Ok((kp, true)) } else { let kp = Keypair::generate_ed25519(); save(path, &kp).context("persistir keypair recién generada")?; Ok((kp, false)) } } /// Persiste el secret de `keypair` a `path`. Crea directorios padres, /// escribe atómico (vía rename), y aplica permisos 0o600 (sólo dueño). fn save(path: &Path, keypair: &Keypair) -> Result<()> { let secret = extract_secret_bytes(keypair)?; if let Some(parent) = path.parent() { std::fs::create_dir_all(parent) .with_context(|| format!("crear dir {}", parent.display()))?; } let tmp = path.with_extension("tmp"); std::fs::write(&tmp, secret).with_context(|| format!("write tmp {}", tmp.display()))?; apply_owner_only_perms(&tmp).context("permisos 0o600 en tmp")?; std::fs::rename(&tmp, path) .with_context(|| format!("rename {} → {}", tmp.display(), path.display()))?; Ok(()) } fn extract_secret_bytes(keypair: &Keypair) -> Result<[u8; SECRET_LEN]> { // libp2p::Keypair no expone secret() directo; pasamos // por la variante ed25519. Solo Ed25519 soportado en brahman-net, // así que el unwrap es seguro tras with_keypair. let ed = keypair .clone() .try_into_ed25519() .map_err(|_| anyhow::anyhow!("la keypair no es Ed25519 (no debería pasar)"))?; let bytes = ed.secret(); let raw: &[u8] = bytes.as_ref(); if raw.len() != SECRET_LEN { bail!("ed25519 secret no es {} bytes", SECRET_LEN); } let mut out = [0u8; SECRET_LEN]; out.copy_from_slice(raw); Ok(out) } #[cfg(unix)] fn apply_owner_only_perms(path: &Path) -> std::io::Result<()> { use std::os::unix::fs::PermissionsExt; let perms = std::fs::Permissions::from_mode(0o600); std::fs::set_permissions(path, perms) } #[cfg(not(unix))] fn apply_owner_only_perms(_path: &Path) -> std::io::Result<()> { Ok(()) } /// Resuelve el path del keystore según convención (env > root path > /// XDG > HOME > tmp). pub fn default_path(dev_mode: bool) -> PathBuf { if let Ok(p) = std::env::var("BRAHMAN_KEYPAIR_PATH") { return PathBuf::from(p); } if !dev_mode { // PID 1: paths del sistema. /var/lib es el lugar canónico // para state persistente de servicios root. return PathBuf::from("/var/lib/brahman/init-keypair.bin"); } // Dev mode: paths de usuario. if let Ok(xdg) = std::env::var("XDG_DATA_HOME") { return PathBuf::from(xdg).join("brahman").join("init-keypair.bin"); } if let Ok(home) = std::env::var("HOME") { return PathBuf::from(home) .join(".local") .join("share") .join("brahman") .join("init-keypair.bin"); } PathBuf::from("/tmp/brahman-init-keypair.bin") } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; #[test] fn generate_persist_and_reload_yields_same_peer_id() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("identity.bin"); let (kp1, loaded) = load_or_generate(&path).unwrap(); assert!(!loaded, "primera vez debe generar"); let peer1 = kp1.public().to_peer_id(); let (kp2, loaded) = load_or_generate(&path).unwrap(); assert!(loaded, "segunda vez debe cargar"); let peer2 = kp2.public().to_peer_id(); assert_eq!(peer1, peer2, "peer_id estable across reloads"); } #[test] fn rejects_corrupted_file() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("bad.bin"); std::fs::write(&path, b"too short").unwrap(); assert!(load_or_generate(&path).is_err()); } #[test] #[cfg(unix)] fn persisted_file_is_owner_only() { use std::os::unix::fs::PermissionsExt; let tmp = TempDir::new().unwrap(); let path = tmp.path().join("perm.bin"); let _ = load_or_generate(&path).unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode(); assert_eq!( mode & 0o777, 0o600, "permisos del keypair file deben ser 0o600 (solo dueño), got {:o}", mode & 0o777 ); } #[test] fn default_path_honors_env() { std::env::set_var("BRAHMAN_KEYPAIR_PATH", "/custom/path.bin"); assert_eq!(default_path(false), PathBuf::from("/custom/path.bin")); assert_eq!(default_path(true), PathBuf::from("/custom/path.bin")); std::env::remove_var("BRAHMAN_KEYPAIR_PATH"); } }