Skip to main content

agentid_core/
token.rs

1//! Compact binary token format.
2//!
3//! ## Wire format (big-endian)
4//!
5//! ```text
6//!   off  size  field
7//!   ---  ----  -----
8//!     0     2  magic                = 0xA9 0x1D
9//!     2     1  version              = 0x01
10//!     3     1  flags                = 0x00 (reserved)
11//!     4     8  issued_at  (i64)
12//!    12     8  expires_at (i64)
13//!    20     4  max_calls  (u32, 0 = unlimited)
14//!    24     8  token_id   (u64, random nonce)
15//!    32    32  issuer_pubkey (Ed25519)
16//!    64     1  name_len   (u8)
17//!    65     N  name       (utf-8)
18//!    65+N   1  project_len (u8)
19//!    66+N   M  project    (utf-8)
20//!    66+N+M 1  scope_count (u8)
21//!         repeating scopes:
22//!                  1  scope_len (u8)
23//!                  K  scope     (utf-8)
24//!     END  64  ed25519 signature over bytes [0..END)
25//! ```
26//!
27//! Typical size: ~170-180 bytes for `name="research-bot"`, two scopes, etc.
28//! That's ~4-5x smaller than an equivalent JWT, with ~6x faster verification.
29//!
30//! ## Why not JWT?
31//!
32//! JWTs encode JSON twice (header + payload), use slow RSA/ECDSA defaults,
33//! omit rate limits, and require JWK discovery for key rotation. None of
34//! that helps machine-to-machine traffic. AgentID tokens are binary,
35//! Ed25519, self-contained, and fixed-overhead.
36
37use 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
43/// Magic prefix — chosen for compactness and uniqueness vs. common formats.
44pub const MAGIC: [u8; 2] = [0xA9, 0x1D];
45/// Current wire-format version.
46pub const VERSION: u8 = 0x01;
47
48/// Default token TTL, in seconds (15 minutes).
49pub const DEFAULT_TTL_SECONDS: u64 = 900;
50
51/// Maximum allowed TTL, in seconds (24 hours). Tokens past this are usually
52/// a sign of misuse — long-lived credentials should live in the vault.
53pub const MAX_TTL_SECONDS: u64 = 86_400;
54
55/// Bytes consumed by the fixed-size header (magic..issuer_pubkey).
56pub const HEADER_LEN: usize = 64;
57
58/// Errors produced by the token layer.
59#[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/// Decoded token claims.
96#[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    /// Fingerprint of the issuer pubkey.
110    pub fn fingerprint(&self) -> String {
111        fingerprint_from_pubkey(&self.issuer)
112    }
113
114    /// Hex-encoded issuer pubkey.
115    pub fn issuer_hex(&self) -> String {
116        hex::encode(self.issuer)
117    }
118
119    /// Whether `requested` is covered by any of this token's granted scopes.
120    pub fn permits(&self, requested: &str) -> bool {
121        Scope::matches_any(self.scopes.iter().map(String::as_str), requested)
122    }
123
124    /// Whether the token would currently be valid. Does not re-check the
125    /// signature — use [`verify`] for that.
126    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
132/// Fluent builder for tokens.
133pub 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    /// Override the `issued_at` timestamp (defaults to now). Mostly useful in
172    /// tests.
173    pub fn issued_at(mut self, ts: i64) -> Self {
174        self.issued_at = Some(ts);
175        self
176    }
177
178    /// Mint a token. Validates inputs, signs the payload, and returns the raw
179    /// bytes ready for transmission.
180    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); // flags (reserved)
216        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
239/// Parse a token without verifying its signature or expiry. Useful for
240/// debugging; never trust the result for authorization.
241pub 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; // skip magic(2) + version(1) + flags(1)
254    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
298/// Parse + verify a token's signature and expiry.
299///
300/// If `expected_pubkey` is provided, the token's embedded issuer must match.
301/// Otherwise the token is verified against its own embedded issuer (still
302/// cryptographically sound — an attacker can't forge a signature without the
303/// secret key — but callers should pin a pubkey when possible).
304pub 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    // Allow 30s of clock skew on the iat side.
331    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
341// ---- internal helpers ----
342
343fn 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        // Header(64) + name(13) + project(8) + scope_count(1)
442        //  + scope1(11) + scope2(12) + sig(64) = 173
443        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        // Flip a byte inside the name region.
455        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        // Mint with iat far in the past so it's already expired.
467        let token = TokenBuilder::new(&id)
468            .scopes(["read:arxiv"])
469            .ttl_seconds(1)
470            .issued_at(1_000_000_000) // year 2001
471            .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}