use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; use rand_core::OsRng; use serde::Serialize; use std::path::Path; #[derive(Debug, thiserror::Error)] pub enum CryptoError { #[error("io error: {0}")] Io(#[from] std::io::Error), #[error("invalid hex key: {0}")] Hex(#[from] hex::FromHexError), #[error("invalid ed25519 key material")] InvalidKey, #[error("signature verification failed")] Verify, #[error("canonical encoding failed: {0}")] Codec(#[from] geth_codec::CodecError), } pub struct AgentKey { signing_key: SigningKey, } impl AgentKey { #[must_use] pub fn generate() -> Self { Self { signing_key: SigningKey::generate(&mut OsRng), } } pub fn load_or_create(path: &Path) -> Result { if path.exists() { return Self::load(path); } let key = Self::generate(); key.save(path)?; Ok(key) } pub fn load(path: &Path) -> Result { let hex_key = std::fs::read_to_string(path)?; let bytes = hex::decode(hex_key.trim())?; let key_bytes: [u8; 32] = bytes.try_into().map_err(|_| CryptoError::InvalidKey)?; Ok(Self { signing_key: SigningKey::from_bytes(&key_bytes), }) } pub fn save(&self, path: &Path) -> Result<(), CryptoError> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let tmp = path.with_extension("tmp"); std::fs::write(&tmp, hex::encode(self.signing_key.to_bytes()))?; std::fs::rename(tmp, path)?; Ok(()) } #[must_use] pub fn verifying_key(&self) -> VerifyingKey { self.signing_key.verifying_key() } #[must_use] pub fn public_key_hex(&self) -> String { hex::encode(self.verifying_key().to_bytes()) } #[must_use] pub fn agent_id(&self) -> geth_types::AgentId { geth_types::AgentId::new(key_fingerprint(&self.verifying_key().to_bytes())) } #[must_use] pub fn sign(&self, bytes: &[u8]) -> Vec { self.signing_key.sign(bytes).to_bytes().to_vec() } pub fn sign_canonical( &self, namespace: &str, payload: &T, ) -> Result, CryptoError> { Ok(self.sign(&geth_codec::signing_payload(namespace, payload)?)) } } pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result<(), CryptoError> { let key_bytes: [u8; 32] = public_key.try_into().map_err(|_| CryptoError::InvalidKey)?; let verifying_key = VerifyingKey::from_bytes(&key_bytes).map_err(|_| CryptoError::InvalidKey)?; let sig = Signature::from_slice(signature).map_err(|_| CryptoError::InvalidKey)?; verifying_key .verify(message, &sig) .map_err(|_| CryptoError::Verify) } pub fn verify_canonical( public_key: &[u8], namespace: &str, payload: &T, signature: &[u8], ) -> Result<(), CryptoError> { verify( public_key, &geth_codec::signing_payload(namespace, payload)?, signature, ) } #[must_use] pub fn blake3_hex(bytes: &[u8]) -> String { blake3::hash(bytes).to_hex().to_string() } #[must_use] pub fn key_fingerprint(public_key: &[u8]) -> String { format!("ed25519:{}", blake3_hex(public_key)) } #[cfg(test)] mod tests { use super::*; #[test] fn agent_identity_persists() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("agent.ed25519"); let first = AgentKey::load_or_create(&path).expect("create"); let second = AgentKey::load_or_create(&path).expect("load"); assert_eq!(first.agent_id(), second.agent_id()); } #[test] fn blake3_helper_matches_known_hash() { assert_eq!( blake3_hex(b"hello geth"), "3a4aa805ade0d4694a1bb69ad5b9a2f1dffcd4de2136df9a465023722d26e325" ); } #[derive(Clone, serde::Serialize)] struct CanonicalSample { version: u8, name: String, } #[test] fn canonical_signature_verifies_only_for_matching_namespace() { let key = AgentKey::generate(); let sample = CanonicalSample { version: 1, name: "geth".to_owned(), }; let signature = key .sign_canonical("geth.keychain.v1@geth.local", &sample) .expect("sign"); verify_canonical( &key.verifying_key().to_bytes(), "geth.keychain.v1@geth.local", &sample, &signature, ) .expect("verify matching namespace"); assert!(matches!( verify_canonical( &key.verifying_key().to_bytes(), "geth.auth-op.v1@geth.local", &sample, &signature, ), Err(CryptoError::Verify) )); } }