1pub use crate::auth_keys::parse_alg;
8use crate::auth_keys::Algorithm;
9use crate::error::{Error, Result};
10use async_trait::async_trait;
11use base64::Engine as _;
12use ed25519_dalek::{Signer, SigningKey, Verifier, VerifyingKey};
13use indexmap::IndexMap;
14use serde::{Deserialize, Serialize};
15use std::time::Duration;
16
17pub const MAX_DELEGATION_TOKEN_BYTES: usize = 64 * 1024;
19
20#[async_trait]
27pub trait DelegationRevocationStore: Send + Sync {
28 async fn revoke(&self, token_id: &str, retain_until_unix: i64) -> Result<()>;
30
31 async fn is_revoked(&self, token_id: &str, now_unix: i64) -> Result<bool>;
34}
35
36#[derive(Debug, Clone, Serialize, Deserialize)]
40#[non_exhaustive]
41pub struct DelegationClaims {
42 pub iss: String,
43 pub sub: String,
44 pub aud: String,
45 pub iat: i64,
46 pub exp: i64,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub nbf: Option<i64>,
49 pub jti: String,
50 pub allowed_actions: Vec<String>,
51 pub resource_patterns: Vec<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub act: Option<Box<ActClaim>>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub constraints: Option<ConstraintSet>,
56 #[serde(default)]
57 pub extra: IndexMap<String, serde_json::Value>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62#[non_exhaustive]
63pub struct ActClaim {
64 pub sub: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub iss: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
68 pub act: Option<Box<ActClaim>>,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(transparent)]
75pub struct ConstraintSet {
76 pub expressions: Vec<ConstraintExpr>,
77}
78
79impl ConstraintSet {
80 pub fn new(expressions: Vec<ConstraintExpr>) -> Self {
81 Self { expressions }
82 }
83
84 pub fn empty() -> Self {
85 Self {
86 expressions: vec![],
87 }
88 }
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(tag = "op", rename_all = "snake_case")]
97#[non_exhaustive]
98pub enum ConstraintExpr {
99 Equals {
100 path: String,
101 value: serde_json::Value,
102 },
103 In {
104 path: String,
105 values: Vec<serde_json::Value>,
106 },
107 GreaterThan {
108 path: String,
109 value: i64,
110 },
111 LessThan {
112 path: String,
113 value: i64,
114 },
115 Glob {
116 path: String,
117 pattern: String,
118 },
119 And {
120 all: Vec<ConstraintExpr>,
121 },
122 Or {
123 any: Vec<ConstraintExpr>,
124 },
125 Not {
126 inner: Box<ConstraintExpr>,
127 },
128}
129
130impl ConstraintExpr {
131 fn has_typed_inputs(&self, root: &serde_json::Value) -> bool {
136 match self {
137 ConstraintExpr::Equals { path, .. } | ConstraintExpr::In { path, .. } => {
138 lookup(root, path).is_some()
139 }
140 ConstraintExpr::GreaterThan { path, .. } | ConstraintExpr::LessThan { path, .. } => {
141 lookup(root, path)
142 .and_then(serde_json::Value::as_i64)
143 .is_some()
144 }
145 ConstraintExpr::Glob { path, .. } => lookup(root, path)
146 .and_then(serde_json::Value::as_str)
147 .is_some(),
148 ConstraintExpr::And { all } => all.iter().all(|expr| expr.has_typed_inputs(root)),
149 ConstraintExpr::Or { any } => any.iter().all(|expr| expr.has_typed_inputs(root)),
150 ConstraintExpr::Not { inner } => inner.has_typed_inputs(root),
151 }
152 }
153
154 pub fn evaluate(&self, root: &serde_json::Value) -> bool {
156 match self {
157 ConstraintExpr::Equals { path, value } => {
158 lookup(root, path).map(|v| v == value).unwrap_or(false)
159 }
160 ConstraintExpr::In { path, values } => lookup(root, path)
161 .map(|v| values.iter().any(|x| x == v))
162 .unwrap_or(false),
163 ConstraintExpr::GreaterThan { path, value } => lookup(root, path)
164 .and_then(|v| v.as_i64())
165 .map(|x| x > *value)
166 .unwrap_or(false),
167 ConstraintExpr::LessThan { path, value } => lookup(root, path)
168 .and_then(|v| v.as_i64())
169 .map(|x| x < *value)
170 .unwrap_or(false),
171 ConstraintExpr::Glob { path, pattern } => lookup(root, path)
172 .and_then(|v| v.as_str())
173 .map(|s| glob_match(pattern, s))
174 .unwrap_or(false),
175 ConstraintExpr::And { all } => all.iter().all(|e| e.evaluate(root)),
176 ConstraintExpr::Or { any } => any.iter().any(|e| e.evaluate(root)),
177 ConstraintExpr::Not { inner } => !inner.evaluate(root),
178 }
179 }
180}
181
182fn lookup<'a>(root: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
183 let bytes = path.as_bytes();
188 let mut cur = root;
189 let mut start = 0;
190 for (i, &b) in bytes.iter().enumerate() {
191 if b == b'.' {
192 if start == i {
193 return None;
194 }
195 let seg = match std::str::from_utf8(&bytes[start..i]) {
196 Ok(s) => s,
197 Err(_) => return None,
198 };
199 cur = cur.get(seg)?;
200 start = i + 1;
201 }
202 }
203 if start == bytes.len() {
204 return None;
205 }
206 let seg = match std::str::from_utf8(&bytes[start..]) {
207 Ok(s) => s,
208 Err(_) => return None,
209 };
210 cur.get(seg)
211}
212
213pub fn glob_match(pattern: &str, value: &str) -> bool {
226 if pattern == "*" {
227 return true;
228 }
229 if !pattern.contains('*') {
230 return pattern == value;
231 }
232 let pat = pattern.as_bytes();
233 let val = value.as_bytes();
234 let pat_len = pat.len();
235 let val_len = val.len();
236 let mut stars: Vec<usize> = Vec::new();
238 for (i, &b) in pat.iter().enumerate() {
239 if b == b'*' {
240 stars.push(i);
241 }
242 }
243 let n_stars = stars.len();
244 let p0_end = stars[0];
246 if !slice_eq(pat, 0, p0_end, val, 0) {
247 return false;
248 }
249 let mut vi = p0_end;
250 let mut s: Vec<usize> = vec![vi];
253 for i in 0..n_stars {
254 let seg_start = stars[i] + 1;
255 let seg_end = if i + 1 < n_stars {
256 stars[i + 1]
257 } else {
258 pat_len
259 };
260 let seg_len = seg_end - seg_start;
261 if i + 1 == n_stars {
262 if seg_len == 0 {
264 return true;
265 }
266 return val_len >= vi + seg_len
267 && slice_eq(pat, seg_start, seg_end, val, val_len - seg_len);
268 }
269 let mut pos = s[i];
271 loop {
272 if pos + seg_len > val_len {
273 return false;
274 }
275 if slice_eq(pat, seg_start, seg_end, val, pos) {
276 vi = pos + seg_len;
277 s.push(vi);
278 break;
279 }
280 pos += 1;
281 }
282 }
283 false
284}
285
286#[inline]
287fn slice_eq(a: &[u8], a_start: usize, a_end: usize, b: &[u8], b_start: usize) -> bool {
288 let len = a_end - a_start;
289 if b_start + len > b.len() {
290 return false;
291 }
292 a[a_start..a_end] == b[b_start..b_start + len]
293}
294
295#[derive(Debug, Clone, Copy)]
297pub struct DelegationConfig {
298 pub ttl: Duration,
299}
300
301impl Default for DelegationConfig {
302 fn default() -> Self {
303 Self {
304 ttl: Duration::from_secs(900),
305 }
306 }
307}
308
309#[derive(Debug, Clone)]
311pub struct DelegationSpec {
312 sub: String,
313 aud: String,
314 allowed_actions: Vec<String>,
315 resource_patterns: Vec<String>,
316 constraints: Option<ConstraintSet>,
317 config: DelegationConfig,
318}
319
320impl DelegationSpec {
321 pub fn new(
322 sub: impl Into<String>,
323 aud: impl Into<String>,
324 allowed_actions: Vec<String>,
325 resource_patterns: Vec<String>,
326 config: DelegationConfig,
327 ) -> Self {
328 Self {
329 sub: sub.into(),
330 aud: aud.into(),
331 allowed_actions,
332 resource_patterns,
333 constraints: None,
334 config,
335 }
336 }
337
338 pub fn with_constraints(mut self, constraints: ConstraintSet) -> Self {
339 self.constraints = Some(constraints);
340 self
341 }
342}
343
344#[derive(Clone)]
346pub struct DelegationSigner {
347 key: SigningKey,
348 key_id: String,
349}
350
351impl std::fmt::Debug for DelegationSigner {
352 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
353 f.debug_struct("DelegationSigner")
354 .field("key_id", &self.key_id)
355 .field("alg", &"EdDSA")
356 .finish()
357 }
358}
359
360impl DelegationSigner {
361 pub fn generate() -> Self {
370 use rand::rngs::OsRng;
371 let key = SigningKey::generate(&mut OsRng);
372 let key_id = format!("agent-{}", &uuid::Uuid::new_v4().to_string()[..8]);
373 Self { key, key_id }
374 }
375
376 pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
381 let arr: [u8; 32] = bytes
382 .try_into()
383 .map_err(|_| Error::InvalidToken("ed25519 key must be 32 bytes".into()))?;
384 Ok(Self {
385 key: SigningKey::from_bytes(&arr),
386 key_id: "imported".into(),
387 })
388 }
389
390 pub fn public_key_b64(&self) -> String {
391 base64::engine::general_purpose::STANDARD.encode(self.key.verifying_key().to_bytes())
392 }
393
394 pub fn verifying_key(&self) -> VerifyingKey {
395 self.key.verifying_key()
396 }
397
398 pub fn key_id(&self) -> &str {
399 &self.key_id
400 }
401
402 pub fn set_key_id(&mut self, id: impl Into<String>) {
403 self.key_id = id.into();
404 }
405
406 pub fn mint(
408 &self,
409 iss: impl Into<String>,
410 sub: impl Into<String>,
411 aud: impl Into<String>,
412 allowed_actions: Vec<String>,
413 resource_patterns: Vec<String>,
414 cfg: DelegationConfig,
415 ) -> Result<DelegationToken> {
416 self.mint_with(
417 iss,
418 sub,
419 aud,
420 allowed_actions,
421 resource_patterns,
422 cfg,
423 |_| {},
424 )
425 }
426
427 #[allow(clippy::too_many_arguments)]
429 pub fn mint_with<F>(
430 &self,
431 iss: impl Into<String>,
432 sub: impl Into<String>,
433 aud: impl Into<String>,
434 allowed_actions: Vec<String>,
435 resource_patterns: Vec<String>,
436 cfg: DelegationConfig,
437 mutate: F,
438 ) -> Result<DelegationToken>
439 where
440 F: FnOnce(&mut DelegationClaims),
441 {
442 let now = chrono::Utc::now().timestamp();
443 let mut claims = DelegationClaims {
444 iss: iss.into(),
445 sub: sub.into(),
446 aud: aud.into(),
447 iat: now,
448 exp: now + cfg.ttl.as_secs() as i64,
449 nbf: None,
450 jti: uuid::Uuid::new_v4().to_string(),
451 allowed_actions,
452 resource_patterns,
453 act: None,
454 constraints: None,
455 extra: IndexMap::new(),
456 };
457 mutate(&mut claims);
458 self.sign_jws(&claims)
459 }
460
461 pub fn mint_attenuated(
470 &self,
471 parent: &VerifiedDelegation,
472 spec: DelegationSpec,
473 ) -> Result<DelegationToken> {
474 let DelegationSpec {
475 sub,
476 aud,
477 allowed_actions,
478 resource_patterns,
479 constraints,
480 config,
481 } = spec;
482 if self.key_id != parent.kid || self.key.verifying_key().to_bytes() != parent.verifying_key
483 {
484 return Err(Error::InvalidToken(
485 "child grant must be signed by the verified parent key".into(),
486 ));
487 }
488 let now = chrono::Utc::now().timestamp();
489 if parent.claims.exp <= now {
490 return Err(Error::InvalidToken(
491 "cannot delegate from an expired parent grant".into(),
492 ));
493 }
494 let ttl_seconds = i64::try_from(config.ttl.as_secs()).map_err(|_| {
495 Error::InvalidToken("child grant TTL exceeds supported timestamp range".into())
496 })?;
497 let exp = now
498 .checked_add(ttl_seconds)
499 .ok_or_else(|| Error::InvalidToken("child grant expiry overflows timestamp".into()))?;
500 if exp > parent.claims.exp {
501 return Err(Error::InvalidToken(
502 "child grant TTL exceeds parent grant lifetime".into(),
503 ));
504 }
505 if allowed_actions.iter().any(|action| {
506 !parent
507 .claims
508 .allowed_actions
509 .iter()
510 .any(|parent_action| parent_action == action)
511 }) {
512 return Err(Error::InvalidToken(
513 "child grant contains an action outside the parent scope".into(),
514 ));
515 }
516 if resource_patterns.iter().any(|child| {
517 if child.contains('*') {
518 !parent
519 .claims
520 .resource_patterns
521 .iter()
522 .any(|parent_pattern| parent_pattern == child)
523 } else {
524 !parent
525 .claims
526 .resource_patterns
527 .iter()
528 .any(|parent_pattern| glob_match(parent_pattern, child))
529 }
530 }) {
531 return Err(Error::InvalidToken(
532 "child grant contains a resource outside the parent scope".into(),
533 ));
534 }
535 if let Some(parent_constraints) = &parent.claims.constraints {
536 let child_constraints = constraints.as_ref().ok_or_else(|| {
537 Error::InvalidToken("child grant cannot remove parent constraints".into())
538 })?;
539 let parent_expressions = parent_constraints
540 .expressions
541 .iter()
542 .map(serde_json::to_value)
543 .collect::<std::result::Result<Vec<_>, _>>()?;
544 let child_expressions = child_constraints
545 .expressions
546 .iter()
547 .map(serde_json::to_value)
548 .collect::<std::result::Result<Vec<_>, _>>()?;
549 if parent_expressions
550 .iter()
551 .any(|parent_expr| !child_expressions.contains(parent_expr))
552 {
553 return Err(Error::InvalidToken(
554 "child grant cannot remove or change parent constraints".into(),
555 ));
556 }
557 }
558
559 if parent.claims.nbf.is_some_and(|nbf| nbf >= exp) {
560 return Err(Error::InvalidToken(
561 "child grant expires before its inherited not-before time".into(),
562 ));
563 }
564 let claims = DelegationClaims {
565 iss: parent.claims.sub.clone(),
566 sub,
567 aud,
568 iat: now,
569 exp,
570 nbf: parent.claims.nbf,
571 jti: uuid::Uuid::new_v4().to_string(),
572 allowed_actions,
573 resource_patterns,
574 act: Some(Box::new(ActClaim {
575 sub: parent.claims.sub.clone(),
576 iss: Some(parent.claims.iss.clone()),
577 act: parent.claims.act.clone(),
578 })),
579 constraints,
580 extra: IndexMap::new(),
581 };
582 self.sign_jws(&claims)
583 }
584
585 pub fn sign_jws(&self, claims: &DelegationClaims) -> Result<DelegationToken> {
587 let header = serde_json::json!({
588 "alg": "EdDSA",
589 "typ": "agentguard-delegation+jwt",
590 "kid": self.key_id,
591 });
592 let header_b64 =
593 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?);
594 let payload_b64 =
595 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
596 let signing_input = format!("{}.{}", header_b64, payload_b64);
597 let sig = self.key.sign(signing_input.as_bytes());
598 let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig.to_bytes());
599 Ok(DelegationToken {
600 claims: claims.clone(),
601 jws: format!("{}.{}", signing_input, sig_b64),
602 })
603 }
604}
605
606#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct DelegationToken {
609 pub claims: DelegationClaims,
610 pub jws: String,
611}
612
613impl DelegationToken {
614 pub fn to_jws(&self) -> &str {
616 &self.jws
617 }
618
619 pub fn parse(s: &str) -> Result<Self> {
629 if s.len() > MAX_DELEGATION_TOKEN_BYTES {
630 return Err(Error::InvalidToken(format!(
631 "delegation token exceeds the {MAX_DELEGATION_TOKEN_BYTES}-byte limit"
632 )));
633 }
634 let parts: Vec<&str> = s.split('.').collect();
635 if parts.len() != 3 {
636 return Err(Error::InvalidToken("JWS must have 3 parts".into()));
637 }
638 let header_b64 = parts[0];
639 let payload_b64 = parts[1];
640 let sig_b64 = parts[2];
641 let header_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
642 .decode(header_b64)
643 .map_err(|e| Error::InvalidToken(format!("header b64: {}", e)))?;
644 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
645 .map_err(|e| Error::InvalidToken(format!("header json: {}", e)))?;
646 let _alg_str = header
647 .get("alg")
648 .and_then(|v| v.as_str())
649 .ok_or_else(|| Error::InvalidToken("missing alg".into()))?;
650
651 let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
652 .decode(payload_b64)
653 .map_err(|e| Error::InvalidToken(format!("payload b64: {}", e)))?;
654 let claims: DelegationClaims = serde_json::from_slice(&payload_bytes)
655 .map_err(|e| Error::InvalidToken(format!("payload json: {}", e)))?;
656
657 let sig = base64::engine::general_purpose::URL_SAFE_NO_PAD
659 .decode(sig_b64)
660 .map_err(|e| Error::InvalidToken(format!("sig b64: {}", e)))?;
661 if sig.len() != 64 {
662 return Err(Error::TokenSignature {
663 reason: format!("ed25519 signature must be 64 bytes, got {}", sig.len()),
664 });
665 }
666
667 Ok(Self {
668 claims,
669 jws: s.to_string(),
670 })
671 }
672}
673
674#[derive(Debug, Clone)]
676pub struct VerifiedDelegation {
677 claims: DelegationClaims,
678 kid: String,
679 alg: Algorithm,
680 verifying_key: [u8; 32],
681}
682
683impl VerifiedDelegation {
684 pub fn claims(&self) -> &DelegationClaims {
688 &self.claims
689 }
690
691 pub fn key_id(&self) -> &str {
692 &self.kid
693 }
694
695 pub fn algorithm(&self) -> Algorithm {
696 self.alg
697 }
698
699 pub fn allows(
709 &self,
710 subject: &str,
711 action: &str,
712 resource: &str,
713 request_facts: &serde_json::Value,
714 ) -> bool {
715 self.claims.sub == subject
716 && self
717 .claims
718 .allowed_actions
719 .iter()
720 .any(|allowed| allowed == action)
721 && self
722 .claims
723 .resource_patterns
724 .iter()
725 .any(|pattern| glob_match(pattern, resource))
726 && self.claims.constraints.as_ref().is_none_or(|constraints| {
727 constraints.expressions.iter().all(|expression| {
728 expression.has_typed_inputs(request_facts) && expression.evaluate(request_facts)
729 })
730 })
731 }
732}
733
734#[derive(Default)]
740pub struct DelegationVerifier {
741 keys: parking_lot::RwLock<std::collections::HashMap<String, (Algorithm, VerifyingKey)>>,
742 clock_skew: std::sync::atomic::AtomicI64,
746}
747
748impl DelegationVerifier {
749 pub fn new() -> Self {
750 Self {
751 clock_skew: std::sync::atomic::AtomicI64::new(60),
752 ..Self::default()
753 }
754 }
755
756 pub fn set_clock_skew_seconds(&self, secs: i64) {
759 self.clock_skew
760 .store(secs.max(0), std::sync::atomic::Ordering::Relaxed);
761 }
762
763 pub fn clock_skew_seconds(&self) -> i64 {
764 self.clock_skew.load(std::sync::atomic::Ordering::Relaxed)
765 }
766
767 pub fn add_key(
773 &self,
774 kid: impl Into<String>,
775 alg: Algorithm,
776 raw: &[u8],
777 ) -> crate::error::Result<()> {
778 let kid_str = kid.into();
779 let key = match alg {
780 Algorithm::EdDSA => {
781 if raw.len() != 32 {
782 return Err(crate::error::Error::Other(format!(
783 "ed25519 pubkey for kid {:?} must be 32 bytes, got {}",
784 kid_str,
785 raw.len()
786 )));
787 }
788 let bytes: [u8; 32] = raw.try_into().expect("length checked above");
789 VerifyingKey::from_bytes(&bytes)
790 .map_err(|e| crate::error::Error::Other(format!("ed25519 key parse: {}", e)))?
791 }
792 _ => {
793 return Err(crate::error::Error::Other(format!(
794 "alg {:?} not yet supported for verification",
795 alg
796 )));
797 }
798 };
799 self.keys.write().insert(kid_str, (alg, key));
800 Ok(())
801 }
802
803 pub fn clear(&self) {
805 self.keys.write().clear();
806 }
807
808 pub fn key_count(&self) -> usize {
810 self.keys.read().len()
811 }
812
813 pub fn verify(
847 &self,
848 token: &str,
849 expected_aud: &str,
850 now_unix: i64,
851 ) -> Result<VerifiedDelegation> {
852 let parsed = DelegationToken::parse(token)?;
854
855 let header_bytes = {
857 let parts: Vec<&str> = token.split('.').collect();
858 base64::engine::general_purpose::URL_SAFE_NO_PAD
859 .decode(parts[0])
860 .map_err(|e| Error::InvalidToken(format!("header b64: {}", e)))?
861 };
862 let header: serde_json::Value = serde_json::from_slice(&header_bytes)
863 .map_err(|e| Error::InvalidToken(format!("header json: {}", e)))?;
864 let alg_str = header
865 .get("alg")
866 .and_then(|v| v.as_str())
867 .ok_or_else(|| Error::InvalidToken("missing alg".into()))?;
868 let kid = header
869 .get("kid")
870 .and_then(|v| v.as_str())
871 .ok_or_else(|| Error::InvalidToken("missing kid in header".into()))?
872 .to_string();
873 if kid.trim().is_empty() {
874 return Err(Error::InvalidToken("empty kid in header".into()));
875 }
876 if header.get("typ").and_then(|v| v.as_str()) != Some("agentguard-delegation+jwt") {
877 return Err(Error::InvalidToken(
878 "unexpected or missing delegation token typ".into(),
879 ));
880 }
881 if header.get("crit").is_some() || header.get("b64").is_some_and(|v| v != true) {
882 return Err(Error::InvalidToken(
883 "unsupported critical or unencoded JWS header parameter".into(),
884 ));
885 }
886 let alg = parse_alg(alg_str)
887 .ok_or_else(|| Error::InvalidToken(format!("unsupported alg: {}", alg_str)))?;
888
889 if matches!(alg, Algorithm::HS256) {
893 return Err(Error::TokenSignature {
894 reason: "HS256 is not supported for delegation tokens".into(),
895 });
896 }
897
898 let keys = self.keys.read();
900 let (registered_alg, verifying_key) =
901 *keys.get(&kid).ok_or_else(|| Error::TokenSignature {
902 reason: format!("unknown kid: {}", kid),
903 })?;
904 drop(keys);
905
906 if alg != registered_alg {
907 return Err(Error::TokenSignature {
908 reason: format!(
909 "algorithm mismatch for kid {}: token={}, registered={}",
910 kid, alg, registered_alg
911 ),
912 });
913 }
914
915 let parts: Vec<&str> = token.split('.').collect();
917 let signing_input = format!("{}.{}", parts[0], parts[1]);
918 let sig_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
919 .decode(parts[2])
920 .map_err(|e| Error::TokenSignature {
921 reason: format!("sig b64: {}", e),
922 })?;
923 verify_ed255sa(&verifying_key, signing_input.as_bytes(), &sig_bytes)?;
924
925 let skew = self.clock_skew_seconds();
927 let claims = &parsed.claims;
928 if claims.iss.trim().is_empty()
929 || claims.sub.trim().is_empty()
930 || claims.aud.trim().is_empty()
931 || claims.jti.trim().is_empty()
932 {
933 return Err(Error::InvalidToken(
934 "iss, sub, aud, and jti must be non-empty".into(),
935 ));
936 }
937 if claims.exp <= claims.iat || claims.nbf.is_some_and(|nbf| nbf >= claims.exp) {
938 return Err(Error::InvalidToken(
939 "time claims must satisfy iat < exp and nbf < exp".into(),
940 ));
941 }
942 let latest_issued_at = now_unix.saturating_add(skew);
943 if claims.iat > latest_issued_at {
944 return Err(Error::TokenNotYetValid(claims.iat.to_string()));
945 }
946 if claims.exp.saturating_add(skew) <= now_unix {
947 return Err(Error::TokenExpired(claims.exp.to_string()));
948 }
949 if let Some(nbf) = claims.nbf {
950 if nbf > latest_issued_at {
951 return Err(Error::TokenNotYetValid(nbf.to_string()));
952 }
953 }
954
955 if claims.aud != expected_aud {
957 return Err(Error::TokenSignature {
958 reason: format!(
959 "audience mismatch: expected {}, got {}",
960 expected_aud, claims.aud
961 ),
962 });
963 }
964
965 Ok(VerifiedDelegation {
966 claims: parsed.claims,
967 kid,
968 alg,
969 verifying_key: verifying_key.to_bytes(),
970 })
971 }
972
973 pub async fn verify_with_revocation_store(
978 &self,
979 token: &str,
980 expected_aud: &str,
981 now_unix: i64,
982 store: &dyn DelegationRevocationStore,
983 ) -> Result<VerifiedDelegation> {
984 let verified = self.verify(token, expected_aud, now_unix)?;
985 if store.is_revoked(&verified.claims.jti, now_unix).await? {
986 return Err(Error::TokenRevoked(verified.claims.jti.clone()));
987 }
988 Ok(verified)
989 }
990
991 pub async fn revoke(
994 &self,
995 verified: &VerifiedDelegation,
996 store: &dyn DelegationRevocationStore,
997 ) -> Result<()> {
998 store
999 .revoke(
1000 &verified.claims.jti,
1001 verified
1002 .claims
1003 .exp
1004 .saturating_add(self.clock_skew_seconds()),
1005 )
1006 .await
1007 }
1008}
1009
1010fn verify_ed255sa(
1012 verifying_key: &VerifyingKey,
1013 signing_input: &[u8],
1014 signature: &[u8],
1015) -> Result<()> {
1016 if signature.len() != 64 {
1017 return Err(Error::TokenSignature {
1018 reason: format!(
1019 "ed25519 signature must be 64 bytes, got {}",
1020 signature.len()
1021 ),
1022 });
1023 }
1024 let sig =
1025 ed25519_dalek::Signature::from_slice(signature).map_err(|e| Error::TokenSignature {
1026 reason: format!("ed25519 sig parse: {}", e),
1027 })?;
1028 verifying_key
1029 .verify(signing_input, &sig)
1030 .map_err(|_| Error::TokenSignature {
1031 reason: "ed25519 signature verification failed".into(),
1032 })?;
1033 Ok(())
1034}
1035
1036impl DelegationSigner {
1038 #[cfg(test)]
1039 pub fn public_key_b64_bytes(&self) -> Vec<u8> {
1040 base64::engine::general_purpose::STANDARD
1041 .decode(self.public_key_b64())
1042 .expect("public key is base64")
1043 }
1044}
1045
1046#[cfg(test)]
1047#[allow(clippy::needless_borrow, unused_must_use)]
1048mod tests {
1052 use super::*;
1053 use parking_lot::Mutex;
1054 use std::collections::HashMap;
1055
1056 #[derive(Default)]
1057 struct TestRevocationStore(Mutex<HashMap<String, i64>>);
1058
1059 #[async_trait]
1060 impl DelegationRevocationStore for TestRevocationStore {
1061 async fn revoke(&self, token_id: &str, retain_until_unix: i64) -> Result<()> {
1062 self.0.lock().insert(token_id.to_owned(), retain_until_unix);
1063 Ok(())
1064 }
1065
1066 async fn is_revoked(&self, token_id: &str, now_unix: i64) -> Result<bool> {
1067 let mut entries = self.0.lock();
1068 match entries.get(token_id).copied() {
1069 Some(retain_until) if retain_until > now_unix => Ok(true),
1070 Some(_) => {
1071 entries.remove(token_id);
1072 Ok(false)
1073 }
1074 None => Ok(false),
1075 }
1076 }
1077 }
1078
1079 struct FailingRevocationStore;
1080
1081 #[async_trait]
1082 impl DelegationRevocationStore for FailingRevocationStore {
1083 async fn revoke(&self, _token_id: &str, _retain_until_unix: i64) -> Result<()> {
1084 Err(Error::Other("revocation storage unavailable".into()))
1085 }
1086
1087 async fn is_revoked(&self, _token_id: &str, _now_unix: i64) -> Result<bool> {
1088 Err(Error::Other("revocation storage unavailable".into()))
1089 }
1090 }
1091
1092 fn child_spec(
1093 sub: &str,
1094 aud: &str,
1095 actions: Vec<String>,
1096 resources: Vec<String>,
1097 ttl_seconds: u64,
1098 ) -> DelegationSpec {
1099 DelegationSpec::new(
1100 sub,
1101 aud,
1102 actions,
1103 resources,
1104 DelegationConfig {
1105 ttl: Duration::from_secs(ttl_seconds),
1106 },
1107 )
1108 }
1109
1110 #[test]
1111 fn mint_and_verify_roundtrip() {
1112 let signer = DelegationSigner::generate();
1113 let token = signer
1114 .mint(
1115 "Agent::\"research\"",
1116 "Agent::\"summarizer\"",
1117 "agentguard://prod/email",
1118 vec!["ToolCall::send_email".into()],
1119 vec!["Mailbox::*".into()],
1120 DelegationConfig::default(),
1121 )
1122 .unwrap();
1123
1124 let verifier = DelegationVerifier::new();
1125 verifier.add_key(
1126 signer.key_id(),
1127 Algorithm::EdDSA,
1128 &signer.public_key_b64_bytes(),
1129 );
1130 let v = verifier
1131 .verify(
1132 token.to_jws(),
1133 "agentguard://prod/email",
1134 chrono::Utc::now().timestamp(),
1135 )
1136 .unwrap();
1137 assert_eq!(v.claims().sub, "Agent::\"summarizer\"");
1138 assert_eq!(v.claims().aud, "agentguard://prod/email");
1139 assert_eq!(v.key_id(), signer.key_id());
1140 }
1141
1142 #[test]
1143 fn verified_grant_enforces_subject_action_resource_and_constraints() {
1144 let signer = DelegationSigner::generate();
1145 let token = signer
1146 .mint_with(
1147 "Agent::parent",
1148 "Agent::worker",
1149 "agentguard://prod/tools",
1150 vec!["ToolCall::read_doc".into()],
1151 vec!["Document::team-*".into()],
1152 DelegationConfig::default(),
1153 |claims| {
1154 claims.constraints = Some(ConstraintSet::new(vec![ConstraintExpr::And {
1155 all: vec![
1156 ConstraintExpr::Equals {
1157 path: "context.tenant".into(),
1158 value: serde_json::json!("team-a"),
1159 },
1160 ConstraintExpr::Not {
1161 inner: Box::new(ConstraintExpr::GreaterThan {
1162 path: "context.risk".into(),
1163 value: 10,
1164 }),
1165 },
1166 ],
1167 }]));
1168 },
1169 )
1170 .unwrap();
1171 let verifier = DelegationVerifier::new();
1172 verifier
1173 .add_key(
1174 signer.key_id(),
1175 Algorithm::EdDSA,
1176 &signer.public_key_b64_bytes(),
1177 )
1178 .unwrap();
1179 let verified = verifier
1180 .verify(
1181 token.to_jws(),
1182 "agentguard://prod/tools",
1183 chrono::Utc::now().timestamp(),
1184 )
1185 .unwrap();
1186 let facts = serde_json::json!({"context": {"tenant": "team-a", "risk": 1}});
1187
1188 assert!(verified.allows(
1189 "Agent::worker",
1190 "ToolCall::read_doc",
1191 "Document::team-42",
1192 &facts,
1193 ));
1194 assert!(!verified.allows(
1195 "Agent::other",
1196 "ToolCall::read_doc",
1197 "Document::team-42",
1198 &facts,
1199 ));
1200 assert!(!verified.allows(
1201 "Agent::worker",
1202 "ToolCall::write_doc",
1203 "Document::team-42",
1204 &facts,
1205 ));
1206 assert!(!verified.allows(
1207 "Agent::worker",
1208 "ToolCall::read_doc",
1209 "Document::external",
1210 &facts,
1211 ));
1212 assert!(!verified.allows(
1213 "Agent::worker",
1214 "ToolCall::read_doc",
1215 "Document::team-42",
1216 &serde_json::json!({"context": {"tenant": "team-b", "risk": 1}}),
1217 ));
1218 assert!(!verified.allows(
1219 "Agent::worker",
1220 "ToolCall::read_doc",
1221 "Document::team-42",
1222 &serde_json::json!({}),
1223 ));
1224 assert!(!verified.allows(
1225 "Agent::worker",
1226 "ToolCall::read_doc",
1227 "Document::team-42",
1228 &serde_json::json!({"context": {"tenant": "team-a"}}),
1229 ));
1230 assert!(!verified.allows(
1231 "Agent::worker",
1232 "ToolCall::read_doc",
1233 "Document::team-42",
1234 &serde_json::json!({"context": {"tenant": "team-a", "risk": "low"}}),
1235 ));
1236 }
1237
1238 #[test]
1239 fn child_grants_are_attenuated_and_bound_to_parent_lifetime() {
1240 let signer = DelegationSigner::generate();
1241 let parent_nbf = chrono::Utc::now().timestamp() + 30;
1242 let parent_token = signer
1243 .mint_with(
1244 "Agent::root",
1245 "Agent::parent",
1246 "agentguard://prod/parent",
1247 vec!["ToolCall::read_doc".into(), "ToolCall::write_doc".into()],
1248 vec!["Document::team-*".into()],
1249 DelegationConfig {
1250 ttl: Duration::from_secs(300),
1251 },
1252 |claims| claims.nbf = Some(parent_nbf),
1253 )
1254 .unwrap();
1255 let verifier = DelegationVerifier::new();
1256 verifier
1257 .add_key(
1258 signer.key_id(),
1259 Algorithm::EdDSA,
1260 &signer.public_key_b64_bytes(),
1261 )
1262 .unwrap();
1263 let parent = verifier
1264 .verify(
1265 parent_token.to_jws(),
1266 "agentguard://prod/parent",
1267 chrono::Utc::now().timestamp(),
1268 )
1269 .unwrap();
1270
1271 let child = signer
1272 .mint_attenuated(
1273 &parent,
1274 child_spec(
1275 "Agent::worker",
1276 "agentguard://prod/worker",
1277 vec!["ToolCall::read_doc".into()],
1278 vec!["Document::team-42".into()],
1279 60,
1280 ),
1281 )
1282 .unwrap();
1283 let verified_child = verifier
1284 .verify(
1285 child.to_jws(),
1286 "agentguard://prod/worker",
1287 chrono::Utc::now().timestamp(),
1288 )
1289 .unwrap();
1290 assert!(verified_child.claims().exp <= parent.claims().exp);
1291 assert!(verified_child.allows(
1292 "Agent::worker",
1293 "ToolCall::read_doc",
1294 "Document::team-42",
1295 &serde_json::json!({}),
1296 ));
1297 assert_eq!(verified_child.claims().nbf, Some(parent_nbf));
1298 let actor = verified_child.claims().act.as_ref().unwrap();
1299 assert_eq!(actor.sub, "Agent::parent");
1300 assert_eq!(actor.iss.as_deref(), Some("Agent::root"));
1301
1302 assert!(signer
1303 .mint_attenuated(
1304 &parent,
1305 child_spec(
1306 "Agent::worker",
1307 "aud",
1308 vec!["ToolCall::shell_exec".into()],
1309 vec!["Document::team-42".into()],
1310 60,
1311 ),
1312 )
1313 .is_err());
1314 assert!(signer
1315 .mint_attenuated(
1316 &parent,
1317 child_spec(
1318 "Agent::worker",
1319 "aud",
1320 vec!["ToolCall::read_doc".into()],
1321 vec!["Document::*".into()],
1322 60,
1323 ),
1324 )
1325 .is_err());
1326 assert!(signer
1327 .mint_attenuated(
1328 &parent,
1329 child_spec(
1330 "Agent::worker",
1331 "aud",
1332 vec!["ToolCall::read_doc".into()],
1333 vec!["Document::team-42".into()],
1334 600,
1335 ),
1336 )
1337 .is_err());
1338 let unrelated_signer = DelegationSigner::generate();
1339 assert!(unrelated_signer
1340 .mint_attenuated(
1341 &parent,
1342 child_spec(
1343 "Agent::worker",
1344 "aud",
1345 vec!["ToolCall::read_doc".into()],
1346 vec!["Document::team-42".into()],
1347 60,
1348 ),
1349 )
1350 .is_err());
1351 }
1352
1353 #[test]
1354 fn child_grants_cannot_remove_or_change_parent_constraints() {
1355 let signer = DelegationSigner::generate();
1356 let parent_constraints = ConstraintSet::new(vec![ConstraintExpr::Equals {
1357 path: "context.tenant".into(),
1358 value: serde_json::json!("team-a"),
1359 }]);
1360 let parent_token = signer
1361 .mint_with(
1362 "Agent::root",
1363 "Agent::parent",
1364 "parent-aud",
1365 vec!["ToolCall::read_doc".into()],
1366 vec!["Document::team-*".into()],
1367 DelegationConfig {
1368 ttl: Duration::from_secs(300),
1369 },
1370 |claims| claims.constraints = Some(parent_constraints.clone()),
1371 )
1372 .unwrap();
1373 let verifier = DelegationVerifier::new();
1374 verifier
1375 .add_key(
1376 signer.key_id(),
1377 Algorithm::EdDSA,
1378 &signer.public_key_b64_bytes(),
1379 )
1380 .unwrap();
1381 let parent = verifier
1382 .verify(
1383 parent_token.to_jws(),
1384 "parent-aud",
1385 chrono::Utc::now().timestamp(),
1386 )
1387 .unwrap();
1388 let actions = vec!["ToolCall::read_doc".into()];
1389 let resources = vec!["Document::team-42".into()];
1390
1391 assert!(signer
1392 .mint_attenuated(
1393 &parent,
1394 child_spec(
1395 "Agent::worker",
1396 "child-aud",
1397 actions.clone(),
1398 resources.clone(),
1399 60,
1400 ),
1401 )
1402 .is_err());
1403 assert!(signer
1404 .mint_attenuated(
1405 &parent,
1406 child_spec(
1407 "Agent::worker",
1408 "child-aud",
1409 actions.clone(),
1410 resources.clone(),
1411 60,
1412 )
1413 .with_constraints(ConstraintSet::new(vec![
1414 ConstraintExpr::Equals {
1415 path: "context.tenant".into(),
1416 value: serde_json::json!("team-b"),
1417 }
1418 ])),
1419 )
1420 .is_err());
1421 assert!(signer
1422 .mint_attenuated(
1423 &parent,
1424 child_spec(
1425 "Agent::worker",
1426 "child-aud",
1427 actions.clone(),
1428 resources.clone(),
1429 60,
1430 )
1431 .with_constraints(parent_constraints.clone()),
1432 )
1433 .is_ok());
1434 let mut stricter_constraints = parent_constraints.expressions.clone();
1435 stricter_constraints.push(ConstraintExpr::Equals {
1436 path: "context.environment".into(),
1437 value: serde_json::json!("production"),
1438 });
1439 assert!(signer
1440 .mint_attenuated(
1441 &parent,
1442 child_spec("Agent::worker", "child-aud", actions, resources, 60)
1443 .with_constraints(ConstraintSet::new(stricter_constraints)),
1444 )
1445 .is_ok());
1446 }
1447
1448 #[test]
1449 fn clock_skew_tolerates_minor_drift() {
1450 let signer = DelegationSigner::generate();
1456 let cfg = DelegationConfig::default();
1457 let token = signer.mint("a", "b", "aud", vec![], vec![], cfg).unwrap();
1458 let verifier = DelegationVerifier::new();
1459 verifier
1460 .add_key(
1461 signer.key_id(),
1462 Algorithm::EdDSA,
1463 &signer.public_key_b64_bytes(),
1464 )
1465 .unwrap();
1466 let now = chrono::Utc::now().timestamp();
1467 let drifted_now = now + 30; verifier.set_clock_skew_seconds(60);
1471 assert!(verifier.verify(&token.to_jws(), "aud", drifted_now).is_ok());
1472 verifier.set_clock_skew_seconds(0);
1474 assert!(verifier.verify(&token.to_jws(), "aud", drifted_now).is_ok());
1475 let far_future = now + 1000;
1477 verifier.set_clock_skew_seconds(60);
1478 assert!(verifier.verify(&token.to_jws(), "aud", far_future).is_err());
1479 verifier.set_clock_skew_seconds(0);
1480 assert!(verifier.verify(&token.to_jws(), "aud", far_future).is_err());
1481 }
1482
1483 #[test]
1484 fn add_key_rejects_wrong_length() {
1485 let v = DelegationVerifier::new();
1486 let bad = vec![0u8; 31];
1488 assert!(v.add_key("k", Algorithm::EdDSA, &bad).is_err());
1489 }
1490
1491 #[test]
1492 fn forged_signature_rejected() {
1493 let signer = DelegationSigner::generate();
1494 let token = signer
1495 .mint("a", "b", "aud", vec![], vec![], DelegationConfig::default())
1496 .unwrap();
1497
1498 let parts: Vec<&str> = token.to_jws().split('.').collect();
1502 let bogus = "A".repeat(86);
1504 let forged = format!("{}.{}.{}", parts[0], parts[1], bogus);
1505
1506 let verifier = DelegationVerifier::new();
1507 verifier.add_key(
1508 signer.key_id(),
1509 Algorithm::EdDSA,
1510 &signer.public_key_b64_bytes(),
1511 );
1512 let res = verifier.verify(&forged, "aud", chrono::Utc::now().timestamp());
1513 assert!(matches!(res, Err(Error::TokenSignature { .. })));
1514 }
1515
1516 #[test]
1517 fn wrong_audience_rejected() {
1518 let signer = DelegationSigner::generate();
1519 let token = signer
1520 .mint(
1521 "a",
1522 "b",
1523 "aud1",
1524 vec![],
1525 vec![],
1526 DelegationConfig::default(),
1527 )
1528 .unwrap();
1529 let verifier = DelegationVerifier::new();
1530 verifier.add_key(
1531 signer.key_id(),
1532 Algorithm::EdDSA,
1533 &signer.public_key_b64_bytes(),
1534 );
1535 let res = verifier.verify(token.to_jws(), "aud2", chrono::Utc::now().timestamp());
1536 assert!(matches!(res, Err(Error::TokenSignature { .. })));
1537 }
1538
1539 fn verify_mutated_claims(
1540 mutate: impl FnOnce(&mut DelegationClaims),
1541 ) -> Result<VerifiedDelegation> {
1542 let signer = DelegationSigner::generate();
1543 let token = signer
1544 .mint_with(
1545 "issuer",
1546 "subject",
1547 "aud",
1548 vec!["ToolCall::read".into()],
1549 vec!["Document::*".into()],
1550 DelegationConfig::default(),
1551 mutate,
1552 )
1553 .unwrap();
1554 let verifier = DelegationVerifier::new();
1555 verifier.add_key(
1556 signer.key_id(),
1557 Algorithm::EdDSA,
1558 &signer.public_key_b64_bytes(),
1559 )?;
1560 verifier.verify(token.to_jws(), "aud", chrono::Utc::now().timestamp())
1561 }
1562
1563 fn sign_with_header(
1564 signer: &DelegationSigner,
1565 claims: &DelegationClaims,
1566 header: serde_json::Value,
1567 ) -> String {
1568 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
1569 .encode(serde_json::to_vec(&header).unwrap());
1570 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
1571 .encode(serde_json::to_vec(claims).unwrap());
1572 let signing_input = format!("{header}.{payload}");
1573 let signature = signer.key.sign(signing_input.as_bytes());
1574 format!(
1575 "{signing_input}.{}",
1576 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.to_bytes())
1577 )
1578 }
1579
1580 #[test]
1581 fn verifier_rejects_wrong_type_and_unsupported_jws_headers() {
1582 let signer = DelegationSigner::generate();
1583 let token = signer
1584 .mint(
1585 "issuer",
1586 "subject",
1587 "aud",
1588 vec![],
1589 vec![],
1590 DelegationConfig::default(),
1591 )
1592 .unwrap();
1593 let verifier = DelegationVerifier::new();
1594 verifier
1595 .add_key(
1596 signer.key_id(),
1597 Algorithm::EdDSA,
1598 &signer.public_key_b64_bytes(),
1599 )
1600 .unwrap();
1601 let now = chrono::Utc::now().timestamp();
1602
1603 for header in [
1604 serde_json::json!({"alg":"EdDSA","typ":"JWT","kid":signer.key_id()}),
1605 serde_json::json!({
1606 "alg":"EdDSA",
1607 "typ":"agentguard-delegation+jwt",
1608 "kid":signer.key_id(),
1609 "crit":["exp"]
1610 }),
1611 serde_json::json!({
1612 "alg":"EdDSA",
1613 "typ":"agentguard-delegation+jwt",
1614 "kid":signer.key_id(),
1615 "b64":false
1616 }),
1617 ] {
1618 let jws = sign_with_header(&signer, &token.claims, header);
1619 assert!(matches!(
1620 verifier.verify(&jws, "aud", now),
1621 Err(Error::InvalidToken(_))
1622 ));
1623 }
1624 }
1625
1626 #[test]
1627 fn verified_claims_must_have_identity_and_consistent_times() {
1628 assert!(matches!(
1629 verify_mutated_claims(|claims| claims.iss.clear()),
1630 Err(Error::InvalidToken(_))
1631 ));
1632 assert!(matches!(
1633 verify_mutated_claims(|claims| claims.sub.clear()),
1634 Err(Error::InvalidToken(_))
1635 ));
1636 assert!(matches!(
1637 verify_mutated_claims(|claims| claims.jti.clear()),
1638 Err(Error::InvalidToken(_))
1639 ));
1640 assert!(matches!(
1641 verify_mutated_claims(|claims| claims.exp = claims.iat),
1642 Err(Error::InvalidToken(_))
1643 ));
1644 assert!(matches!(
1645 verify_mutated_claims(|claims| claims.iat = chrono::Utc::now().timestamp() + 120),
1646 Err(Error::TokenNotYetValid(_))
1647 ));
1648 assert!(matches!(
1649 verify_mutated_claims(|claims| claims.nbf = Some(i64::MAX)),
1650 Err(Error::InvalidToken(_))
1651 ));
1652 assert!(matches!(
1653 verify_mutated_claims(|claims| {
1654 claims.nbf = Some(chrono::Utc::now().timestamp() + 120)
1655 }),
1656 Err(Error::TokenNotYetValid(_))
1657 ));
1658 }
1659
1660 #[test]
1661 fn unknown_kid_rejected() {
1662 let signer = DelegationSigner::generate();
1663 let token = signer
1664 .mint("a", "b", "aud", vec![], vec![], DelegationConfig::default())
1665 .unwrap();
1666 let verifier = DelegationVerifier::new();
1667 let res = verifier.verify(token.to_jws(), "aud", chrono::Utc::now().timestamp());
1669 assert!(matches!(res, Err(Error::TokenSignature { .. })));
1670 }
1671
1672 #[test]
1673 fn parser_rejects_oversized_compact_tokens_before_decoding() {
1674 let oversized = "a".repeat(MAX_DELEGATION_TOKEN_BYTES + 1);
1675 assert!(matches!(
1676 DelegationToken::parse(&oversized),
1677 Err(Error::InvalidToken(_))
1678 ));
1679 }
1680
1681 #[tokio::test]
1682 async fn shared_revocation_is_checked_and_storage_failures_fail_closed() {
1683 let signer = DelegationSigner::generate();
1684 let token = signer
1685 .mint(
1686 "issuer",
1687 "subject",
1688 "aud",
1689 vec!["ToolCall::read".into()],
1690 vec!["Document::*".into()],
1691 DelegationConfig::default(),
1692 )
1693 .unwrap();
1694 let verifier = DelegationVerifier::new();
1695 verifier
1696 .add_key(
1697 signer.key_id(),
1698 Algorithm::EdDSA,
1699 &signer.public_key_b64_bytes(),
1700 )
1701 .unwrap();
1702 let now = chrono::Utc::now().timestamp();
1703 let store = TestRevocationStore::default();
1704 let verified = verifier
1705 .verify_with_revocation_store(token.to_jws(), "aud", now, &store)
1706 .await
1707 .unwrap();
1708
1709 verifier.revoke(&verified, &store).await.unwrap();
1710 assert!(matches!(
1711 verifier
1712 .verify_with_revocation_store(token.to_jws(), "aud", now, &store)
1713 .await,
1714 Err(Error::TokenRevoked(_))
1715 ));
1716 assert_eq!(
1717 store.0.lock().get(&verified.claims().jti),
1718 Some(&(verified.claims().exp + verifier.clock_skew_seconds()))
1719 );
1720 assert!(matches!(
1721 verifier
1722 .verify_with_revocation_store(token.to_jws(), "aud", now, &FailingRevocationStore,)
1723 .await,
1724 Err(Error::Other(_))
1725 ));
1726 }
1727
1728 #[test]
1729 fn hs256_rejected_to_prevent_algorithm_confusion() {
1730 let signer = DelegationSigner::generate();
1734 let token = signer
1735 .mint("a", "b", "aud", vec![], vec![], DelegationConfig::default())
1736 .unwrap();
1737
1738 let parts: Vec<&str> = token.to_jws().split('.').collect();
1739 let header = serde_json::json!({"alg": "HS256", "typ": "JWT", "kid": signer.key_id()});
1740 let header_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD
1741 .encode(serde_json::to_vec(&header).unwrap());
1742 let signing_input = format!("{}.{}", header_b64, parts[1]);
1743 use hmac::{Hmac, Mac};
1744 use sha2::Sha256;
1745 let mut mac = <Hmac<Sha256> as Mac>::new_from_slice(b"some-hmac-secret").expect("hmac key");
1746 mac.update(signing_input.as_bytes());
1747 let sig = mac.finalize().into_bytes();
1748 let sig_b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(sig);
1749 let forged = format!("{}.{}.{}", header_b64, parts[1], sig_b64);
1750
1751 let verifier = DelegationVerifier::new();
1752 verifier.add_key(
1753 signer.key_id(),
1754 Algorithm::EdDSA,
1755 &signer.public_key_b64_bytes(),
1756 );
1757 let res = verifier.verify(&forged, "aud", chrono::Utc::now().timestamp());
1758 assert!(matches!(res, Err(Error::TokenSignature { .. })));
1759 }
1760
1761 #[test]
1762 fn glob_match_wildcards() {
1763 assert!(glob_match("*", "anything"));
1764 assert!(glob_match("Mailbox::*", "Mailbox::\"alice@x\""));
1765 assert!(glob_match("Mailbox::\"alice*\"", "Mailbox::\"alice@x\""));
1766 assert!(!glob_match("Mailbox::\"bob*\"", "Mailbox::\"alice@x\""));
1767 assert!(glob_match("a*b*c", "axbxc"));
1769 assert!(glob_match("a*b*c", "abxc"));
1770 assert!(glob_match("a*b*c", "axbxc"));
1771 assert!(glob_match("a*x*", "axxby"));
1774 }
1775
1776 #[test]
1778 fn double_negation_collapses() {
1779 let inner = ConstraintExpr::GreaterThan {
1780 path: "context.args.amount".into(),
1781 value: 100,
1782 };
1783 let doubly_negated = ConstraintExpr::Not {
1784 inner: Box::new(ConstraintExpr::Not {
1785 inner: Box::new(inner.clone()),
1786 }),
1787 };
1788 let mut high = serde_json::Map::new();
1789 high.insert("amount".into(), serde_json::json!(200));
1790 let mut ctx = serde_json::Map::new();
1791 ctx.insert("args".into(), serde_json::Value::Object(high));
1792 let req = serde_json::Value::Object(ctx);
1793 assert_eq!(doubly_negated.evaluate(&req), inner.evaluate(&req));
1797 }
1798
1799 #[test]
1803 fn missing_path_evaluates_false() {
1804 let c = ConstraintExpr::Equals {
1805 path: "context.args.missing".into(),
1806 value: serde_json::json!("x"),
1807 };
1808 let req = serde_json::json!({"context": {"args": {"present": "x"}}});
1809 assert!(!c.evaluate(&req));
1810 }
1811
1812 #[cfg(test)]
1813 mod proptests {
1814 use super::*;
1815 use proptest::prelude::*;
1816
1817 proptest! {
1818 #[test]
1819 fn glob_match_always_matches_star(
1820 v in ".*"
1821 ) {
1822 prop_assert!(glob_match("*", &v));
1823 }
1824
1825 #[test]
1826 fn glob_match_literal_no_wildcards(
1827 lit in "[a-zA-Z0-9]{1,16}"
1828 ) {
1829 prop_assert!(glob_match(&lit, &lit));
1830 prop_assume!(lit.len() != 1);
1834 prop_assert!(!glob_match(&lit, "xy"));
1835 }
1836
1837 #[test]
1838 fn glob_match_star_prefix(
1839 prefix in "[a-zA-Z]{1,8}",
1840 suffix in "[a-zA-Z]{1,8}",
1841 ) {
1842 let pat = format!("{}*", prefix);
1843 let val = format!("{}{}", prefix, suffix);
1844 prop_assert!(glob_match(&pat, &val));
1845 }
1846 }
1847 }
1848
1849 #[test]
1850 fn constraint_evaluation_works() {
1851 let c = ConstraintSet::new(vec![ConstraintExpr::LessThan {
1852 path: "context.args.amount".into(),
1853 value: 1000,
1854 }]);
1855 let req = serde_json::json!({"context": {"args": {"amount": 500}}});
1856 assert!(c.expressions[0].evaluate(&req));
1857 let req2 = serde_json::json!({"context": {"args": {"amount": 5000}}});
1858 assert!(!c.expressions[0].evaluate(&req2));
1859 }
1860
1861 #[test]
1862 fn act_chain_roundtrip() {
1863 let signer = DelegationSigner::generate();
1864 let token = signer
1865 .mint_with(
1866 "User::\"alice\"",
1867 "Agent::\"summarizer\"",
1868 "aud",
1869 vec![],
1870 vec![],
1871 DelegationConfig::default(),
1872 |c| {
1873 c.act = Some(Box::new(ActClaim {
1874 sub: "Agent::\"research\"".into(),
1875 iss: Some("User::\"alice\"".into()),
1876 act: Some(Box::new(ActClaim {
1877 sub: "User::\"alice\"".into(),
1878 iss: None,
1879 act: None,
1880 })),
1881 }));
1882 },
1883 )
1884 .unwrap();
1885 let verifier = DelegationVerifier::new();
1886 verifier.add_key(
1887 signer.key_id(),
1888 Algorithm::EdDSA,
1889 &signer.public_key_b64_bytes(),
1890 );
1891 let v = verifier
1892 .verify(token.to_jws(), "aud", chrono::Utc::now().timestamp())
1893 .unwrap();
1894 assert_eq!(v.claims().sub, "Agent::\"summarizer\"");
1895 assert_eq!(v.claims().act.as_ref().unwrap().sub, "Agent::\"research\"");
1896 }
1897}