//! Local keypair identity — ownership without accounts. //! //! The cli keeps an ed25519 keypair at ~/.config/notdouble/identity.key. //! Uploads and deletes are signed with it; the hub stores the public key //! as the project's `owner`. Private key never leaves the machine. use anyhow::{Context, Result}; use base64::Engine as _; use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use std::path::Path; #[derive(Debug, Clone)] pub struct Identity { signing: SigningKey, } impl Identity { pub fn generate() -> Identity { let mut rng = rand_core::OsRng; Identity { signing: SigningKey::generate(&mut rng) } } /// base64 of the 32-byte public key — this is what the hub stores. pub fn public_b64(&self) -> String { b64(&self.signing.verifying_key().to_bytes()) } /// Short display form (first 8 base64 chars). pub fn short(&self) -> String { self.public_b64().chars().take(8).collect() } /// base64 signature over `msg`. pub fn sign(&self, msg: &[u8]) -> String { b64(&self.signing.sign(msg).to_bytes()) } pub fn save(&self, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } std::fs::write(path, b64(&self.signing.to_bytes()))?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; } Ok(()) } } pub fn load(path: &Path) -> Result { let raw = std::fs::read_to_string(path) .with_context(|| format!("read {}", path.display()))?; from_seed_b64(raw.trim()).with_context(|| format!("bad identity file {}", path.display())) } /// Load the keypair, generating and saving a new one if missing. pub fn load_or_create(path: &Path) -> Result { if path.exists() { load(path) } else { let id = Identity::generate(); id.save(path)?; Ok(id) } } pub fn from_seed_b64(s: &str) -> Result { let bytes = unb64(s)?; let seed: [u8; 32] = bytes .try_into() .map_err(|_| anyhow::anyhow!("identity seed must be 32 bytes"))?; Ok(Identity { signing: SigningKey::from_bytes(&seed) }) } /// Verify a base64 signature over `msg` made by the base64 public key. pub fn verify(pub_b64: &str, msg: &[u8], sig_b64: &str) -> bool { let (Ok(pk), Ok(sig)) = (unb64(pub_b64), unb64(sig_b64)) else { return false; }; let (Ok(pk), Ok(sig)) = ( <[u8; 32]>::try_from(pk.as_slice()), <[u8; 64]>::try_from(sig.as_slice()), ) else { return false; }; let Ok(vk) = VerifyingKey::from_bytes(&pk) else { return false; }; vk.verify(msg, &Signature::from_bytes(&sig)).is_ok() } /// Does this string decode to a plausible ed25519 public key? pub fn pubkey_valid(s: &str) -> bool { unb64(s).map(|b| b.len() == 32).unwrap_or(false) } /// First 8 chars of a public key, for display. pub fn short_key(pub_b64: &str) -> String { pub_b64.chars().take(8).collect() } fn b64(data: &[u8]) -> String { base64::engine::general_purpose::STANDARD.encode(data) } fn unb64(s: &str) -> Result> { Ok(base64::engine::general_purpose::STANDARD.decode(s.trim())?) } #[cfg(test)] mod tests { use super::*; #[test] fn roundtrip_and_sign() { let id = Identity::generate(); let seed = b64(&{ // recover seed via from_seed path: serialize pub, then re-derive [0u8; 0] }); let _ = seed; let pub_b64 = id.public_b64(); assert_eq!(pub_b64.len(), 44); // 32 bytes -> 43 chars + '=' padding assert!(pubkey_valid(&pub_b64)); let sig = id.sign(b"hello"); assert!(verify(&pub_b64, b"hello", &sig)); assert!(!verify(&pub_b64, b"tampered", &sig)); // a different key cannot verify for this owner let other = Identity::generate(); assert!(!verify(&other.public_b64(), b"hello", &sig)); } #[test] fn seed_roundtrip() { let id = Identity::generate(); let file = std::env::temp_dir().join(format!("nd-id-{}", std::process::id())); id.save(&file).unwrap(); let back = load(&file).unwrap(); assert_eq!(id.public_b64(), back.public_b64()); std::fs::remove_file(&file).ok(); } }