1use 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
19const SIGNING_DATA_VERSION: u8 = 0x01;
22
23const DEFAULT_CLOCK_SKEW_SECS: i64 = 30;
25
26#[expect(clippy::cast_possible_truncation)]
30fn write_length_prefixed(data: &mut Vec<u8>, bytes: &[u8]) {
31 data.extend_from_slice(&(bytes.len() as u32).to_le_bytes());
34 data.extend_from_slice(bytes);
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
39pub struct AuditEntryId(pub Uuid);
40
41impl AuditEntryId {
42 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
63#[serde(rename_all = "snake_case")]
64pub enum TokenScope {
65 Session,
67 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#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct CapabilityToken {
83 pub id: TokenId,
85 pub resource: ResourcePattern,
87 pub permissions: Vec<Permission>,
89 pub issued_at: Timestamp,
91 pub expires_at: Option<Timestamp>,
93 pub scope: TokenScope,
95 pub issuer: PublicKey,
97 pub user_id: [u8; 8],
99 pub approval_audit_id: AuditEntryId,
101 #[serde(default)]
103 pub single_use: bool,
104 pub signature: Signature,
106}
107
108impl CapabilityToken {
109 #[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 #[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 #[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 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]), };
173
174 let signing_data = token.signing_data();
176 token.signature = runtime_key.sign(&signing_data);
177
178 token
179 }
180
181 #[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 data.push(SIGNING_DATA_VERSION);
204
205 write_length_prefixed(&mut data, self.id.0.as_bytes());
207
208 write_length_prefixed(&mut data, self.resource.as_str().as_bytes());
210
211 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 data.extend_from_slice(&self.issued_at.0.timestamp().to_le_bytes());
219
220 if let Some(expires) = &self.expires_at {
222 data.push(0x01); data.extend_from_slice(&expires.0.timestamp().to_le_bytes());
224 } else {
225 data.push(0x00); }
227
228 write_length_prefixed(&mut data, self.scope.to_string().as_bytes());
230
231 data.extend_from_slice(self.issuer.as_bytes());
233
234 data.extend_from_slice(&self.user_id);
236
237 write_length_prefixed(&mut data, self.approval_audit_id.0.as_bytes());
239
240 data.push(u8::from(self.single_use));
242
243 data
244 }
245
246 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 #[must_use]
260 pub fn is_expired(&self) -> bool {
261 self.is_expired_with_skew(0)
262 }
263
264 #[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 #[expect(clippy::arithmetic_side_effects)]
274 let adjusted_expiry = exp.0 + Duration::seconds(skew_secs);
275 now > adjusted_expiry
276 })
277 }
278
279 pub fn validate(&self) -> CapabilityResult<()> {
288 self.validate_with_skew(DEFAULT_CLOCK_SKEW_SECS)
289 }
290
291 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 #[must_use]
308 pub fn is_single_use(&self) -> bool {
309 self.single_use
310 }
311
312 #[must_use]
314 pub fn grants(&self, resource: &str, permission: Permission) -> bool {
315 self.resource.matches(resource) && self.permissions.contains(&permission)
316 }
317
318 #[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#[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 #[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 #[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 #[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 #[must_use]
385 pub(crate) fn scope(mut self, scope: TokenScope) -> Self {
386 self.scope = scope;
387 self
388 }
389
390 #[must_use]
392 pub(crate) fn persistent(self) -> Self {
393 self.scope(TokenScope::Persistent)
394 }
395
396 #[must_use]
398 pub(crate) fn session(self) -> Self {
399 self.scope(TokenScope::Session)
400 }
401
402 #[must_use]
404 pub(crate) fn ttl(mut self, ttl: Duration) -> Self {
405 self.ttl = Some(ttl);
406 self
407 }
408
409 #[must_use]
411 pub(crate) fn single_use(mut self) -> Self {
412 self.single_use = true;
413 self
414 }
415
416 #[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 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)), );
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 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)), );
524
525 assert!(token.is_expired());
527 assert!(token.is_expired_with_skew(0));
528
529 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 assert!(token.verify_signature().is_ok());
569
570 token.permissions.push(Permission::Write);
572
573 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 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}