Skip to main content

agentid_core/
identity.rs

1//! Agent identity — Ed25519 keypair derivation and signing primitives.
2//!
3//! Identities are deterministic by default: given the same
4//! `(name, project, seed?)` triple, the same secret key is produced. This is
5//! intentional — it lets a developer recreate an identity on a new machine
6//! from the same inputs without copying secret material around. For one-off
7//! ephemeral identities, callers can pass a random seed.
8//!
9//! Derivation:
10//!
11//! ```text
12//!   IKM   = name || 0x00 || project || 0x00 || seed?
13//!   salt  = b"agentid-v1"
14//!   info  = b"ed25519-signing-key"
15//!   okm   = HKDF-SHA256(salt, IKM, info, len = 32)
16//!   sk    = Ed25519 SigningKey::from_bytes(okm)
17//! ```
18
19use ed25519_dalek::{
20    Signature, Signer, SigningKey, Verifier, VerifyingKey, SECRET_KEY_LENGTH, SIGNATURE_LENGTH,
21};
22use hkdf::Hkdf;
23use sha2::{Digest, Sha256};
24use thiserror::Error;
25use zeroize::Zeroize;
26
27const HKDF_SALT: &[u8] = b"agentid-v1";
28const HKDF_INFO: &[u8] = b"ed25519-signing-key";
29
30/// Errors produced by the identity layer.
31#[derive(Error, Debug)]
32pub enum IdentityError {
33    #[error("invalid public key length: expected 32, got {0}")]
34    InvalidPublicKeyLength(usize),
35    #[error("invalid secret key length: expected 32, got {0}")]
36    InvalidSecretKeyLength(usize),
37    #[error("invalid signature length: expected 64, got {0}")]
38    InvalidSignatureLength(usize),
39    #[error("invalid public key bytes")]
40    InvalidPublicKey,
41    #[error("signature verification failed")]
42    BadSignature,
43    #[error("name must not be empty")]
44    EmptyName,
45    #[error("project must not be empty")]
46    EmptyProject,
47    #[error("name too long: max 255 bytes, got {0}")]
48    NameTooLong(usize),
49    #[error("project too long: max 255 bytes, got {0}")]
50    ProjectTooLong(usize),
51}
52
53/// A cryptographic agent identity. Wraps an Ed25519 [`SigningKey`] alongside
54/// the human-readable `(name, project)` tuple that derives it.
55pub struct AgentIdentity {
56    pub name: String,
57    pub project: String,
58    signing_key: SigningKey,
59}
60
61impl AgentIdentity {
62    /// Deterministically derive an identity from `(name, project, seed?)`.
63    ///
64    /// The same inputs always produce the same keypair. Pass a random `seed`
65    /// to mint an ephemeral identity that's still bound to a name/project for
66    /// logging.
67    pub fn derive(name: &str, project: &str, seed: Option<&[u8]>) -> Result<Self, IdentityError> {
68        validate_name_project(name, project)?;
69
70        let mut ikm = Vec::with_capacity(name.len() + project.len() + 2 + seed.map_or(0, <[u8]>::len));
71        ikm.extend_from_slice(name.as_bytes());
72        ikm.push(0);
73        ikm.extend_from_slice(project.as_bytes());
74        ikm.push(0);
75        if let Some(s) = seed {
76            ikm.extend_from_slice(s);
77        }
78
79        let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), &ikm);
80        let mut okm = [0u8; SECRET_KEY_LENGTH];
81        hk.expand(HKDF_INFO, &mut okm)
82            .expect("HKDF-SHA256 expand to 32 bytes never fails");
83        let signing_key = SigningKey::from_bytes(&okm);
84
85        ikm.zeroize();
86        okm.zeroize();
87
88        Ok(Self {
89            name: name.to_string(),
90            project: project.to_string(),
91            signing_key,
92        })
93    }
94
95    /// Reconstruct an identity from a raw 32-byte Ed25519 secret key.
96    pub fn from_secret_bytes(
97        name: &str,
98        project: &str,
99        secret: &[u8],
100    ) -> Result<Self, IdentityError> {
101        validate_name_project(name, project)?;
102        if secret.len() != SECRET_KEY_LENGTH {
103            return Err(IdentityError::InvalidSecretKeyLength(secret.len()));
104        }
105        let mut sk = [0u8; SECRET_KEY_LENGTH];
106        sk.copy_from_slice(secret);
107        let signing_key = SigningKey::from_bytes(&sk);
108        sk.zeroize();
109        Ok(Self {
110            name: name.to_string(),
111            project: project.to_string(),
112            signing_key,
113        })
114    }
115
116    /// 32-byte Ed25519 public key.
117    pub fn public_key(&self) -> [u8; 32] {
118        self.signing_key.verifying_key().to_bytes()
119    }
120
121    /// Hex-encoded public key.
122    pub fn public_key_hex(&self) -> String {
123        hex::encode(self.public_key())
124    }
125
126    /// 32-byte Ed25519 secret key. Treat as sensitive — caller is responsible
127    /// for zeroising the returned buffer.
128    pub fn secret_bytes(&self) -> [u8; SECRET_KEY_LENGTH] {
129        self.signing_key.to_bytes()
130    }
131
132    /// Underlying signing key, for advanced uses.
133    pub fn signing_key(&self) -> &SigningKey {
134        &self.signing_key
135    }
136
137    /// Human-readable fingerprint: `ag:sha256:<first-16-hex-chars-of-SHA256(pubkey)>`.
138    pub fn fingerprint(&self) -> String {
139        fingerprint_from_pubkey(&self.public_key())
140    }
141
142    /// Sign an arbitrary message with this identity.
143    pub fn sign(&self, msg: &[u8]) -> Signature {
144        self.signing_key.sign(msg)
145    }
146}
147
148impl std::fmt::Debug for AgentIdentity {
149    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150        f.debug_struct("AgentIdentity")
151            .field("name", &self.name)
152            .field("project", &self.project)
153            .field("fingerprint", &self.fingerprint())
154            .finish()
155    }
156}
157
158/// Verify an Ed25519 signature.
159pub fn verify_signature(
160    public_key: &[u8; 32],
161    msg: &[u8],
162    signature: &[u8; SIGNATURE_LENGTH],
163) -> Result<(), IdentityError> {
164    let vk = VerifyingKey::from_bytes(public_key).map_err(|_| IdentityError::InvalidPublicKey)?;
165    let sig = Signature::from_bytes(signature);
166    vk.verify(msg, &sig).map_err(|_| IdentityError::BadSignature)
167}
168
169/// Compute the fingerprint string from a raw public key.
170pub fn fingerprint_from_pubkey(pubkey: &[u8; 32]) -> String {
171    let mut h = Sha256::new();
172    h.update(pubkey);
173    let digest = h.finalize();
174    format!("ag:sha256:{}", &hex::encode(digest)[..16])
175}
176
177fn validate_name_project(name: &str, project: &str) -> Result<(), IdentityError> {
178    if name.is_empty() {
179        return Err(IdentityError::EmptyName);
180    }
181    if project.is_empty() {
182        return Err(IdentityError::EmptyProject);
183    }
184    if name.len() > 255 {
185        return Err(IdentityError::NameTooLong(name.len()));
186    }
187    if project.len() > 255 {
188        return Err(IdentityError::ProjectTooLong(project.len()));
189    }
190    Ok(())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    #[test]
198    fn derivation_is_deterministic() {
199        let a = AgentIdentity::derive("research-bot", "phd-lab", None).unwrap();
200        let b = AgentIdentity::derive("research-bot", "phd-lab", None).unwrap();
201        assert_eq!(a.public_key(), b.public_key());
202        assert_eq!(a.fingerprint(), b.fingerprint());
203    }
204
205    #[test]
206    fn different_projects_produce_different_keys() {
207        let a = AgentIdentity::derive("bot", "proj-a", None).unwrap();
208        let b = AgentIdentity::derive("bot", "proj-b", None).unwrap();
209        assert_ne!(a.public_key(), b.public_key());
210    }
211
212    #[test]
213    fn seed_changes_key() {
214        let a = AgentIdentity::derive("bot", "proj", None).unwrap();
215        let b = AgentIdentity::derive("bot", "proj", Some(b"extra")).unwrap();
216        assert_ne!(a.public_key(), b.public_key());
217    }
218
219    #[test]
220    fn round_trip_secret_bytes() {
221        let a = AgentIdentity::derive("bot", "proj", None).unwrap();
222        let bytes = a.secret_bytes();
223        let b = AgentIdentity::from_secret_bytes("bot", "proj", &bytes).unwrap();
224        assert_eq!(a.public_key(), b.public_key());
225    }
226
227    #[test]
228    fn sign_and_verify() {
229        let a = AgentIdentity::derive("bot", "proj", None).unwrap();
230        let msg = b"hello";
231        let sig = a.sign(msg);
232        let pk = a.public_key();
233        assert!(verify_signature(&pk, msg, &sig.to_bytes()).is_ok());
234        assert!(verify_signature(&pk, b"goodbye", &sig.to_bytes()).is_err());
235    }
236
237    #[test]
238    fn fingerprint_format() {
239        let a = AgentIdentity::derive("bot", "proj", None).unwrap();
240        let fp = a.fingerprint();
241        assert!(fp.starts_with("ag:sha256:"));
242        assert_eq!(fp.len(), "ag:sha256:".len() + 16);
243    }
244
245    #[test]
246    fn rejects_empty_name() {
247        assert!(matches!(
248            AgentIdentity::derive("", "proj", None),
249            Err(IdentityError::EmptyName)
250        ));
251    }
252}