agentguard_core/
delegation.rs

1//! Scoped delegation tokens for sub-agents.
2//!
3//! v2.0: hard-break from v1 compact format. Tokens are now standard JWS
4//! (RFC 7515) using EdDSA. Supports structured constraints, RFC 8693 act
5//! chain, and algorithm agility via [`Algorithm`].
6
7pub 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
17/// Maximum compact JWS size accepted by the delegation parser.
18pub const MAX_DELEGATION_TOKEN_BYTES: usize = 64 * 1024;
19
20/// Durable revocation-state port for delegated grants.
21///
22/// Implementations must share state across every verifier that accepts the
23/// token, retain revocations until `retain_until_unix`, and fail closed on
24/// storage errors. This async port allows Redis or database adapters to avoid
25/// blocking runtime workers. It does not provide storage itself.
26#[async_trait]
27pub trait DelegationRevocationStore: Send + Sync {
28    /// Mark a token id revoked until its signature can no longer be accepted.
29    async fn revoke(&self, token_id: &str, retain_until_unix: i64) -> Result<()>;
30
31    /// Check revocation state. Implementations may discard entries whose
32    /// retention deadline is at or before `now_unix`.
33    async fn is_revoked(&self, token_id: &str, now_unix: i64) -> Result<bool>;
34}
35
36/// Standard JWS compact serialization: `base64url(header).base64url(payload).base64url(signature)`.
37///
38/// The header carries `alg`, `kid`, `typ`.
39#[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/// RFC 8693 nested act claim.
61#[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/// Structured constraints — a tree of expressions evaluated against the
72/// request context. Replaces v1's free-form constraint strings.
73#[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/// A single constraint expression over a context path.
92///
93/// Path syntax: dotted segments like `context.args.amount`,
94/// `context.session.ip`, `principal.tenant_id`.
95#[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    /// Return false when evaluation would have to interpret missing or
132    /// wrongly typed input as a boolean false. This is especially important
133    /// beneath `Not`, where negating the ordinary fail-closed `false` result
134    /// would otherwise turn absent facts into permission.
135    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    /// Evaluate against a JSON value (the request context).
155    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    // Walk the dotted path directly on the Value. Avoids the String
184    // allocation that `path.split('.')` would produce, and avoids the
185    // JSON-pointer conversion that `Value::pointer` would require.
186    // The path is dot-separated segments like `context.args.amount`.
187    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
213/// Glob match with `*` wildcard support.
214///
215/// Supports `*` (matches any sequence of characters, including empty).
216/// The match is greedy: it matches the longest possible prefix of the value
217/// against the literal prefix, then advances. This is sufficient for the
218/// resource-pattern use case in delegation tokens.
219///
220/// Implementation: a two-pointer walk that avoids both the recursion and
221/// the Vec<&str> allocation of the previous version. The
222/// `*`-delimited segments are matched left-to-right; on any partial
223/// failure we back up the segment pointer to the previous `*` position
224/// and try the next value position.
225pub 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    // Find split points: positions of each '*' in `pat`.
237    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    // Walk the non-wildcard prefix P0.
245    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    // Match segments P1..Pn greedily from the start; on failure,
251    // backtrack by incrementing the previous value position.
252    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            // Last segment: must be a suffix of value.
263            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        // Find pat[seg_start..seg_end] in val starting at or after vi.
270        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/// TTL configuration for minting tokens.
296#[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/// Input for minting a child delegation from a verified parent.
310#[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/// Signer that mints JWS tokens.
345#[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    /// Generate a fresh Ed25519 signing key.
362    ///
363    /// # Examples
364    /// ```
365    /// use agentguard_core::DelegationSigner;
366    /// let signer = DelegationSigner::generate();
367    /// let _public_key = signer.public_key_b64();
368    /// ```
369    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    /// Construct from raw 32-byte Ed25519 secret bytes.
377    ///
378    /// # Errors
379    /// Returns `Error::InvalidToken` if `bytes` is not exactly 32 bytes.
380    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    /// Mint a delegation token with the given configuration.
407    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    /// Mint a delegation token with a callback to mutate the claims before signing.
428    #[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    /// Mint a child grant that cannot outlive or broaden the supplied,
462    /// cryptographically verified parent grant.
463    ///
464    /// Actions must be present in the parent grant. Literal child resources
465    /// must match a parent resource pattern; child patterns containing `*`
466    /// are accepted only when identical to a parent pattern. If the parent
467    /// has constraints, the child must preserve all of them and may add
468    /// stricter conjunctive constraints.
469    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    /// Sign a JWS with the given claims.
586    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/// A signed JWS token + its parsed claims.
607#[derive(Debug, Clone, Serialize, Deserialize)]
608pub struct DelegationToken {
609    pub claims: DelegationClaims,
610    pub jws: String,
611}
612
613impl DelegationToken {
614    /// Return the compact JWS string.
615    pub fn to_jws(&self) -> &str {
616        &self.jws
617    }
618
619    /// Parse the JWS structure (header, payload, signature) without
620    /// cryptographic verification.
621    ///
622    /// # Warning
623    /// The returned `DelegationToken` is **not authenticated**. The
624    /// `claims` field comes from the unverified payload and can be
625    /// arbitrarily chosen by an attacker. Always use
626    /// [`DelegationVerifier::verify`] before trusting any field
627    /// of the returned value.
628    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        // Verify the signature is well-formed (correct length for the alg).
658        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/// A successfully verified delegation token.
675#[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    /// Read claims only after successful cryptographic and standard-claim
685    /// verification. The returned reference prevents callers from widening
686    /// scopes after verification.
687    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    /// Check whether this verified grant covers one concrete request.
700    ///
701    /// `subject` must be the trusted identity of the acting agent, not a
702    /// value copied from an untrusted request. `request_facts` is the JSON
703    /// object against which optional constraints are evaluated. Missing
704    /// action/resource scopes and missing constraint paths fail closed.
705    /// This check does not establish that the issuer had authority to mint
706    /// the grant, nor does it provide revocation; callers must enforce those
707    /// properties separately.
708    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/// Verifies JWS tokens using a key registry of public keys.
735///
736/// The verifier holds an internal `HashMap<kid, (Algorithm, VerifyingKey)>`.
737/// When a token is verified, the registry is consulted to find the matching
738/// key, and the EdDSA signature is checked cryptographically.
739#[derive(Default)]
740pub struct DelegationVerifier {
741    keys: parking_lot::RwLock<std::collections::HashMap<String, (Algorithm, VerifyingKey)>>,
742    /// Clock skew tolerance for `exp` / `nbf` checks. Mirrors
743    /// `JwtConfig::clock_skew` so a 60-second drift doesn't reject
744    /// otherwise-valid tokens. Default 60 s.
745    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    /// Set the clock-skew tolerance (seconds) used when comparing
757    /// `exp` / `nbf` to "now".
758    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    /// Add a public key (raw Ed25519 bytes).
768    ///
769    /// Replaces any existing key under the same `kid`. Returns `Err`
770    /// when the key bytes are invalid (wrong length, bad parse) so
771    /// callers see the failure rather than silently dropping it.
772    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    /// Remove all keys.
804    pub fn clear(&self) {
805        self.keys.write().clear();
806    }
807
808    /// Number of registered keys.
809    pub fn key_count(&self) -> usize {
810        self.keys.read().len()
811    }
812
813    /// Verify the JWS signature, then validate standard claims (exp, nbf, aud).
814    /// Returns the verified claims on success.
815    ///
816    /// This is the secure entry point — unlike [`DelegationToken::parse`],
817    /// it actually verifies the EdDSA signature, AgentGuard token type, and
818    /// standard identity/time claims.
819    ///
820    /// # Errors
821    /// Returns `Error::TokenSignature` if the signature is invalid
822    /// or the kid is unknown. Returns `Error::TokenExpired` if `now_unix`
823    /// is past `exp + clock_skew_seconds`. Returns `Error::TokenNotYetValid`
824    /// if `iat` or `nbf` is in the future beyond the configured skew. Returns `Error::InvalidToken` for any
825    /// JWS parse / base64 / signature-format error or audience mismatch.
826    ///
827    /// # Examples
828    /// ```
829    /// use agentguard_core::{DelegationConfig, DelegationSigner, DelegationVerifier,
830    ///     auth_keys::Algorithm};
831    /// use base64::Engine as _;
832    ///
833    /// let signer = DelegationSigner::generate();
834    /// let verifier = DelegationVerifier::new();
835    /// let pub_bytes = base64::engine::general_purpose::STANDARD
836    ///     .decode(signer.public_key_b64()).unwrap();
837    /// verifier.add_key(signer.key_id(), Algorithm::EdDSA, &pub_bytes).unwrap();
838    /// let token = signer.mint("alice", "summarizer", "aud",
839    ///     vec!["ToolCall::send".into()],
840    ///     vec!["Mailbox::*".into()],
841    ///     DelegationConfig::default()).unwrap();
842    /// let verified = verifier.verify(&token.jws, "aud", chrono::Utc::now().timestamp()).unwrap();
843    /// assert_eq!(verified.claims().iss, "alice");
844    /// assert_eq!(verified.claims().aud, "aud");
845    /// ```
846    pub fn verify(
847        &self,
848        token: &str,
849        expected_aud: &str,
850        now_unix: i64,
851    ) -> Result<VerifiedDelegation> {
852        // Step 1: parse the JWS structure (no crypto yet).
853        let parsed = DelegationToken::parse(token)?;
854
855        // Step 2: extract `alg` and `kid` from the header.
856        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        // Step 3: reject HS* with asymmetric keys (algorithm confusion).
890        // We never accept HS256 for delegation — symmetric algorithms don't
891        // apply to the public-key model.
892        if matches!(alg, Algorithm::HS256) {
893            return Err(Error::TokenSignature {
894                reason: "HS256 is not supported for delegation tokens".into(),
895            });
896        }
897
898        // Step 4: look up the key by kid in the registry.
899        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        // Step 5: compute the EdDSA signature check.
916        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        // Step 6: validate time-based claims with clock skew.
926        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        // Step 7: validate the audience.
956        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    /// Verify a token and require a successful shared revocation-state check.
974    /// Storage errors propagate; callers must never continue with the
975    /// cryptographically verified grant when the revocation store is
976    /// unavailable.
977    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    /// Revoke a verified grant in the supplied shared store. The retention
992    /// deadline includes this verifier's configured expiry clock skew.
993    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
1010/// Verify an EdDSA signature over `signing_input` using `verifying_key`.
1011fn 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
1036// Test helpers.
1037impl 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)]
1048// `add_key` is `Result`-returning and `verify`'s `&str` arg wants
1049// explicit borrows. The tests are tightly scoped to verify
1050// cryptographic behavior, not error plumbing.
1051mod 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        // Two tokens minted back-to-back; the verifier is asked to
1451        // verify them 30 seconds after they were minted (i.e. the
1452        // verifier's clock is 30 s behind). With skew=60 the
1453        // second one is still inside the window; with skew=0 it
1454        // is rejected.
1455        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 clock is 30 s ahead
1468                                    // Token's exp = now + 900. Verifier now is now + 30; with
1469                                    // skew=60, 30 + 60 = 90 < 900 → verify OK.
1470        verifier.set_clock_skew_seconds(60);
1471        assert!(verifier.verify(&token.to_jws(), "aud", drifted_now).is_ok());
1472        // With skew=0, 30 < 900 still verifies — sanity check.
1473        verifier.set_clock_skew_seconds(0);
1474        assert!(verifier.verify(&token.to_jws(), "aud", drifted_now).is_ok());
1475        // Now use a clock 1000 seconds ahead — token is past exp.
1476        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        // 31 bytes — too short for Ed25519.
1487        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        // Replace the signature with a 64-byte all-zero buffer. The verifier
1499        // must reject this because the signature is not a valid Ed25519
1500        // signature over the signing input.
1501        let parts: Vec<&str> = token.to_jws().split('.').collect();
1502        // base64url-no-pad of 64 zero bytes
1503        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        // No key registered — must fail.
1668        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        // Forge a token with alg=HS256 and a signature that's "valid" HMAC-SHA256
1731        // over the signing input. The verifier must reject it because HS256
1732        // is never accepted for delegation tokens.
1733        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        // Multiple wildcards.
1768        assert!(glob_match("a*b*c", "axbxc"));
1769        assert!(glob_match("a*b*c", "abxc"));
1770        assert!(glob_match("a*b*c", "axbxc"));
1771        // The previous "overly strict" bug: pattern "a*x*" should match "axxby"
1772        // (first * matches "xx", second * matches "y" after consuming "b").
1773        assert!(glob_match("a*x*", "axxby"));
1774    }
1775
1776    /// T2: double negation collapses. `Not(Not(x))` must equal `x`.
1777    #[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        // The inner says amount > 100. Negated says amount <= 100.
1794        // Double-negated says amount > 100 again. Both branches should
1795        // agree with the inner.
1796        assert_eq!(doubly_negated.evaluate(&req), inner.evaluate(&req));
1797    }
1798
1799    /// T3: missing path evaluates to false (not a panic). Constraints
1800    /// reference context paths; if the path is absent the constraint
1801    /// does not match.
1802    #[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                // Mismatch only when lengths differ. "x" is 1 char, lit is
1831                // 1-16 chars, so for length-1 lit "x" the assertion is invalid.
1832                // Use a length-2 alternative.
1833                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}