1use 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#[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
53pub struct AgentIdentity {
56 pub name: String,
57 pub project: String,
58 signing_key: SigningKey,
59}
60
61impl AgentIdentity {
62 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 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 pub fn public_key(&self) -> [u8; 32] {
118 self.signing_key.verifying_key().to_bytes()
119 }
120
121 pub fn public_key_hex(&self) -> String {
123 hex::encode(self.public_key())
124 }
125
126 pub fn secret_bytes(&self) -> [u8; SECRET_KEY_LENGTH] {
129 self.signing_key.to_bytes()
130 }
131
132 pub fn signing_key(&self) -> &SigningKey {
134 &self.signing_key
135 }
136
137 pub fn fingerprint(&self) -> String {
139 fingerprint_from_pubkey(&self.public_key())
140 }
141
142 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
158pub 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
169pub 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}