Skip to main content

av_identity/
validator.rs

1//! Token validation and delegation-chain verification.
2
3use crate::claims::{NhiClaims, MAX_TTL_SECS};
4use crate::revocation::{RevocationIdentity, RevokedBy};
5
6/// Clock tolerance shared by inbound and exchanged-token validation.
7pub const DEFAULT_LEEWAY_SECS: u64 = 30;
8/// Maximum number of scopes carried by a token.
9pub const MAX_SCOPES: usize = 64;
10/// Maximum characters in a rendered identity field or scope.
11pub const MAX_IDENTITY_STRING_CHARS: usize = 256;
12use base64::Engine as _;
13use jsonwebtoken::{Algorithm, DecodingKey, Validation};
14use parking_lot::RwLock;
15use std::collections::{HashMap, HashSet};
16
17/// Verification key material, bound to a `kid`.
18#[derive(Clone)]
19pub enum KeyMaterial {
20    /// Ed25519 public key, SPKI PEM (`-----BEGIN PUBLIC KEY-----`). EdDSA.
21    Ed25519Pem(String),
22    /// Ed25519 JWK `x` coordinate, base64url without padding.
23    Ed25519Jwk(String),
24    /// HMAC shared secret. HS256 (dev / shared-secret IdP integrations).
25    HmacSecret(Vec<u8>),
26}
27
28impl std::fmt::Debug for KeyMaterial {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Self::Ed25519Pem(_) => f.write_str("KeyMaterial::Ed25519Pem(..)"),
32            Self::Ed25519Jwk(_) => f.write_str("KeyMaterial::Ed25519Jwk(..)"),
33            Self::HmacSecret(_) => f.write_str("KeyMaterial::HmacSecret(..)"), // never print secrets
34        }
35    }
36}
37
38/// Identity validation failures.
39#[derive(Debug, thiserror::Error, PartialEq, Eq)]
40#[non_exhaustive]
41pub enum IdentityError {
42    /// Token structurally malformed.
43    #[error("malformed token: {0}")]
44    Malformed(String),
45    /// Header lacks a `kid`.
46    #[error("token header has no kid")]
47    MissingKid,
48    /// No key registered for this `kid`.
49    #[error("unknown kid {0:?}")]
50    UnknownKid(String),
51    /// Token `alg` is not accepted, or does not match the key type for its
52    /// `kid` (algorithm-confusion defense).
53    #[error("algorithm {alg:?} not permitted for kid {kid:?}")]
54    AlgorithmRejected {
55        /// Stated algorithm.
56        alg: String,
57        /// Key id.
58        kid: String,
59    },
60    /// Signature invalid / expired / nbf future / wrong audience — collapsed
61    /// by jsonwebtoken; the detail string preserves the cause.
62    #[error("verification failed: {0}")]
63    Verification(String),
64    /// `exp - iat` exceeds the 15-minute NHI cap.
65    #[error("ttl {0}s exceeds the {MAX_TTL_SECS}s NHI cap")]
66    TtlTooLong(u64),
67    /// `exp ≤ iat` or other timestamp nonsense.
68    #[error("inconsistent timestamps (iat {iat}, exp {exp})")]
69    BadTimestamps {
70        /// Issued-at.
71        iat: u64,
72        /// Expiry.
73        exp: u64,
74    },
75    /// `iat` is unreasonably far in the future.
76    #[error("issued-at timestamp {iat} is in the future (now {now})")]
77    FutureIat {
78        /// Issued-at claim.
79        iat: u64,
80        /// Validator wall clock.
81        now: u64,
82    },
83    /// Token expired — with enough context to tell a stale token from a
84    /// skewed validator clock. Round-106/107 drills: a +6h host rejects
85    /// every honest client with a bare "ExpiredSignature", sending the
86    /// operator hunting client TTLs instead of their own NTP. An
87    /// "expiry" further in the past than the maximum issuable TTL can
88    /// only mean the validating host's clock is ahead.
89    #[error(
90        "token expired: exp {exp}, validator now {now} ({past_s}s past expiry{hint})",
91        past_s = now.saturating_sub(*exp),
92        hint = if now.saturating_sub(*exp) > MAX_TTL_SECS {
93            " — longer than the maximum token TTL; this host's clock is likely ahead (check NTP)"
94        } else {
95            ""
96        }
97    )]
98    Expired {
99        /// Expiry claim (signature-verified before extraction).
100        exp: u64,
101        /// Validator wall clock.
102        now: u64,
103    },
104    /// Required identity field empty.
105    #[error("empty identity field {0}")]
106    EmptyField(&'static str),
107    /// Identity field longer than its documented cap.
108    #[error("identity field {field} exceeds {max} characters")]
109    FieldTooLong {
110        /// Field name.
111        field: &'static str,
112        /// Documented cap in Unicode code points.
113        max: usize,
114    },
115    /// Field carries a bidi override or zero-width character that would
116    /// spoof how the identity renders in a log or audit view.
117    #[error("identity field {0} carries a bidi/zero-width spoofing character")]
118    SpoofingCharacter(&'static str),
119    /// Issuer not in the allowlist.
120    #[error("issuer {0:?} not allowed")]
121    BadIssuer(String),
122    /// JWKS document was malformed or contained no supported signing keys.
123    #[error("invalid JWKS: {0}")]
124    Jwks(String),
125    /// Child scopes exceed parent scopes.
126    #[error("scope escalation: {scope:?} not granted by parent")]
127    ScopeEscalation {
128        /// The offending scope.
129        scope: String,
130    },
131    /// Child outlives parent.
132    #[error("child exp {child} outlives parent exp {parent}")]
133    ExpEscalation {
134        /// Child expiry.
135        child: u64,
136        /// Parent expiry.
137        parent: u64,
138    },
139    /// Child claims to be issued before its parent (the delegator).
140    /// A backdated child forges audit-trail causality: consumers that
141    /// treat `claims.iat` as "when this identity became authorized"
142    /// see the child asserting authority before the parent that
143    /// granted it existed.
144    #[error("child iat {child} predates parent iat {parent}")]
145    IatEscalation {
146        /// Child issued-at.
147        child: u64,
148        /// Parent issued-at.
149        parent: u64,
150    },
151    /// Child's effective start (`nbf` when set, else `iat`) is
152    /// earlier than the parent's effective start (`nbf` when set,
153    /// else `iat`) — same forgery class as `IatEscalation`. The
154    /// carried `child` and `parent` values are the compared
155    /// EFFECTIVE starts, not the raw `nbf` fields (which may be
156    /// absent). See `IdentityValidator::validate` for the full
157    /// case matrix.
158    #[error("child effective-start {child} predates parent effective-start {parent}")]
159    NbfEscalation {
160        /// Child effective start: `child.nbf` when set, else
161        /// `child.iat`. The fallback closes an input case where the
162        /// child omits `nbf` but its `iat` predates the parent's
163        /// explicit `nbf` — a temporal-inversion forgery an earlier
164        /// version of this guard silently accepted.
165        child: u64,
166        /// Parent effective start: `parent.nbf` when set, else
167        /// `parent.iat`.
168        parent: u64,
169    },
170    /// Delegation chain deeper than permitted.
171    #[error("delegation chain deeper than {0}")]
172    ChainTooDeep(usize),
173    /// Token's JTI (or the JTI of a token in its delegation chain) appears
174    /// in the revocation store.
175    #[error("token {0:?} has been revoked")]
176    Revoked(String),
177    /// Tokens issued by this instance at or before the cutoff were revoked.
178    #[error("instance {instance_uid:?} revoked through {cutoff}")]
179    InstanceRevoked {
180        /// Revoked instance identifier.
181        instance_uid: String,
182        /// Inclusive issued-at cutoff.
183        cutoff: u64,
184    },
185    /// The revocation store could not be read. The token is refused
186    /// because it might be revoked; this is an availability fault, not a
187    /// credential fault.
188    #[error("revocation list unavailable: {0}")]
189    RevocationUnavailable(String),
190    /// The token's `sub` does not match the configured human-principal
191    /// pattern, so the identity is not attributable to a human.
192    #[error("token subject is not a human principal")]
193    SubjectNotHuman,
194    /// The human-principal pattern itself is invalid.
195    #[error("invalid human subject pattern: {0}")]
196    SubjectPattern(String),
197}
198
199/// A successfully validated identity.
200#[derive(Debug, Clone)]
201pub struct ValidatedIdentity {
202    /// The leaf token's claims.
203    pub claims: NhiClaims,
204    /// Number of delegation links above the leaf (0 = root token).
205    pub chain_depth: usize,
206    /// Validated tokens in the delegation chain, leaf first.
207    pub chain_tokens: Vec<RevocationIdentity>,
208    /// Total delegation depth including both `parent_token` chain links
209    /// and `act` claim nesting.
210    pub total_delegation_depth: usize,
211    /// Seconds of TTL remaining at validation time.
212    pub ttl_remaining_s: u64,
213}
214
215impl ValidatedIdentity {
216    /// The agent identity block to bind into emitted events (Module D → E).
217    pub fn agent_identity(&self) -> av_events::AgentIdentity {
218        av_events::AgentIdentity {
219            version: self.claims.version.clone(),
220            charter: self.claims.charter.clone().into(),
221            instance_uid: self.claims.instance_uid.clone(),
222            ttl_remaining_s: Some(self.ttl_remaining_s),
223        }
224    }
225}
226
227/// The validator: keyed by `kid`, audience-bound, and optionally
228/// issuer-allowlisted (opt in via [`IdentityValidator::allow_issuers`];
229/// with no allowlist configured, any issuer is accepted).
230pub struct IdentityValidator {
231    keys: RwLock<HashMap<String, KeyMaterial>>,
232    jwks_kids: RwLock<HashSet<String>>,
233    audience: String,
234    allowed_issuers: Option<Vec<String>>,
235    max_chain_depth: usize,
236    leeway_secs: u64,
237    revocation: Option<std::sync::Arc<dyn crate::revocation::RevocationStore>>,
238    human_subject: Option<regex::Regex>,
239}
240
241impl IdentityValidator {
242    /// Create a validator for `audience`.
243    pub fn new(audience: impl Into<String>) -> Self {
244        Self {
245            keys: RwLock::new(HashMap::new()),
246            jwks_kids: RwLock::new(HashSet::new()),
247            audience: audience.into(),
248            allowed_issuers: None,
249            max_chain_depth: 4,
250            leeway_secs: DEFAULT_LEEWAY_SECS,
251            revocation: None,
252            human_subject: None,
253        }
254    }
255
256    /// Require every validated token's `sub` to match `pattern`, so each
257    /// agent identity is attributable to a human principal (for example
258    /// `^user:` or an identity provider's user-id prefix). Anchor the
259    /// pattern: it is matched with `is_match`, anywhere in `sub`.
260    pub fn set_human_subject_pattern(&mut self, pattern: &str) -> Result<(), IdentityError> {
261        let compiled = regex::RegexBuilder::new(pattern)
262            .size_limit(1 << 20)
263            .build()
264            .map_err(|error| IdentityError::SubjectPattern(error.to_string()))?;
265        self.human_subject = Some(compiled);
266        Ok(())
267    }
268
269    /// True when `sub` satisfies the human-principal rule (always true
270    /// when no pattern is configured).
271    pub fn subject_is_human(&self, sub: &str) -> bool {
272        self.human_subject
273            .as_ref()
274            .is_none_or(|pattern| pattern.is_match(sub))
275    }
276
277    /// Register key material under a `kid`.
278    ///
279    /// Refuse to shadow a JWKS-tracked kid. Without
280    /// this guard, an ordering hazard silently discarded operator
281    /// intent: if `add_key("X", …)` was called for a kid `X` that
282    /// a prior `add_jwks` had installed, the manual entry would
283    /// overwrite the JWKS one — but `jwks_kids` still contained
284    /// `X`, so the *next* `add_jwks` drain would remove `X` and
285    /// reinstall the JWKS version, silently discarding the
286    /// operator's manual key. `add_key` is normally a startup
287    /// call, but nothing in the API constrained late/admin use.
288    pub fn add_key(&self, kid: impl Into<String>, key: KeyMaterial) -> Result<(), IdentityError> {
289        let kid = kid.into();
290        // Lock order matches `add_jwks` (keys before jwks_kids) and the
291        // keys lock is held across the check AND the insert: releasing
292        // between them let a concurrent `add_jwks` install kid X in the
293        // gap, after which this insert silently shadowed the JWKS entry —
294        // exactly the ordering hazard this guard exists to refuse.
295        let mut loaded = self.keys.write();
296        let prior = self.jwks_kids.read();
297        if prior.contains(&kid) {
298            return Err(IdentityError::Jwks(format!(
299                "manual kid {kid:?} conflicts with a JWKS-tracked kid; rotate JWKS first"
300            )));
301        }
302        drop(prior);
303        loaded.insert(kid, key);
304        Ok(())
305    }
306
307    /// Add all supported Ed25519 keys from a standard JWKS document. Keys
308    /// loaded by a *previous* `add_jwks` call are retired (replace
309    /// semantics, so IdP rotation drops superseded JWKS keys); keys
310    /// registered manually via `add_key` are left untouched, and a JWKS
311    /// entry whose `kid` collides with a manually-registered key is
312    /// refused so the manual key stays authoritative.
313    pub fn add_jwks(&self, document: &serde_json::Value) -> Result<usize, IdentityError> {
314        // Cap the total number of
315        // entries iterated (parsed OR skipped), so a hostile JWKS
316        // full of RSA/EC decoys with a handful of legitimate OKP
317        // keys cannot stall the parse loop just by inflating the
318        // `keys` array to tens of thousands of entries. An
319        // earlier fix only capped parsed OKP entries, so a JWKS
320        // with 40k `kty=RSA` decoys still walked the whole array.
321        // Cap the outer array up front at the same threshold, and
322        // keep the inner cap as defense-in-depth against a future
323        // parser that stops short-circuiting on non-Ed25519 entries.
324        const MAX_JWKS_KEYS: usize = 256;
325        let keys = document
326            .get("keys")
327            .and_then(serde_json::Value::as_array)
328            .ok_or_else(|| IdentityError::Jwks("missing keys array".to_owned()))?;
329        if keys.len() > MAX_JWKS_KEYS {
330            return Err(IdentityError::Jwks(format!(
331                "JWKS keys array carries {} entries; refusing to walk more than {MAX_JWKS_KEYS} (this cap fires before the inner parser regardless of `kty`)",
332                keys.len()
333            )));
334        }
335        let mut parsed = Vec::new();
336        // Refuse duplicate `kid` within a single JWKS.
337        // HashMap's insert-with-overwrite semantics would otherwise
338        // silently accept a poisoned refresh where an attacker mixes
339        // an alien public key with the same kid as a legitimate one —
340        // the array's LAST entry silently wins verification with no
341        // log line, no counter, no error.
342        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
343        for key in keys {
344            if key.get("kty").and_then(serde_json::Value::as_str) != Some("OKP")
345                || key.get("crv").and_then(serde_json::Value::as_str) != Some("Ed25519")
346            {
347                continue;
348            }
349            // Respect RFC 7517 §4.2/§4.4. A JWK's
350            // `use` (public key use) member — when present — MUST
351            // be "sig" for verification material; "enc" keys are
352            // encryption-only and MUST NOT be installed for
353            // signature verification. Similarly `alg` — when
354            // present — MUST identify the algorithm intended for
355            // this key. For OKP/Ed25519 that's exclusively
356            // "EdDSA" (RFC 8037 §3.1). An IdP that ships an
357            // encryption-only key with a signing-domain kid
358            // (misconfig or partial compromise) would otherwise
359            // be silently installed as a verifier. Signature
360            // correctness is still protected by the alg/kty
361            // check at verify time, so this is defence-in-depth
362            // rather than a direct forgery close, but it aligns
363            // with the IdP's stated policy so audits can rely on
364            // it.
365            let kid_for_diag = key
366                .get("kid")
367                .and_then(serde_json::Value::as_str)
368                .unwrap_or("<missing>");
369            if let Some(use_) = key.get("use") {
370                if use_.as_str() != Some("sig") {
371                    return Err(IdentityError::Jwks(format!(
372                        "kid {kid_for_diag:?} declares use={use_:?}; only \"sig\" is accepted"
373                    )));
374                }
375            }
376            if let Some(alg) = key.get("alg") {
377                if alg.as_str() != Some("EdDSA") {
378                    return Err(IdentityError::Jwks(format!(
379                        "kid {kid_for_diag:?} declares alg={alg:?}; only \"EdDSA\" is accepted for OKP/Ed25519"
380                    )));
381                }
382            }
383            if let Some(operations) = key.get("key_ops") {
384                let Some(operations) = operations.as_array() else {
385                    return Err(IdentityError::Jwks(format!(
386                        "kid {kid_for_diag:?} key_ops must be an array"
387                    )));
388                };
389                let mut seen_operations = HashSet::new();
390                if operations.iter().any(|operation| {
391                    operation
392                        .as_str()
393                        .is_none_or(|operation| !seen_operations.insert(operation))
394                }) || !seen_operations.contains("verify")
395                {
396                    return Err(IdentityError::Jwks(format!(
397                        "kid {kid_for_diag:?} key_ops must contain unique strings and permit verify"
398                    )));
399                }
400            }
401            let kid = key
402                .get("kid")
403                .and_then(serde_json::Value::as_str)
404                .filter(|value| !value.is_empty())
405                .ok_or_else(|| IdentityError::Jwks("Ed25519 key missing kid".to_owned()))?;
406            if !seen.insert(kid.to_owned()) {
407                return Err(IdentityError::Jwks(format!(
408                    "duplicate kid {kid:?} in JWKS document; refusing to accept a poisoned key set"
409                )));
410            }
411            let x = key
412                .get("x")
413                .and_then(serde_json::Value::as_str)
414                .filter(|value| !value.is_empty())
415                .ok_or_else(|| IdentityError::Jwks(format!("key {kid:?} missing x")))?;
416            // Shape-check the key at load time. Without
417            // this, a malformed base64url `x` (e.g., "!!!") or a
418            // wrong-length pubkey installs successfully, then every
419            // JWT with that kid fails at verify time with a confusing
420            // "bad JWK for kid" error. Rejecting during `add_jwks`
421            // surfaces operator config errors at boot/JWKS refresh
422            // instead of silently poisoning every downstream request.
423            let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD
424                .decode(x)
425                .map_err(|error| {
426                    IdentityError::Jwks(format!("key {kid:?} has malformed base64url `x`: {error}"))
427                })?;
428            if decoded.len() != 32 {
429                return Err(IdentityError::Jwks(format!(
430                    "key {kid:?} `x` decodes to {} bytes; Ed25519 public keys must be exactly 32",
431                    decoded.len()
432                )));
433            }
434            parsed.push((kid.to_owned(), KeyMaterial::Ed25519Jwk(x.to_owned())));
435            // The outer `keys.len() > MAX_JWKS_KEYS`
436            // guard at the top of the function already bounds
437            // `parsed.len()` (parsed is a subset of the outer
438            // array). This inner check is therefore unreachable in
439            // practice — kept as defense-in-depth so a future
440            // refactor that drops the outer guard cannot silently
441            // reopen the write-lock stall vector.
442            debug_assert!(
443                parsed.len() <= MAX_JWKS_KEYS,
444                "outer keys.len() cap should have already refused this document"
445            );
446            if parsed.len() > MAX_JWKS_KEYS {
447                return Err(IdentityError::Jwks(format!(
448                    "JWKS declares more than {MAX_JWKS_KEYS} Ed25519 OKP keys; refusing to install"
449                )));
450            }
451        }
452        if parsed.is_empty() {
453            // An explicitly-EMPTY `keys` array is authoritative
454            // revocation: the key owner withdrew every signing key
455            // (compromise response). The old blanket error here made
456            // revocation impossible — every refresh failed and the
457            // cached keys verified compromised-key tokens forever.
458            // Retiring the JWKS-tracked keys fails closed: with no
459            // usable keys, every token is refused until the endpoint
460            // publishes keys again. A NON-empty document with zero
461            // usable Ed25519 OKP entries stays an error (that shape is
462            // a misconfigured endpoint — RSA-only, malformed — not a
463            // deliberate withdrawal), so a broken rotation keeps the
464            // last-known-good keys rather than dropping enforcement
465            // into refuse-everything.
466            if keys.is_empty() {
467                let mut loaded = self.keys.write();
468                let mut prior = self.jwks_kids.write();
469                for kid in prior.drain() {
470                    loaded.remove(&kid);
471                }
472                return Ok(0);
473            }
474            return Err(IdentityError::Jwks("no Ed25519 OKP keys found".to_owned()));
475        }
476        let mut loaded = self.keys.write();
477        let mut prior = self.jwks_kids.write();
478        // A manually-added key must not be silently converted to a
479        // JWKS-tracked entry: without this refusal, the next JWKS refresh
480        // that no longer carries the colliding kid would retire (delete)
481        // an operator-configured key. Report the conflict — the operator
482        // can rename either side.
483        for (kid, _) in &parsed {
484            if loaded.contains_key(kid) && !prior.contains(kid) {
485                return Err(IdentityError::Jwks(format!(
486                    "JWKS kid {kid:?} conflicts with a manually-registered key; rename one"
487                )));
488            }
489        }
490        for kid in prior.drain() {
491            loaded.remove(&kid);
492        }
493        for (kid, material) in &parsed {
494            loaded.insert(kid.clone(), material.clone());
495            prior.insert(kid.clone());
496        }
497        Ok(parsed.len())
498    }
499
500    /// Number of verification keys currently loaded.
501    pub fn key_count(&self) -> usize {
502        self.keys.read().len()
503    }
504
505    /// Restrict accepted issuers.
506    pub fn allow_issuers(&mut self, issuers: Vec<String>) {
507        self.allowed_issuers = Some(issuers);
508    }
509
510    /// Override the delegation-depth cap (default 4).
511    pub fn set_max_chain_depth(&mut self, depth: usize) {
512        self.max_chain_depth = depth;
513    }
514
515    /// Clock tolerance used when checking token timestamps.
516    pub fn leeway_secs(&self) -> u64 {
517        self.leeway_secs
518    }
519
520    /// Attach a JTI revocation store. When set, the `jti` of every
521    /// validated token, and of every token in its delegation chain, is
522    /// checked against the store; revoked tokens are refused with
523    /// `IdentityError::Revoked`.
524    pub fn set_revocation_store(&mut self, store: std::sync::Arc<dyn crate::revocation::RevocationStore>) {
525        self.revocation = Some(store);
526    }
527
528    /// The attached revocation store, so a revocation endpoint writes to
529    /// the same list this validator reads.
530    pub fn revocation_store(&self) -> Option<&std::sync::Arc<dyn crate::revocation::RevocationStore>> {
531        self.revocation.as_ref()
532    }
533
534    /// Must run only after the token's signature verified, so an attacker
535    /// cannot probe the revocation list with unsigned tokens.
536    fn check_not_revoked(&self, claims: &NhiClaims) -> Result<(), IdentityError> {
537        let Some(store) = &self.revocation else {
538            return Ok(());
539        };
540        match store.check_token(&claims.iss, &claims.jti, &claims.instance_uid, claims.iat) {
541            Ok(None) => Ok(()),
542            Ok(Some(RevokedBy::Jti)) => Err(IdentityError::Revoked(claims.jti.clone())),
543            Ok(Some(RevokedBy::Instance { cutoff })) => Err(IdentityError::InstanceRevoked {
544                instance_uid: claims.instance_uid.clone(),
545                cutoff,
546            }),
547            Err(error) => Err(IdentityError::RevocationUnavailable(error)),
548        }
549    }
550
551    /// Validate a token and its full delegation chain.
552    pub fn validate(&self, token: &str) -> Result<ValidatedIdentity, IdentityError> {
553        self.validate_inner(token, false)
554    }
555
556    /// Authenticate a holder revocation request, including an already-revoked
557    /// token or a token scheduled within the next maximum token lifetime.
558    /// Future activation is allowed only for revocation; it never grants access.
559    pub fn validate_for_revocation(&self, token: &str) -> Result<ValidatedIdentity, IdentityError> {
560        self.validate_inner(token, true)
561    }
562
563    fn validate_inner(&self, token: &str, for_revocation: bool) -> Result<ValidatedIdentity, IdentityError> {
564        let leaf = self.validate_single(token, for_revocation)?;
565        if !for_revocation {
566            self.check_not_revoked(&leaf)?;
567            // Holder revocation stays possible for any token; only access
568            // requires a human-attributable subject.
569            if !self.subject_is_human(&leaf.sub) {
570                return Err(IdentityError::SubjectNotHuman);
571            }
572        }
573        let mut chain_tokens = vec![RevocationIdentity::from(&leaf)];
574
575        // Check act claim nesting depth against the same cap as parent_token
576        // chains. The total delegation depth = parent_token links + act nesting.
577        let mut act_depth = leaf.act.as_ref().map_or(0, |a| 1 + a.depth());
578        if act_depth > self.max_chain_depth {
579            return Err(IdentityError::ChainTooDeep(self.max_chain_depth));
580        }
581
582        let mut depth = 0usize;
583        let mut child = leaf.clone();
584        let mut parent_token = leaf.parent_token.clone();
585        while let Some(pt) = parent_token {
586            depth += 1;
587            let total = depth + act_depth;
588            if total > self.max_chain_depth {
589                return Err(IdentityError::ChainTooDeep(self.max_chain_depth));
590            }
591            let parent = self.validate_single(&pt, for_revocation)?;
592            act_depth += parent.act.as_ref().map_or(0, |a| 1 + a.depth());
593            if depth + act_depth > self.max_chain_depth {
594                return Err(IdentityError::ChainTooDeep(self.max_chain_depth));
595            }
596            chain_tokens.push(RevocationIdentity::from(&parent));
597            // Revoking a delegator must cut off every token it delegated.
598            if !for_revocation {
599                self.check_not_revoked(&parent)?;
600            }
601            // Scope inheritance: child ⊆ parent, with the SAME wildcard
602            // semantics the harness's runtime authorization uses
603            // (`av-harness::pipeline::scope_allows`). Do not use
604            // exact-string equality here: it rejected legitimately-delegated
605            // narrower scopes when the parent held a wildcard — e.g.,
606            // parent `["tool:*"]`, child `["tool:db_write"]` was refused
607            // as `ScopeEscalation("tool:db_write")` even though the
608            // runtime gate would authorize it, so the delegation
609            // machinery was strictly stricter than the authorization
610            // machinery. That asymmetry made wildcard-parent tokens
611            // effectively unusable for narrowing delegation, the
612            // canonical use case for scope subsetting.
613            if let Some(escalated) = child
614                .scopes
615                .iter()
616                .find(|scope| !scope_covered_by(scope, &parent.scopes))
617            {
618                return Err(IdentityError::ScopeEscalation {
619                    scope: escalated.clone(),
620                });
621            }
622            // Child must not outlive parent.
623            if child.exp > parent.exp {
624                return Err(IdentityError::ExpEscalation {
625                    child: child.exp,
626                    parent: parent.exp,
627                });
628            }
629            // Child must not be backdated before its parent. A
630            // hostile HMAC-shared-secret holder could otherwise mint
631            // a child with `iat` up to MAX_TTL_SECS BEFORE the parent
632            // that "delegated" it — asserting authorization causality
633            // that never happened. Consumers that treat `claims.iat`
634            // as "when this identity became authorized" (audit
635            // pipelines, revocation-cache pruning that keys eviction
636            // horizons on `iat + MAX_TTL_SECS`) are then lied to.
637            if child.iat < parent.iat {
638                return Err(IdentityError::IatEscalation {
639                    child: child.iat,
640                    parent: parent.iat,
641                });
642            }
643            // Same posture for `nbf`: a child cannot become usable
644            // before the parent that granted it did. Compare
645            // EFFECTIVE start times uniformly — fall back to `iat`
646            // on either side that omits `nbf`. An earlier version
647            // of this block was guarded by
648            // `if let Some(child_nbf) = child.nbf`, so a child that
649            // OMITTED `nbf` sailed past the check even when its raw
650            // `iat` predated the parent's explicit `nbf`: e.g.
651            // `parent{iat=t0, nbf=t0+15}` +
652            // `child{iat=t0+10, nbf=None}` was accepted, though the
653            // child asserts authority 5 seconds before the parent
654            // grant became active. The `IatEscalation` check above
655            // fires only for `child.iat < parent.iat` and does not
656            // cover this gap. Consumers treating `claims.iat` (or
657            // `nbf`) as "when this identity became authorized" saw
658            // an inverted causal ordering.
659            let parent_start = parent.nbf.unwrap_or(parent.iat);
660            let child_start = child.nbf.unwrap_or(child.iat);
661            if child_start < parent_start {
662                return Err(IdentityError::NbfEscalation {
663                    child: child_start,
664                    parent: parent_start,
665                });
666            }
667            parent_token = parent.parent_token.clone();
668            child = parent;
669        }
670        // For tokens with no parent_token chain, still check act depth alone.
671        if depth == 0 && act_depth > self.max_chain_depth {
672            return Err(IdentityError::ChainTooDeep(self.max_chain_depth));
673        }
674        let total = depth + act_depth;
675        let now_s = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
676        Ok(ValidatedIdentity {
677            ttl_remaining_s: leaf.exp.saturating_sub(now_s),
678            chain_depth: depth,
679            chain_tokens,
680            total_delegation_depth: total,
681            claims: leaf,
682        })
683    }
684
685    /// Validate one JWT, in order: pre-auth 8 KiB size cap, header sanity,
686    /// kid lookup, alg/key-type match, signature,
687    /// exp/aud/sub/iss required (+ `nbf` when present), `exp > iat`
688    /// consistency, future-iat, TTL cap, field presence,
689    /// bidi/zero-width spoofing guard, issuer allowlist (when configured).
690    fn validate_single(&self, token: &str, for_revocation: bool) -> Result<NhiClaims, IdentityError> {
691        // Reject oversized tokens up front so an unauthenticated caller
692        // cannot amplify their pre-auth memory footprint through
693        // `jsonwebtoken::decode_header`, which base64-decodes the
694        // header segment before signature verification. RFC-realistic
695        // NHI JWTs are at most a few KiB; 8 KiB is a comfortable
696        // ceiling that blocks the amplification while accepting real
697        // tokens with generous claim sets.
698        const MAX_JWT_BYTES: usize = 8 * 1024;
699        if token.len() > MAX_JWT_BYTES {
700            return Err(IdentityError::Malformed(format!(
701                "token is {} bytes, exceeds pre-auth cap of {MAX_JWT_BYTES}",
702                token.len()
703            )));
704        }
705        let header =
706            jsonwebtoken::decode_header(token).map_err(|e| IdentityError::Malformed(e.to_string()))?;
707        validate_jose_header(&header)?;
708        // Harness tool and intent credentials are separate profiles, even
709        // if an operator also registers the harness key in the IdP key set.
710        if matches!(header.typ.as_deref(), Some("av-tool+jwt" | "av-intent+jwt")) {
711            return Err(IdentityError::Verification(
712                "backend token profile is not an inbound identity".into(),
713            ));
714        }
715        let kid = header.kid.ok_or(IdentityError::MissingKid)?;
716        let keys = self.keys.read();
717        let key = keys
718            .get(&kid)
719            .ok_or_else(|| IdentityError::UnknownKid(kid.clone()))?;
720
721        // Algorithm-confusion defense: the key's type dictates the only
722        // acceptable alg; the token's stated alg must equal it exactly.
723        let (expected_alg, decoding_key) = match key {
724            KeyMaterial::Ed25519Pem(pem) => (
725                Algorithm::EdDSA,
726                DecodingKey::from_ed_pem(pem.as_bytes())
727                    .map_err(|e| IdentityError::Malformed(format!("bad key for kid {kid}: {e}")))?,
728            ),
729            KeyMaterial::Ed25519Jwk(x) => (
730                Algorithm::EdDSA,
731                DecodingKey::from_ed_components(x)
732                    .map_err(|e| IdentityError::Malformed(format!("bad JWK for kid {kid}: {e}")))?,
733            ),
734            KeyMaterial::HmacSecret(secret) => (Algorithm::HS256, DecodingKey::from_secret(secret)),
735        };
736        if header.alg != expected_alg {
737            return Err(IdentityError::AlgorithmRejected {
738                alg: format!("{:?}", header.alg),
739                kid,
740            });
741        }
742
743        let mut validation = Validation::new(expected_alg);
744        validation.set_audience(std::slice::from_ref(&self.audience));
745        validation.set_required_spec_claims(&["exp", "aud", "sub", "iss"]);
746        validation.leeway = self.leeway_secs;
747        validation.validate_nbf = !for_revocation;
748
749        let data = jsonwebtoken::decode::<NhiClaims>(token, &decoding_key, &validation).map_err(|e| {
750            // Round-107: on expiry, re-decode with the SAME key and
751            // audience but exp validation off — signature integrity
752            // still enforced — purely to recover exp for the error.
753            // The token is still rejected either way; this only
754            // upgrades diagnosability (skewed-host vs stale-token).
755            if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::ExpiredSignature) {
756                let mut relaxed = validation.clone();
757                relaxed.validate_exp = false;
758                if let Ok(peek) = jsonwebtoken::decode::<NhiClaims>(token, &decoding_key, &relaxed) {
759                    let now_s = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
760                    return IdentityError::Expired {
761                        exp: peek.claims.exp,
762                        now: now_s,
763                    };
764                }
765            }
766            IdentityError::Verification(e.to_string())
767        })?;
768        let claims = data.claims;
769
770        let now_s = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
771        validate_claim_fields(&claims, now_s, self.leeway_secs, for_revocation)?;
772        if let Some(allowed) = &self.allowed_issuers {
773            if !allowed.contains(&claims.iss) {
774                return Err(IdentityError::BadIssuer(claims.iss));
775            }
776        }
777        Ok(claims)
778    }
779}
780
781/// Neither credential profile implements JOSE extensions or unencoded payloads.
782/// jsonwebtoken parses these headers but does not enforce their semantics.
783pub(crate) fn validate_jose_header(header: &jsonwebtoken::Header) -> Result<(), IdentityError> {
784    if header.crit.is_some() || !matches!(header.extras.get::<bool>("b64"), Ok(None | Some(true))) {
785        return Err(IdentityError::Verification(
786            "unsupported critical JWT header or unencoded payload".into(),
787        ));
788    }
789    Ok(())
790}
791
792/// Structural checks shared by inbound and harness-issued tokens after signature verification.
793pub(crate) fn validate_claim_fields(
794    claims: &NhiClaims,
795    now_s: u64,
796    leeway_secs: u64,
797    for_revocation: bool,
798) -> Result<(), IdentityError> {
799    if claims.exp <= claims.iat {
800        return Err(IdentityError::BadTimestamps {
801            iat: claims.iat,
802            exp: claims.exp,
803        });
804    }
805    let future_bound = now_s
806        .saturating_add(leeway_secs)
807        .saturating_add(if for_revocation { MAX_TTL_SECS } else { 0 });
808    if claims.iat > future_bound || (for_revocation && claims.nbf.is_some_and(|nbf| nbf > future_bound)) {
809        return Err(IdentityError::FutureIat {
810            iat: claims.iat,
811            now: now_s,
812        });
813    }
814    let ttl = claims.exp - claims.iat;
815    if ttl > MAX_TTL_SECS {
816        return Err(IdentityError::TtlTooLong(ttl));
817    }
818    if claims.iss.is_empty() {
819        return Err(IdentityError::EmptyField("iss"));
820    }
821    if claims.sub.is_empty() {
822        return Err(IdentityError::EmptyField("sub"));
823    }
824    if claims.jti.is_empty() {
825        return Err(IdentityError::EmptyField("jti"));
826    }
827    if claims.instance_uid.is_empty() {
828        return Err(IdentityError::EmptyField("instance_uid"));
829    }
830    // Administrative revocation accepts these exact identifier constraints.
831    // Admitting a control-bearing identifier here would create credentials
832    // that the operator endpoint cannot name when responding to an incident.
833    for (field, value) in [
834        ("jti", claims.jti.as_str()),
835        ("instance_uid", claims.instance_uid.as_str()),
836    ] {
837        if value.trim().is_empty() {
838            return Err(IdentityError::EmptyField(field));
839        }
840        if value.chars().any(char::is_control) {
841            return Err(IdentityError::Malformed(format!(
842                "identity field {field} contains a control character"
843            )));
844        }
845    }
846    if claims.charter.is_empty() {
847        return Err(IdentityError::EmptyField("charter"));
848    }
849    if claims.version.is_empty() {
850        return Err(IdentityError::EmptyField("version"));
851    }
852    // docs/reference/LIMITS.md documents a 256-code-point charter cap
853    // ("longer are refused with 400"); the same reasoning applies to
854    // EVERY identity string that flows into logs, receipts, and
855    // event chains — an unbounded (up to the ~7 KiB per-claim JWT
856    // budget) attacker-chosen field is log-spam surface and — since
857    // instance_uid/version bind into every SIGNED receipt — bloats
858    // the JCS-canonicalized signing input on every request. Cap all
859    // identity strings at the same limit for one consistent rule.
860    // Threat model: an HMAC-shared-secret deployment where multiple
861    // principals hold the identity signing key. Any of them can
862    // construct a valid JWT with hostile-length claims.
863    // instance_uid has a TIGHTER cap than the other identity
864    // strings: it flows verbatim into `ai_agent.instance_uid` on
865    // every OCSF event and signed receipt, and the shipped schemas
866    // (ocsf-agent-event, receipt-v2) pin it at maxLength 128 —
867    // matching `av_core::InstanceUid::parse`. A 129–256-char uid
868    // admitted here would build events/receipts that Rust-side
869    // validation accepts but every external schema verifier (and
870    // the console ingest) refuses — the split-verdict class.
871    const MAX_INSTANCE_UID_CHARS: usize = 128;
872    if claims.instance_uid.chars().count() > MAX_INSTANCE_UID_CHARS {
873        return Err(IdentityError::FieldTooLong {
874            field: "instance_uid",
875            max: MAX_INSTANCE_UID_CHARS,
876        });
877    }
878    // azp: if present, must be non-empty and within length limits.
879    if let Some(azp) = &claims.azp {
880        if azp.is_empty() {
881            return Err(IdentityError::EmptyField("azp"));
882        }
883    }
884    // act: if present, each nested actor sub must be non-empty.
885    if let Some(act) = &claims.act {
886        validate_actor_chain(act)?;
887    }
888    let mut identity_fields: Vec<(&str, &str)> = vec![
889        ("instance_uid", claims.instance_uid.as_str()),
890        ("charter", claims.charter.as_str()),
891        ("version", claims.version.as_str()),
892        ("sub", claims.sub.as_str()),
893        ("iss", claims.iss.as_str()),
894        ("jti", claims.jti.as_str()),
895    ];
896    if let Some(azp) = &claims.azp {
897        identity_fields.push(("azp", azp.as_str()));
898    }
899    for (name, value) in &identity_fields {
900        if value.chars().count() > MAX_IDENTITY_STRING_CHARS {
901            return Err(IdentityError::FieldTooLong {
902                field: name,
903                max: MAX_IDENTITY_STRING_CHARS,
904            });
905        }
906    }
907    // Bound the scopes array too: an unbounded list (or an
908    // individually oversized scope) is the same log-spam / receipt-
909    // bloat vector as the strings above, and the delegation-chain
910    // subset check runs a per-element comparison so a 10000-entry
911    // scopes[] amplifies delegation-verification cost per request.
912    if claims.scopes.len() > MAX_SCOPES {
913        return Err(IdentityError::FieldTooLong {
914            field: "scopes",
915            max: MAX_SCOPES,
916        });
917    }
918    for scope in &claims.scopes {
919        // Scopes are emitted as a space-delimited OAuth scope string during
920        // exchange and introspection. One array entry must never become two
921        // permissions when a backend parses that representation.
922        if scope.is_empty() || scope.chars().any(|c| c.is_whitespace() || c.is_control()) {
923            return Err(IdentityError::Malformed(
924                "scope entries must be non-empty without whitespace or controls".into(),
925            ));
926        }
927        if scope.chars().count() > MAX_IDENTITY_STRING_CHARS {
928            return Err(IdentityError::FieldTooLong {
929                field: "scopes[]",
930                max: MAX_IDENTITY_STRING_CHARS,
931            });
932        }
933    }
934    // Trojan-Source guard: any bidi override or zero-width character in
935    // a rendered identity field would spoof how it looks in operator
936    // logs, receipts, and event chains while remaining part of the raw
937    // bytes on the wire. Scopes must be guarded too — they are the
938    // most audit-prominent identity strings and were the only one
939    // omitted from the original list, an inconsistency that let a
940    // scope like `payout\u{202E}elbast` render as visually-corrupt
941    // junk in operator logs while still surviving the length cap and
942    // the scope-subset check on raw bytes.
943    let mut bidi_fields: Vec<(&str, &str)> = vec![
944        ("instance_uid", claims.instance_uid.as_str()),
945        ("charter", claims.charter.as_str()),
946        ("version", claims.version.as_str()),
947        ("sub", claims.sub.as_str()),
948        ("iss", claims.iss.as_str()),
949        ("jti", claims.jti.as_str()),
950    ];
951    if let Some(azp) = &claims.azp {
952        bidi_fields.push(("azp", azp.as_str()));
953    }
954    for (name, value) in &bidi_fields {
955        if av_core::text::contains_bidi_or_zero_width(value) {
956            return Err(IdentityError::SpoofingCharacter(name));
957        }
958    }
959    for scope in &claims.scopes {
960        if av_core::text::contains_bidi_or_zero_width(scope) {
961            return Err(IdentityError::SpoofingCharacter("scopes[]"));
962        }
963    }
964    Ok(())
965}
966
967/// Validate every node in an actor claim chain: each `sub` must be non-empty
968/// and free of bidi/zero-width spoofing characters.
969fn validate_actor_chain(actor: &crate::claims::ActorClaim) -> Result<(), IdentityError> {
970    if actor.sub.is_empty() {
971        return Err(IdentityError::EmptyField("act.sub"));
972    }
973    if av_core::text::contains_bidi_or_zero_width(&actor.sub) {
974        return Err(IdentityError::SpoofingCharacter("act.sub"));
975    }
976    const MAX_IDENTITY_STRING_CHARS: usize = 256;
977    if actor.sub.chars().count() > MAX_IDENTITY_STRING_CHARS {
978        return Err(IdentityError::FieldTooLong {
979            field: "act.sub",
980            max: MAX_IDENTITY_STRING_CHARS,
981        });
982    }
983    if let Some(inner) = &actor.act {
984        validate_actor_chain(inner)?;
985    }
986    Ok(())
987}
988
989/// Return true if `scope` is authorized by any of the `parent_scopes`,
990/// using the SAME wildcard semantics
991/// (`av-harness::pipeline::scope_allows`) applied at runtime
992/// authorization. Duplicating (rather than re-exporting) the check
993/// keeps `av-identity` free of an `av-harness` dependency; if these
994/// two ever diverge, the delegation gate becomes strictly stricter or
995/// looser than the runtime gate — exact-string matching here once
996/// rejected legitimately-delegated narrower scopes under a wildcard
997/// parent, which is the class of bug such divergence produces.
998fn scope_covered_by(scope: &str, parent_scopes: &[String]) -> bool {
999    parent_scopes.iter().any(|parent| {
1000        parent == "*"
1001            || parent == scope
1002            || parent
1003                .strip_suffix(":*")
1004                .is_some_and(|prefix| scope.starts_with(&format!("{prefix}:")))
1005    })
1006}
1007
1008#[cfg(test)]
1009mod key_material_tests {
1010    #![allow(clippy::unwrap_used)]
1011
1012    use super::KeyMaterial;
1013
1014    /// Round-19 mutation finding: the hand-written Debug impl exists
1015    /// solely so key material can never leak into logs/error chains
1016    /// ("never print secrets"), but nothing pinned it — replacing the
1017    /// whole impl with a no-op survived the suite, and so would a
1018    /// future `#[derive(Debug)]` refactor that prints the secret bytes
1019    /// verbatim into any `{:?}` log site. Pin the redaction contract.
1020    #[test]
1021    fn debug_never_prints_key_material() {
1022        const SECRET_PEM: &str = "-----BEGIN PUBLIC KEY-----abc123secretbytes";
1023        const SECRET_HMAC: &[u8] = b"hmac-shared-secret-0123456789abcdef";
1024        let cases = [
1025            (
1026                format!("{:?}", KeyMaterial::Ed25519Pem(SECRET_PEM.to_owned())),
1027                "Ed25519Pem",
1028            ),
1029            (
1030                format!("{:?}", KeyMaterial::Ed25519Jwk(SECRET_PEM.to_owned())),
1031                "Ed25519Jwk",
1032            ),
1033            (
1034                format!("{:?}", KeyMaterial::HmacSecret(SECRET_HMAC.to_vec())),
1035                "HmacSecret",
1036            ),
1037        ];
1038        for (rendered, variant) in cases {
1039            assert!(
1040                rendered.contains(variant),
1041                "Debug must still name the variant for diagnosability: {rendered}"
1042            );
1043            assert!(
1044                !rendered.contains("secret")
1045                    && !rendered.contains("abc123")
1046                    && !rendered.contains("0123456789"),
1047                "Debug leaked key material: {rendered}"
1048            );
1049        }
1050    }
1051}