Skip to main content

astrid_capabilities/
token.rs

1//! Capability tokens - cryptographically signed authorization.
2//!
3//! A capability token grants specific permissions to access resources.
4//! Tokens are:
5//! - Signed by the runtime's ed25519 key
6//! - Linked to the approval event that created them
7//! - Scoped (session or persistent)
8//! - Time-bounded (optional expiration)
9
10use astrid_core::{Permission, Timestamp, TokenId};
11use astrid_crypto::{ContentHash, KeyPair, PublicKey, Signature};
12use chrono::{Duration, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16use crate::error::{CapabilityError, CapabilityResult};
17use crate::pattern::ResourcePattern;
18
19/// Version of the signing data format.
20/// Increment this when the signing data structure changes.
21const SIGNING_DATA_VERSION: u8 = 0x01;
22
23/// Default clock skew tolerance in seconds.
24const DEFAULT_CLOCK_SKEW_SECS: i64 = 30;
25
26/// Write a length-prefixed byte slice to the output buffer.
27///
28/// Format: 4-byte little-endian length followed by the data.
29#[expect(clippy::cast_possible_truncation)]
30fn write_length_prefixed(data: &mut Vec<u8>, bytes: &[u8]) {
31    // Length is limited to u32::MAX; larger slices would be truncated.
32    // This is acceptable as capability token fields are small.
33    data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
34    data.extend_from_slice(bytes);
35}
36
37/// Unique identifier for an audit entry (used for linking).
38#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub struct AuditEntryId(pub Uuid);
40
41impl AuditEntryId {
42    /// Create a new audit entry ID.
43    #[must_use]
44    pub fn new() -> Self {
45        Self(Uuid::new_v4())
46    }
47}
48
49impl Default for AuditEntryId {
50    fn default() -> Self {
51        Self::new()
52    }
53}
54
55impl std::fmt::Display for AuditEntryId {
56    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57        write!(f, "audit:{}", &self.0.to_string()[..8])
58    }
59}
60
61/// Token scope - how long it lasts.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum TokenScope {
65    /// Valid only for the current session (in-memory).
66    Session,
67    /// Persisted across sessions.
68    Persistent,
69}
70
71impl std::fmt::Display for TokenScope {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::Session => write!(f, "session"),
75            Self::Persistent => write!(f, "persistent"),
76        }
77    }
78}
79
80/// A capability token granting permissions for a resource.
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct CapabilityToken {
83    /// Unique token identifier.
84    pub id: TokenId,
85    /// Resource pattern this token applies to.
86    pub resource: ResourcePattern,
87    /// Permissions granted.
88    pub permissions: Vec<Permission>,
89    /// When the token was issued.
90    pub issued_at: Timestamp,
91    /// When the token expires (None = no expiration within scope).
92    pub expires_at: Option<Timestamp>,
93    /// Token scope (session or persistent).
94    pub scope: TokenScope,
95    /// Public key of the issuer (runtime).
96    pub issuer: PublicKey,
97    /// User who approved this token (key ID, first 8 bytes).
98    pub user_id: [u8; 8],
99    /// Audit entry ID linking to the approval event.
100    pub approval_audit_id: AuditEntryId,
101    /// Whether this token can only be used once (replay protection).
102    #[serde(default)]
103    pub single_use: bool,
104    /// Cryptographic signature of the token.
105    pub signature: Signature,
106}
107
108impl CapabilityToken {
109    /// Create a new capability token.
110    ///
111    /// This is typically called by the runtime after user approval.
112    #[must_use]
113    pub fn create(
114        resource: ResourcePattern,
115        permissions: Vec<Permission>,
116        scope: TokenScope,
117        user_id: [u8; 8],
118        approval_audit_id: AuditEntryId,
119        runtime_key: &KeyPair,
120        ttl: Option<Duration>,
121    ) -> Self {
122        Self::create_with_options(
123            resource,
124            permissions,
125            scope,
126            user_id,
127            approval_audit_id,
128            runtime_key,
129            ttl,
130            false,
131        )
132    }
133
134    /// Create a new capability token with additional options.
135    ///
136    /// This is typically called by the runtime after user approval.
137    #[must_use]
138    #[expect(clippy::too_many_arguments)]
139    pub fn create_with_options(
140        resource: ResourcePattern,
141        permissions: Vec<Permission>,
142        scope: TokenScope,
143        user_id: [u8; 8],
144        approval_audit_id: AuditEntryId,
145        runtime_key: &KeyPair,
146        ttl: Option<Duration>,
147        single_use: bool,
148    ) -> Self {
149        let id = TokenId::new();
150        let issued_at = Timestamp::now();
151        let expires_at = ttl.map(|d| {
152            // Safety: chrono Duration addition to DateTime cannot overflow for reasonable durations
153            #[expect(clippy::arithmetic_side_effects)]
154            let expiry = Utc::now() + d;
155            Timestamp::from_datetime(expiry)
156        });
157        let issuer = runtime_key.export_public_key();
158
159        // Create token without signature for signing
160        let mut token = Self {
161            id,
162            resource,
163            permissions,
164            issued_at,
165            expires_at,
166            scope,
167            issuer,
168            user_id,
169            approval_audit_id,
170            single_use,
171            signature: Signature::from_bytes([0u8; 64]), // Placeholder
172        };
173
174        // Sign the token
175        let signing_data = token.signing_data();
176        token.signature = runtime_key.sign(&signing_data);
177
178        token
179    }
180
181    /// Get the data used for signing (excludes the signature itself).
182    ///
183    /// Format (v1):
184    /// - 1 byte: version (0x01)
185    /// - Length-prefixed token ID (UUID bytes)
186    /// - Length-prefixed resource pattern string
187    /// - 4 bytes: number of permissions
188    /// - For each permission: length-prefixed string
189    /// - 8 bytes: `issued_at` timestamp (i64 LE)
190    /// - 1 byte: `has_expiration` flag
191    /// - If `has_expiration`: 8 bytes expiration timestamp (i64 LE)
192    /// - Length-prefixed scope string
193    /// - 32 bytes: issuer public key
194    /// - 8 bytes: `user_id`
195    /// - Length-prefixed audit entry ID (UUID bytes)
196    /// - 1 byte: `single_use` flag
197    #[must_use]
198    #[expect(clippy::cast_possible_truncation)]
199    pub fn signing_data(&self) -> Vec<u8> {
200        let mut data = Vec::with_capacity(512);
201
202        // Version prefix
203        data.push(SIGNING_DATA_VERSION);
204
205        // Token ID
206        write_length_prefixed(&mut data, self.id.0.as_bytes());
207
208        // Resource pattern
209        write_length_prefixed(&mut data, self.resource.as_str().as_bytes());
210
211        // Permissions count and values
212        data.extend_from_slice(&(self.permissions.len() as u32).to_le_bytes());
213        for perm in &self.permissions {
214            write_length_prefixed(&mut data, perm.to_string().as_bytes());
215        }
216
217        // Issued at
218        data.extend_from_slice(&self.issued_at.0.timestamp().to_le_bytes());
219
220        // Expiration (with presence flag)
221        if let Some(expires) = &self.expires_at {
222            data.push(0x01); // has expiration
223            data.extend_from_slice(&expires.0.timestamp().to_le_bytes());
224        } else {
225            data.push(0x00); // no expiration
226        }
227
228        // Scope
229        write_length_prefixed(&mut data, self.scope.to_string().as_bytes());
230
231        // Issuer (fixed 32 bytes)
232        data.extend_from_slice(self.issuer.as_bytes());
233
234        // User ID (fixed 8 bytes)
235        data.extend_from_slice(&self.user_id);
236
237        // Approval audit ID
238        write_length_prefixed(&mut data, self.approval_audit_id.0.as_bytes());
239
240        // Single use flag
241        data.push(u8::from(self.single_use));
242
243        data
244    }
245
246    /// Verify the token's signature.
247    ///
248    /// # Errors
249    ///
250    /// Returns [`CapabilityError::InvalidSignature`] if the signature is invalid.
251    pub fn verify_signature(&self) -> CapabilityResult<()> {
252        let signing_data = self.signing_data();
253        self.issuer
254            .verify(&signing_data, &self.signature)
255            .map_err(|_| CapabilityError::InvalidSignature)
256    }
257
258    /// Check if the token has expired.
259    #[must_use]
260    pub fn is_expired(&self) -> bool {
261        self.is_expired_with_skew(0)
262    }
263
264    /// Check if the token has expired, with clock skew tolerance.
265    ///
266    /// A positive `skew_secs` value allows tokens that expired up to
267    /// that many seconds ago to still be considered valid.
268    #[must_use]
269    pub fn is_expired_with_skew(&self, skew_secs: i64) -> bool {
270        self.expires_at.as_ref().is_some_and(|exp| {
271            let now = Utc::now();
272            // Safety: chrono Duration addition to DateTime cannot overflow for reasonable skew values
273            #[expect(clippy::arithmetic_side_effects)]
274            let adjusted_expiry = exp.0 + Duration::seconds(skew_secs);
275            now > adjusted_expiry
276        })
277    }
278
279    /// Check if the token is valid (not expired, signature OK).
280    ///
281    /// Uses the default clock skew tolerance (30 seconds).
282    ///
283    /// # Errors
284    ///
285    /// Returns [`CapabilityError::TokenExpired`] if expired,
286    /// or [`CapabilityError::InvalidSignature`] if the signature is invalid.
287    pub fn validate(&self) -> CapabilityResult<()> {
288        self.validate_with_skew(DEFAULT_CLOCK_SKEW_SECS)
289    }
290
291    /// Check if the token is valid with custom clock skew tolerance.
292    ///
293    /// # Errors
294    ///
295    /// Returns [`CapabilityError::TokenExpired`] if expired,
296    /// or [`CapabilityError::InvalidSignature`] if the signature is invalid.
297    pub fn validate_with_skew(&self, skew_secs: i64) -> CapabilityResult<()> {
298        if self.is_expired_with_skew(skew_secs) {
299            return Err(CapabilityError::TokenExpired {
300                token_id: self.id.to_string(),
301            });
302        }
303        self.verify_signature()
304    }
305
306    /// Check if this is a single-use token.
307    #[must_use]
308    pub fn is_single_use(&self) -> bool {
309        self.single_use
310    }
311
312    /// Check if this token grants a permission for a resource.
313    #[must_use]
314    pub fn grants(&self, resource: &str, permission: Permission) -> bool {
315        self.resource.matches(resource) && self.permissions.contains(&permission)
316    }
317
318    /// Hash the token for audit purposes.
319    #[must_use]
320    pub fn content_hash(&self) -> ContentHash {
321        ContentHash::hash(&self.signing_data())
322    }
323}
324
325impl PartialEq for CapabilityToken {
326    fn eq(&self, other: &Self) -> bool {
327        self.id == other.id
328    }
329}
330
331impl Eq for CapabilityToken {}
332
333impl std::hash::Hash for CapabilityToken {
334    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
335        self.id.hash(state);
336    }
337}
338
339/// Builder for creating capability tokens with fluent API.
340#[cfg(test)]
341pub(crate) struct TokenBuilder {
342    resource: ResourcePattern,
343    permissions: Vec<Permission>,
344    scope: TokenScope,
345    ttl: Option<Duration>,
346    single_use: bool,
347}
348
349#[cfg(test)]
350impl TokenBuilder {
351    /// Create a new token builder.
352    #[must_use]
353    pub(crate) fn new(resource: ResourcePattern) -> Self {
354        Self {
355            resource,
356            permissions: Vec::new(),
357            scope: TokenScope::Session,
358            ttl: None,
359            single_use: false,
360        }
361    }
362
363    /// Add a permission.
364    #[must_use]
365    pub(crate) fn permission(mut self, perm: Permission) -> Self {
366        if !self.permissions.contains(&perm) {
367            self.permissions.push(perm);
368        }
369        self
370    }
371
372    /// Add multiple permissions.
373    #[must_use]
374    pub(crate) fn permissions(mut self, perms: impl IntoIterator<Item = Permission>) -> Self {
375        for perm in perms {
376            if !self.permissions.contains(&perm) {
377                self.permissions.push(perm);
378            }
379        }
380        self
381    }
382
383    /// Set the scope.
384    #[must_use]
385    pub(crate) fn scope(mut self, scope: TokenScope) -> Self {
386        self.scope = scope;
387        self
388    }
389
390    /// Set persistent scope.
391    #[must_use]
392    pub(crate) fn persistent(self) -> Self {
393        self.scope(TokenScope::Persistent)
394    }
395
396    /// Set session scope.
397    #[must_use]
398    pub(crate) fn session(self) -> Self {
399        self.scope(TokenScope::Session)
400    }
401
402    /// Set time-to-live.
403    #[must_use]
404    pub(crate) fn ttl(mut self, ttl: Duration) -> Self {
405        self.ttl = Some(ttl);
406        self
407    }
408
409    /// Mark token as single-use (for replay protection).
410    #[must_use]
411    pub(crate) fn single_use(mut self) -> Self {
412        self.single_use = true;
413        self
414    }
415
416    /// Build the token (requires runtime key and user context).
417    #[must_use]
418    pub(crate) fn build(
419        self,
420        user_id: [u8; 8],
421        approval_audit_id: AuditEntryId,
422        runtime_key: &KeyPair,
423    ) -> CapabilityToken {
424        CapabilityToken::create_with_options(
425            self.resource,
426            self.permissions,
427            self.scope,
428            user_id,
429            approval_audit_id,
430            runtime_key,
431            self.ttl,
432            self.single_use,
433        )
434    }
435}
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use astrid_core::Permission;
441
442    fn test_keypair() -> KeyPair {
443        KeyPair::generate()
444    }
445
446    #[test]
447    fn test_token_creation() {
448        let keypair = test_keypair();
449        let pattern = ResourcePattern::exact("mcp://filesystem:read_file").unwrap();
450
451        let token = CapabilityToken::create(
452            pattern,
453            vec![Permission::Invoke],
454            TokenScope::Session,
455            keypair.key_id(),
456            AuditEntryId::new(),
457            &keypair,
458            None,
459        );
460
461        assert!(!token.is_expired());
462        assert!(token.verify_signature().is_ok());
463    }
464
465    #[test]
466    fn test_token_grants() {
467        let keypair = test_keypair();
468        let pattern = ResourcePattern::new("mcp://filesystem:*").unwrap();
469
470        let token = CapabilityToken::create(
471            pattern,
472            vec![Permission::Invoke, Permission::Read],
473            TokenScope::Session,
474            keypair.key_id(),
475            AuditEntryId::new(),
476            &keypair,
477            None,
478        );
479
480        assert!(token.grants("mcp://filesystem:read_file", Permission::Invoke));
481        assert!(token.grants("mcp://filesystem:write_file", Permission::Invoke));
482        assert!(!token.grants("mcp://filesystem:read_file", Permission::Write));
483        assert!(!token.grants("mcp://memory:read", Permission::Invoke));
484    }
485
486    #[test]
487    fn test_token_expiration() {
488        let keypair = test_keypair();
489        let pattern = ResourcePattern::exact("test://resource").unwrap();
490
491        // Create expired token (beyond clock skew tolerance of 30s)
492        let token = CapabilityToken::create(
493            pattern,
494            vec![Permission::Read],
495            TokenScope::Session,
496            keypair.key_id(),
497            AuditEntryId::new(),
498            &keypair,
499            Some(Duration::seconds(-60)), // Expired well beyond skew tolerance
500        );
501
502        assert!(token.is_expired());
503        assert!(matches!(
504            token.validate(),
505            Err(CapabilityError::TokenExpired { .. })
506        ));
507    }
508
509    #[test]
510    fn test_token_expiration_with_clock_skew() {
511        let keypair = test_keypair();
512        let pattern = ResourcePattern::exact("test://resource").unwrap();
513
514        // Create token that just expired but is within clock skew tolerance
515        let token = CapabilityToken::create(
516            pattern,
517            vec![Permission::Read],
518            TokenScope::Session,
519            keypair.key_id(),
520            AuditEntryId::new(),
521            &keypair,
522            Some(Duration::seconds(-10)), // Just expired
523        );
524
525        // Without skew tolerance, it's expired
526        assert!(token.is_expired());
527        assert!(token.is_expired_with_skew(0));
528
529        // With default 30s skew tolerance, it's still valid
530        assert!(!token.is_expired_with_skew(30));
531        assert!(token.validate().is_ok());
532    }
533
534    #[test]
535    fn test_token_builder() {
536        let keypair = test_keypair();
537
538        let token =
539            TokenBuilder::new(ResourcePattern::exact("mcp://filesystem:read_file").unwrap())
540                .permission(Permission::Invoke)
541                .permission(Permission::Read)
542                .persistent()
543                .ttl(Duration::hours(24))
544                .build(keypair.key_id(), AuditEntryId::new(), &keypair);
545
546        assert_eq!(token.scope, TokenScope::Persistent);
547        assert!(token.expires_at.is_some());
548        assert!(token.permissions.contains(&Permission::Invoke));
549        assert!(token.permissions.contains(&Permission::Read));
550    }
551
552    #[test]
553    fn test_token_signature_verification() {
554        let keypair = test_keypair();
555        let pattern = ResourcePattern::exact("test://resource").unwrap();
556
557        let mut token = CapabilityToken::create(
558            pattern,
559            vec![Permission::Read],
560            TokenScope::Session,
561            keypair.key_id(),
562            AuditEntryId::new(),
563            &keypair,
564            None,
565        );
566
567        // Valid signature
568        assert!(token.verify_signature().is_ok());
569
570        // Tamper with token
571        token.permissions.push(Permission::Write);
572
573        // Signature should now fail
574        assert!(matches!(
575            token.verify_signature(),
576            Err(CapabilityError::InvalidSignature)
577        ));
578    }
579
580    #[test]
581    fn test_token_content_hash() {
582        let keypair = test_keypair();
583        let pattern = ResourcePattern::exact("test://resource").unwrap();
584
585        let token = CapabilityToken::create(
586            pattern.clone(),
587            vec![Permission::Read],
588            TokenScope::Session,
589            keypair.key_id(),
590            AuditEntryId::new(),
591            &keypair,
592            None,
593        );
594
595        let hash = token.content_hash();
596        assert!(!hash.is_zero());
597
598        // Different token should have different hash
599        let token2 = CapabilityToken::create(
600            pattern,
601            vec![Permission::Write],
602            TokenScope::Session,
603            keypair.key_id(),
604            AuditEntryId::new(),
605            &keypair,
606            None,
607        );
608
609        assert_ne!(token.content_hash(), token2.content_hash());
610    }
611}