use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey};
use crate::error::{Error, Result};
#[derive(Clone)]
pub struct AgentIdentity {
signing_key: SigningKey,
}
impl std::fmt::Debug for AgentIdentity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentIdentity")
.field("public_key", &self.public_key_base64())
.finish_non_exhaustive()
}
}
impl AgentIdentity {
pub fn generate() -> Self {
Self {
signing_key: SigningKey::generate(&mut rand::rngs::OsRng),
}
}
pub fn from_secret_bytes(bytes: &[u8; 32]) -> Self {
Self {
signing_key: SigningKey::from_bytes(bytes),
}
}
pub fn from_secret_base64(encoded: &str) -> Result<Self> {
let bytes = B64
.decode(encoded.trim())
.map_err(|e| Error::Identity(format!("invalid base64 secret key: {e}")))?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| Error::Identity("secret key must be exactly 32 bytes".into()))?;
Ok(Self::from_secret_bytes(&bytes))
}
pub fn secret_key_base64(&self) -> String {
B64.encode(self.signing_key.to_bytes())
}
pub fn public_key_base64(&self) -> String {
B64.encode(self.signing_key.verifying_key().to_bytes())
}
pub fn did_key(&self) -> String {
did_key_from_public_bytes(&self.signing_key.verifying_key().to_bytes())
}
pub fn sign(&self, message: &[u8]) -> String {
B64.encode(self.signing_key.sign(message).to_bytes())
}
#[cfg(feature = "x509")]
pub(crate) fn pkcs8_der(&self) -> Result<Vec<u8>> {
use ed25519_dalek::pkcs8::EncodePrivateKey;
let document = self
.signing_key
.to_pkcs8_der()
.map_err(|e| Error::Identity(format!("PKCS#8 encoding failed: {e}")))?;
Ok(document.as_bytes().to_vec())
}
}
const DID_KEY_ED25519_MULTICODEC: [u8; 2] = [0xed, 0x01];
fn did_key_from_public_bytes(public_key: &[u8; 32]) -> String {
let mut bytes = Vec::with_capacity(34);
bytes.extend_from_slice(&DID_KEY_ED25519_MULTICODEC);
bytes.extend_from_slice(public_key);
format!("did:key:z{}", bs58::encode(bytes).into_string())
}
pub fn did_key_from_public(public_key_base64: &str) -> Result<String> {
let bytes = B64
.decode(public_key_base64.trim())
.map_err(|e| Error::Identity(format!("invalid base64 public key: {e}")))?;
let bytes: [u8; 32] = bytes
.try_into()
.map_err(|_| Error::Identity("public key must be exactly 32 bytes".into()))?;
Ok(did_key_from_public_bytes(&bytes))
}
pub fn public_key_from_did(did: &str) -> Result<String> {
let encoded = did.trim().strip_prefix("did:key:z").ok_or_else(|| {
Error::Identity("only did:key with base58btc multibase ('z') is supported".into())
})?;
let bytes = bs58::decode(encoded)
.into_vec()
.map_err(|e| Error::Identity(format!("invalid base58 in did:key: {e}")))?;
let key = bytes
.strip_prefix(&DID_KEY_ED25519_MULTICODEC)
.ok_or_else(|| Error::Identity("did:key does not contain an Ed25519 key".into()))?;
if key.len() != 32 {
return Err(Error::Identity(
"did:key payload must be a 32-byte Ed25519 key".into(),
));
}
Ok(B64.encode(key))
}
pub fn normalize_key(key_or_did: &str) -> Result<String> {
let trimmed = key_or_did.trim();
if trimmed.starts_with("did:key:") {
return public_key_from_did(trimmed);
}
let bytes = B64
.decode(trimmed)
.map_err(|e| Error::Identity(format!("invalid base64 public key: {e}")))?;
if bytes.len() != 32 {
return Err(Error::Identity(
"public key must be exactly 32 bytes".into(),
));
}
Ok(trimmed.to_string())
}
pub fn verify_signature(public_key: &str, message: &[u8], signature: &str) -> Result<()> {
let key_bytes = B64
.decode(normalize_key(public_key)?)
.map_err(|e| Error::Identity(format!("invalid base64 public key: {e}")))?;
let key_bytes: [u8; 32] = key_bytes
.try_into()
.map_err(|_| Error::Identity("public key must be exactly 32 bytes".into()))?;
let verifying_key = VerifyingKey::from_bytes(&key_bytes)
.map_err(|e| Error::Identity(format!("invalid Ed25519 public key: {e}")))?;
let sig_bytes = B64
.decode(signature.trim())
.map_err(|e| Error::Identity(format!("invalid base64 signature: {e}")))?;
let sig_bytes: [u8; 64] = sig_bytes
.try_into()
.map_err(|_| Error::Identity("signature must be exactly 64 bytes".into()))?;
let signature = Signature::from_bytes(&sig_bytes);
verifying_key
.verify(message, &signature)
.map_err(|_| Error::Verification("Ed25519 signature does not match".into()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_and_verify_roundtrip() {
let identity = AgentIdentity::generate();
let sig = identity.sign(b"hello agent");
verify_signature(&identity.public_key_base64(), b"hello agent", &sig).unwrap();
}
#[test]
fn tampered_message_fails() {
let identity = AgentIdentity::generate();
let sig = identity.sign(b"hello agent");
let err =
verify_signature(&identity.public_key_base64(), b"hello agent!", &sig).unwrap_err();
assert!(matches!(err, Error::Verification(_)));
}
#[test]
fn secret_key_roundtrip() {
let identity = AgentIdentity::generate();
let restored = AgentIdentity::from_secret_base64(&identity.secret_key_base64()).unwrap();
assert_eq!(identity.public_key_base64(), restored.public_key_base64());
}
#[test]
fn wrong_key_fails() {
let identity = AgentIdentity::generate();
let other = AgentIdentity::generate();
let sig = identity.sign(b"msg");
assert!(verify_signature(&other.public_key_base64(), b"msg", &sig).is_err());
}
#[test]
fn did_key_roundtrip() {
let identity = AgentIdentity::generate();
let did = identity.did_key();
assert!(
did.starts_with("did:key:z6Mk"),
"ed25519 did:key starts with z6Mk, got {did}"
);
assert_eq!(
public_key_from_did(&did).unwrap(),
identity.public_key_base64()
);
assert_eq!(
did_key_from_public(&identity.public_key_base64()).unwrap(),
did
);
}
#[test]
fn verify_accepts_did_as_key() {
let identity = AgentIdentity::generate();
let sig = identity.sign(b"did message");
verify_signature(&identity.did_key(), b"did message", &sig).unwrap();
}
#[test]
fn normalize_key_accepts_both_forms() {
let identity = AgentIdentity::generate();
let b64 = identity.public_key_base64();
assert_eq!(normalize_key(&b64).unwrap(), b64);
assert_eq!(normalize_key(&identity.did_key()).unwrap(), b64);
assert!(normalize_key("did:key:not-multibase").is_err());
assert!(normalize_key("not base64!!!").is_err());
}
}