1use crate::identity::{fingerprint_from_pubkey, verify_signature, AgentIdentity, IdentityError};
38use crate::scopes::{Scope, ScopeError};
39use ed25519_dalek::{Signer, SIGNATURE_LENGTH};
40use serde::{Deserialize, Serialize};
41use thiserror::Error;
42
43pub const MAGIC: [u8; 2] = [0xA9, 0x1D];
45pub const VERSION: u8 = 0x01;
47
48pub const DEFAULT_TTL_SECONDS: u64 = 900;
50
51pub const MAX_TTL_SECONDS: u64 = 86_400;
54
55pub const HEADER_LEN: usize = 64;
57
58#[derive(Error, Debug)]
60pub enum TokenError {
61 #[error("token too short: {0} bytes")]
62 TooShort(usize),
63 #[error("invalid magic bytes")]
64 InvalidMagic,
65 #[error("unsupported token version: {0:#x}")]
66 UnsupportedVersion(u8),
67 #[error("invalid utf-8 in {field}")]
68 InvalidUtf8 { field: &'static str },
69 #[error("name too long (max 255 bytes)")]
70 NameTooLong,
71 #[error("project too long (max 255 bytes)")]
72 ProjectTooLong,
73 #[error("scope too long (max 255 bytes)")]
74 ScopeTooLong,
75 #[error("too many scopes (max 255)")]
76 TooManyScopes,
77 #[error("malformed token: {0}")]
78 Malformed(&'static str),
79 #[error("ttl out of range: must be 1..={max} seconds", max = MAX_TTL_SECONDS)]
80 TtlOutOfRange,
81 #[error("token expired (exp={exp}, now={now})")]
82 Expired { exp: i64, now: i64 },
83 #[error("token not yet valid (iat={iat}, now={now})")]
84 NotYetValid { iat: i64, now: i64 },
85 #[error("signature verification failed")]
86 SignatureInvalid,
87 #[error("issuer mismatch (token issuer ≠ expected pubkey)")]
88 IssuerMismatch,
89 #[error(transparent)]
90 Identity(#[from] IdentityError),
91 #[error(transparent)]
92 Scope(#[from] ScopeError),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct AgentClaims {
98 pub name: String,
99 pub project: String,
100 pub scopes: Vec<String>,
101 pub issued_at: i64,
102 pub expires_at: i64,
103 pub max_calls: u32,
104 pub token_id: u64,
105 pub issuer: [u8; 32],
106}
107
108impl AgentClaims {
109 pub fn fingerprint(&self) -> String {
111 fingerprint_from_pubkey(&self.issuer)
112 }
113
114 pub fn issuer_hex(&self) -> String {
116 hex::encode(self.issuer)
117 }
118
119 pub fn permits(&self, requested: &str) -> bool {
121 Scope::matches_any(self.scopes.iter().map(String::as_str), requested)
122 }
123
124 pub fn is_currently_valid(&self) -> bool {
127 let now = unix_now();
128 now >= self.issued_at - 30 && now < self.expires_at
129 }
130}
131
132pub struct TokenBuilder<'a> {
134 identity: &'a AgentIdentity,
135 scopes: Vec<String>,
136 ttl_seconds: u64,
137 max_calls: u32,
138 issued_at: Option<i64>,
139}
140
141impl<'a> TokenBuilder<'a> {
142 pub fn new(identity: &'a AgentIdentity) -> Self {
143 Self {
144 identity,
145 scopes: Vec::new(),
146 ttl_seconds: DEFAULT_TTL_SECONDS,
147 max_calls: 0,
148 issued_at: None,
149 }
150 }
151
152 pub fn scopes<I, S>(mut self, scopes: I) -> Self
153 where
154 I: IntoIterator<Item = S>,
155 S: Into<String>,
156 {
157 self.scopes = scopes.into_iter().map(Into::into).collect();
158 self
159 }
160
161 pub fn ttl_seconds(mut self, ttl: u64) -> Self {
162 self.ttl_seconds = ttl;
163 self
164 }
165
166 pub fn max_calls(mut self, max_calls: u32) -> Self {
167 self.max_calls = max_calls;
168 self
169 }
170
171 pub fn issued_at(mut self, ts: i64) -> Self {
174 self.issued_at = Some(ts);
175 self
176 }
177
178 pub fn build(self) -> Result<Vec<u8>, TokenError> {
181 if self.ttl_seconds == 0 || self.ttl_seconds > MAX_TTL_SECONDS {
182 return Err(TokenError::TtlOutOfRange);
183 }
184 for s in &self.scopes {
185 Scope::parse(s)?;
186 }
187 if self.scopes.len() > u8::MAX as usize {
188 return Err(TokenError::TooManyScopes);
189 }
190 if self.identity.name.len() > u8::MAX as usize {
191 return Err(TokenError::NameTooLong);
192 }
193 if self.identity.project.len() > u8::MAX as usize {
194 return Err(TokenError::ProjectTooLong);
195 }
196
197 let issued_at = self.issued_at.unwrap_or_else(unix_now);
198 let expires_at = issued_at
199 .checked_add(self.ttl_seconds as i64)
200 .ok_or(TokenError::Malformed("expires_at overflow"))?;
201 let token_id = random_u64();
202
203 let est_size = HEADER_LEN
204 + 1
205 + self.identity.name.len()
206 + 1
207 + self.identity.project.len()
208 + 1
209 + self.scopes.iter().map(|s| 1 + s.len()).sum::<usize>()
210 + SIGNATURE_LENGTH;
211 let mut buf = Vec::with_capacity(est_size);
212
213 buf.extend_from_slice(&MAGIC);
214 buf.push(VERSION);
215 buf.push(0); buf.extend_from_slice(&issued_at.to_be_bytes());
217 buf.extend_from_slice(&expires_at.to_be_bytes());
218 buf.extend_from_slice(&self.max_calls.to_be_bytes());
219 buf.extend_from_slice(&token_id.to_be_bytes());
220 buf.extend_from_slice(&self.identity.public_key());
221
222 push_short_string(&mut buf, &self.identity.name);
223 push_short_string(&mut buf, &self.identity.project);
224
225 buf.push(self.scopes.len() as u8);
226 for s in &self.scopes {
227 if s.len() > u8::MAX as usize {
228 return Err(TokenError::ScopeTooLong);
229 }
230 push_short_string(&mut buf, s);
231 }
232
233 let sig = self.identity.signing_key().sign(&buf);
234 buf.extend_from_slice(&sig.to_bytes());
235 Ok(buf)
236 }
237}
238
239pub fn parse(token: &[u8]) -> Result<AgentClaims, TokenError> {
242 if token.len() < HEADER_LEN + SIGNATURE_LENGTH {
243 return Err(TokenError::TooShort(token.len()));
244 }
245 if token[0..2] != MAGIC {
246 return Err(TokenError::InvalidMagic);
247 }
248 if token[2] != VERSION {
249 return Err(TokenError::UnsupportedVersion(token[2]));
250 }
251 let payload_end = token.len() - SIGNATURE_LENGTH;
252
253 let mut o = 4usize; let issued_at = read_i64_be(token, o, payload_end)?;
255 o += 8;
256 let expires_at = read_i64_be(token, o, payload_end)?;
257 o += 8;
258 let max_calls = read_u32_be(token, o, payload_end)?;
259 o += 4;
260 let token_id = read_u64_be(token, o, payload_end)?;
261 o += 8;
262 let mut issuer = [0u8; 32];
263 if o + 32 > payload_end {
264 return Err(TokenError::Malformed("issuer truncated"));
265 }
266 issuer.copy_from_slice(&token[o..o + 32]);
267 o += 32;
268
269 let name = read_short_string(token, &mut o, payload_end, "name")?;
270 let project = read_short_string(token, &mut o, payload_end, "project")?;
271
272 if o >= payload_end {
273 return Err(TokenError::Malformed("scope_count truncated"));
274 }
275 let scope_count = token[o] as usize;
276 o += 1;
277 let mut scopes = Vec::with_capacity(scope_count);
278 for _ in 0..scope_count {
279 scopes.push(read_short_string(token, &mut o, payload_end, "scope")?);
280 }
281
282 if o != payload_end {
283 return Err(TokenError::Malformed("trailing bytes between payload and signature"));
284 }
285
286 Ok(AgentClaims {
287 name,
288 project,
289 scopes,
290 issued_at,
291 expires_at,
292 max_calls,
293 token_id,
294 issuer,
295 })
296}
297
298pub fn verify(
305 token: &[u8],
306 expected_pubkey: Option<&[u8; 32]>,
307) -> Result<AgentClaims, TokenError> {
308 let claims = parse(token)?;
309
310 if let Some(pk) = expected_pubkey {
311 if pk != &claims.issuer {
312 return Err(TokenError::IssuerMismatch);
313 }
314 }
315
316 let sig_start = token.len() - SIGNATURE_LENGTH;
317 let payload = &token[..sig_start];
318 let mut sig = [0u8; SIGNATURE_LENGTH];
319 sig.copy_from_slice(&token[sig_start..]);
320
321 verify_signature(&claims.issuer, payload, &sig).map_err(|_| TokenError::SignatureInvalid)?;
322
323 let now = unix_now();
324 if now >= claims.expires_at {
325 return Err(TokenError::Expired {
326 exp: claims.expires_at,
327 now,
328 });
329 }
330 if now < claims.issued_at - 30 {
332 return Err(TokenError::NotYetValid {
333 iat: claims.issued_at,
334 now,
335 });
336 }
337
338 Ok(claims)
339}
340
341fn push_short_string(buf: &mut Vec<u8>, s: &str) {
344 buf.push(s.len() as u8);
345 buf.extend_from_slice(s.as_bytes());
346}
347
348fn read_short_string(
349 buf: &[u8],
350 o: &mut usize,
351 end: usize,
352 field: &'static str,
353) -> Result<String, TokenError> {
354 if *o >= end {
355 return Err(TokenError::Malformed("string length truncated"));
356 }
357 let len = buf[*o] as usize;
358 *o += 1;
359 if *o + len > end {
360 return Err(TokenError::Malformed("string truncated"));
361 }
362 let s = std::str::from_utf8(&buf[*o..*o + len])
363 .map_err(|_| TokenError::InvalidUtf8 { field })?
364 .to_string();
365 *o += len;
366 Ok(s)
367}
368
369fn read_i64_be(buf: &[u8], o: usize, end: usize) -> Result<i64, TokenError> {
370 if o + 8 > end {
371 return Err(TokenError::Malformed("i64 truncated"));
372 }
373 Ok(i64::from_be_bytes(buf[o..o + 8].try_into().unwrap()))
374}
375
376fn read_u32_be(buf: &[u8], o: usize, end: usize) -> Result<u32, TokenError> {
377 if o + 4 > end {
378 return Err(TokenError::Malformed("u32 truncated"));
379 }
380 Ok(u32::from_be_bytes(buf[o..o + 4].try_into().unwrap()))
381}
382
383fn read_u64_be(buf: &[u8], o: usize, end: usize) -> Result<u64, TokenError> {
384 if o + 8 > end {
385 return Err(TokenError::Malformed("u64 truncated"));
386 }
387 Ok(u64::from_be_bytes(buf[o..o + 8].try_into().unwrap()))
388}
389
390fn unix_now() -> i64 {
391 use std::time::{SystemTime, UNIX_EPOCH};
392 SystemTime::now()
393 .duration_since(UNIX_EPOCH)
394 .map(|d| d.as_secs() as i64)
395 .unwrap_or(0)
396}
397
398fn random_u64() -> u64 {
399 use ring::rand::{SecureRandom, SystemRandom};
400 let rng = SystemRandom::new();
401 let mut buf = [0u8; 8];
402 rng.fill(&mut buf).expect("system rng must succeed");
403 u64::from_be_bytes(buf)
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409 use crate::identity::AgentIdentity;
410
411 fn fixture() -> AgentIdentity {
412 AgentIdentity::derive("research-bot", "phd-lab", None).unwrap()
413 }
414
415 #[test]
416 fn round_trip() {
417 let id = fixture();
418 let token = TokenBuilder::new(&id)
419 .scopes(["read:arxiv", "write:notes"])
420 .ttl_seconds(60)
421 .max_calls(100)
422 .build()
423 .unwrap();
424 let claims = verify(&token, Some(&id.public_key())).unwrap();
425 assert_eq!(claims.name, "research-bot");
426 assert_eq!(claims.project, "phd-lab");
427 assert_eq!(claims.scopes, vec!["read:arxiv", "write:notes"]);
428 assert_eq!(claims.max_calls, 100);
429 assert_eq!(claims.issuer, id.public_key());
430 }
431
432 #[test]
433 fn typical_size_is_under_200_bytes() {
434 let id = fixture();
435 let token = TokenBuilder::new(&id)
436 .scopes(["read:arxiv", "write:notes"])
437 .ttl_seconds(900)
438 .max_calls(100)
439 .build()
440 .unwrap();
441 assert!(token.len() < 200, "token was {} bytes", token.len());
444 }
445
446 #[test]
447 fn rejects_tampered_payload() {
448 let id = fixture();
449 let mut token = TokenBuilder::new(&id)
450 .scopes(["read:arxiv"])
451 .ttl_seconds(60)
452 .build()
453 .unwrap();
454 let target = HEADER_LEN + 2;
456 token[target] ^= 0xFF;
457 assert!(matches!(
458 verify(&token, Some(&id.public_key())),
459 Err(TokenError::SignatureInvalid) | Err(TokenError::InvalidUtf8 { .. })
460 ));
461 }
462
463 #[test]
464 fn rejects_expired_token() {
465 let id = fixture();
466 let token = TokenBuilder::new(&id)
468 .scopes(["read:arxiv"])
469 .ttl_seconds(1)
470 .issued_at(1_000_000_000) .build()
472 .unwrap();
473 assert!(matches!(
474 verify(&token, Some(&id.public_key())),
475 Err(TokenError::Expired { .. })
476 ));
477 }
478
479 #[test]
480 fn rejects_issuer_mismatch() {
481 let a = fixture();
482 let b = AgentIdentity::derive("other-bot", "other-proj", None).unwrap();
483 let token = TokenBuilder::new(&a).ttl_seconds(60).build().unwrap();
484 assert!(matches!(
485 verify(&token, Some(&b.public_key())),
486 Err(TokenError::IssuerMismatch)
487 ));
488 }
489
490 #[test]
491 fn rejects_invalid_magic() {
492 let id = fixture();
493 let mut token = TokenBuilder::new(&id).ttl_seconds(60).build().unwrap();
494 token[0] = 0x00;
495 assert!(matches!(parse(&token), Err(TokenError::InvalidMagic)));
496 }
497
498 #[test]
499 fn permits_checks_scopes() {
500 let id = fixture();
501 let token = TokenBuilder::new(&id)
502 .scopes(["read:*"])
503 .ttl_seconds(60)
504 .build()
505 .unwrap();
506 let claims = verify(&token, None).unwrap();
507 assert!(claims.permits("read:arxiv"));
508 assert!(!claims.permits("write:arxiv"));
509 }
510
511 #[test]
512 fn unique_token_ids() {
513 let id = fixture();
514 let t1 = TokenBuilder::new(&id).ttl_seconds(60).build().unwrap();
515 let t2 = TokenBuilder::new(&id).ttl_seconds(60).build().unwrap();
516 let c1 = parse(&t1).unwrap();
517 let c2 = parse(&t2).unwrap();
518 assert_ne!(c1.token_id, c2.token_id);
519 }
520}