//! Device identity: ed25519 keypairs in OpenSSH format, signing and verification. use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use serde::{Deserialize, Serialize}; use ssh_key::{LineEnding, PrivateKey, PublicKey}; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; /// A registered device entry in devices.json. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DeviceEntry { pub name: String, /// OpenSSH public key format: "ssh-ed25519 AAAA..." pub public_key: String, pub added_at: i64, pub added_by: String, } /// A revoked device entry in revoked.json. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RevokedEntry { pub name: String, pub public_key: String, pub revoked_at: i64, pub revoked_by: String, } /// Generate a new ed25519 keypair, returning (private_openssh, public_openssh). pub fn generate_keypair() -> Result<(Zeroizing, String)> { use ssh_key::private::{Ed25519Keypair, Ed25519PrivateKey, KeypairData}; use ssh_key::public::Ed25519PublicKey; let signing_key = SigningKey::generate(&mut rand::rngs::OsRng); let verifying_key = signing_key.verifying_key(); // Build ssh-key types from raw bytes let ed_private = Ed25519PrivateKey::from_bytes(signing_key.as_bytes()); let ed_public = Ed25519PublicKey(*verifying_key.as_bytes()); let keypair = Ed25519Keypair { public: ed_public, private: ed_private }; let keypair_data = KeypairData::Ed25519(keypair); let ssh_private = PrivateKey::new(keypair_data, "") .map_err(|e| RelicarioError::DeviceKey(format!("private key create: {e}")))?; let ssh_public = ssh_private.public_key(); let private_pem = ssh_private .to_openssh(LineEnding::LF) .map_err(|e| RelicarioError::DeviceKey(format!("private key encode: {e}")))?; let public_line = ssh_public .to_openssh() .map_err(|e| RelicarioError::DeviceKey(format!("public key encode: {e}")))?; Ok((Zeroizing::new(private_pem.to_string()), public_line)) } /// Sign data with an OpenSSH private key, returning base64 signature. pub fn sign(private_key_openssh: &str, data: &[u8]) -> Result { use base64::Engine; let private = PrivateKey::from_openssh(private_key_openssh) .map_err(|e| RelicarioError::DeviceKey(format!("parse private key: {e}")))?; let key_data = private .key_data() .ed25519() .ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?; let secret_slice: &[u8] = key_data.private.as_ref(); let secret_bytes: [u8; 32] = secret_slice .try_into() .map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?; let signing_key = SigningKey::from_bytes(&secret_bytes); let signature = signing_key.sign(data); Ok(base64::engine::general_purpose::STANDARD.encode(signature.to_bytes())) } /// Verify a signature against an OpenSSH public key. pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Result { use base64::Engine; let public = PublicKey::from_openssh(public_key_openssh) .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?; let key_data = public .key_data() .ed25519() .ok_or_else(|| RelicarioError::DeviceKey("not an ed25519 key".into()))?; let pub_slice: &[u8] = key_data.as_ref(); let pub_bytes: [u8; 32] = pub_slice .try_into() .map_err(|_| RelicarioError::DeviceKey("invalid key length".into()))?; let verifying_key = VerifyingKey::from_bytes(&pub_bytes) .map_err(|e| RelicarioError::DeviceKey(format!("invalid public key: {e}")))?; let sig_bytes = base64::engine::general_purpose::STANDARD .decode(signature_b64) .map_err(|e| RelicarioError::DeviceKey(format!("decode signature: {e}")))?; let signature = Signature::from_slice(&sig_bytes) .map_err(|e| RelicarioError::DeviceKey(format!("parse signature: {e}")))?; Ok(verifying_key.verify(data, &signature).is_ok()) } /// Compute the OpenSSH SHA-256 fingerprint of a public key. /// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`: /// `SHA256:<43-char base64 without padding>`. pub fn fingerprint(public_key_openssh: &str) -> Result { use ssh_key::HashAlg; let public = PublicKey::from_openssh(public_key_openssh) .map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?; Ok(public.fingerprint(HashAlg::Sha256).to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn generate_and_sign_verify_roundtrip() { let (private, public) = generate_keypair().unwrap(); let data = b"hello world"; let sig = sign(&private, data).unwrap(); assert!(verify(&public, data, &sig).unwrap()); } #[test] fn verify_rejects_wrong_data() { let (private, public) = generate_keypair().unwrap(); let sig = sign(&private, b"hello").unwrap(); assert!(!verify(&public, b"world", &sig).unwrap()); } #[test] fn verify_rejects_wrong_key() { let (private, _) = generate_keypair().unwrap(); let (_, other_public) = generate_keypair().unwrap(); let sig = sign(&private, b"hello").unwrap(); assert!(!verify(&other_public, b"hello", &sig).unwrap()); } #[test] fn fingerprint_matches_ssh_keygen_format() { let (_, public) = generate_keypair().unwrap(); let fp = fingerprint(&public).unwrap(); assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}"); let body = fp.strip_prefix("SHA256:").unwrap(); assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)"); assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/')); } #[test] fn fingerprint_is_deterministic() { let (_, public) = generate_keypair().unwrap(); assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap()); } #[test] fn fingerprint_differs_per_key() { let (_, p1) = generate_keypair().unwrap(); let (_, p2) = generate_keypair().unwrap(); assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap()); } }