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}