Skip to main content

av_identity/
validator.rs

1//! Token validation and delegation-chain verification.
2
3use crate::claims::{NhiClaims, MAX_TTL_SECS};
4use jsonwebtoken::{Algorithm, DecodingKey, Validation};
5use parking_lot::RwLock;
6use std::collections::{HashMap, HashSet};
7
8/// Verification key material, bound to a `kid`.
9#[derive(Clone)]
10pub enum KeyMaterial {
11    /// Ed25519 public key, SPKI PEM (`-----BEGIN PUBLIC KEY-----`). EdDSA.
12    Ed25519Pem(String),
13    /// Ed25519 JWK `x` coordinate, base64url without padding.
14    Ed25519Jwk(String),
15    /// HMAC shared secret. HS256 (dev / shared-secret IdP integrations).
16    HmacSecret(Vec<u8>),
17}
18
19impl std::fmt::Debug for KeyMaterial {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            Self::Ed25519Pem(_) => f.write_str("KeyMaterial::Ed25519Pem(..)"),
23            Self::Ed25519Jwk(_) => f.write_str("KeyMaterial::Ed25519Jwk(..)"),
24            Self::HmacSecret(_) => f.write_str("KeyMaterial::HmacSecret(..)"), // never print secrets
25        }
26    }
27}
28
29/// Identity validation failures.
30#[derive(Debug, thiserror::Error, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum IdentityError {
33    /// Token structurally malformed.
34    #[error("malformed token: {0}")]
35    Malformed(String),
36    /// Header lacks a `kid`.
37    #[error("token header has no kid")]
38    MissingKid,
39    /// No key registered for this `kid`.
40    #[error("unknown kid {0:?}")]
41    UnknownKid(String),
42    /// Token `alg` is not accepted, or does not match the key type for its
43    /// `kid` (algorithm-confusion defense).
44    #[error("algorithm {alg:?} not permitted for kid {kid:?}")]
45    AlgorithmRejected {
46        /// Stated algorithm.
47        alg: String,
48        /// Key id.
49        kid: String,
50    },
51    /// Signature invalid / expired / nbf future / wrong audience — collapsed
52    /// by jsonwebtoken; the detail string preserves the cause.
53    #[error("verification failed: {0}")]
54    Verification(String),
55    /// `exp - iat` exceeds the 15-minute NHI cap.
56    #[error("ttl {0}s exceeds the {MAX_TTL_SECS}s NHI cap")]
57    TtlTooLong(u64),
58    /// `exp ≤ iat` or other timestamp nonsense.
59    #[error("inconsistent timestamps (iat {iat}, exp {exp})")]
60    BadTimestamps {
61        /// Issued-at.
62        iat: u64,
63        /// Expiry.
64        exp: u64,
65    },
66    /// `iat` is unreasonably far in the future.
67    #[error("issued-at timestamp {iat} is in the future (now {now})")]
68    FutureIat {
69        /// Issued-at claim.
70        iat: u64,
71        /// Validator wall clock.
72        now: u64,
73    },
74    /// Required identity field empty.
75    #[error("empty identity field {0}")]
76    EmptyField(&'static str),
77    /// Field carries a bidi override or zero-width character that would
78    /// spoof how the identity renders in a log or audit view.
79    #[error("identity field {0} carries a bidi/zero-width spoofing character")]
80    SpoofingCharacter(&'static str),
81    /// Issuer not in the allowlist.
82    #[error("issuer {0:?} not allowed")]
83    BadIssuer(String),
84    /// JWKS document was malformed or contained no supported signing keys.
85    #[error("invalid JWKS: {0}")]
86    Jwks(String),
87    /// Child scopes exceed parent scopes.
88    #[error("scope escalation: {scope:?} not granted by parent")]
89    ScopeEscalation {
90        /// The offending scope.
91        scope: String,
92    },
93    /// Child outlives parent.
94    #[error("child exp {child} outlives parent exp {parent}")]
95    ExpEscalation {
96        /// Child expiry.
97        child: u64,
98        /// Parent expiry.
99        parent: u64,
100    },
101    /// Delegation chain deeper than permitted.
102    #[error("delegation chain deeper than {0}")]
103    ChainTooDeep(usize),
104}
105
106/// A successfully validated identity.
107#[derive(Debug, Clone)]
108pub struct ValidatedIdentity {
109    /// The leaf token's claims.
110    pub claims: NhiClaims,
111    /// Number of delegation links above the leaf (0 = root token).
112    pub chain_depth: usize,
113    /// Seconds of TTL remaining at validation time.
114    pub ttl_remaining_s: u64,
115}
116
117impl ValidatedIdentity {
118    /// The agent identity block to bind into emitted events (Module D → E).
119    pub fn agent_identity(&self) -> av_events::AgentIdentity {
120        av_events::AgentIdentity {
121            version: self.claims.version.clone(),
122            charter: self.claims.charter.clone().into(),
123            instance_uid: self.claims.instance_uid.clone(),
124            ttl_remaining_s: Some(self.ttl_remaining_s),
125        }
126    }
127}
128
129/// The validator: keyed by `kid`, audience-bound, and optionally
130/// issuer-allowlisted (opt in via [`IdentityValidator::allow_issuers`];
131/// with no allowlist configured, any issuer is accepted).
132pub struct IdentityValidator {
133    keys: RwLock<HashMap<String, KeyMaterial>>,
134    jwks_kids: RwLock<HashSet<String>>,
135    audience: String,
136    allowed_issuers: Option<Vec<String>>,
137    max_chain_depth: usize,
138    leeway_secs: u64,
139}
140
141impl IdentityValidator {
142    /// Create a validator for `audience`.
143    pub fn new(audience: impl Into<String>) -> Self {
144        Self {
145            keys: RwLock::new(HashMap::new()),
146            jwks_kids: RwLock::new(HashSet::new()),
147            audience: audience.into(),
148            allowed_issuers: None,
149            max_chain_depth: 4,
150            leeway_secs: 30,
151        }
152    }
153
154    /// Register key material under a `kid`.
155    ///
156    /// Round-25 F2: refuse to shadow a JWKS-tracked kid. Without
157    /// this guard, an ordering hazard silently discarded operator
158    /// intent: if `add_key("X", …)` was called for a kid `X` that
159    /// a prior `add_jwks` had installed, the manual entry would
160    /// overwrite the JWKS one — but `jwks_kids` still contained
161    /// `X`, so the *next* `add_jwks` drain would remove `X` and
162    /// reinstall the JWKS version, silently discarding the
163    /// operator's manual key. `add_key` is normally a startup
164    /// call, but nothing in the API constrained late/admin use.
165    pub fn add_key(&self, kid: impl Into<String>, key: KeyMaterial) -> Result<(), IdentityError> {
166        let kid = kid.into();
167        let prior = self.jwks_kids.read();
168        if prior.contains(&kid) {
169            return Err(IdentityError::Jwks(format!(
170                "manual kid {kid:?} conflicts with a JWKS-tracked kid; rotate JWKS first"
171            )));
172        }
173        drop(prior);
174        self.keys.write().insert(kid, key);
175        Ok(())
176    }
177
178    /// Add all supported Ed25519 keys from a standard JWKS document. Keys
179    /// loaded by a *previous* `add_jwks` call are retired (replace
180    /// semantics, so IdP rotation drops superseded JWKS keys); keys
181    /// registered manually via `add_key` are left untouched, and a JWKS
182    /// entry whose `kid` collides with a manually-registered key is
183    /// refused so the manual key stays authoritative.
184    pub fn add_jwks(&self, document: &serde_json::Value) -> Result<usize, IdentityError> {
185        // Round-12 F11 + round-15 F5: cap the total number of
186        // entries iterated (parsed OR skipped), so a hostile JWKS
187        // full of RSA/EC decoys with a handful of legitimate OKP
188        // keys cannot stall the parse loop just by inflating the
189        // `keys` array to tens of thousands of entries. The
190        // round-12 fix only capped parsed OKP entries, so a JWKS
191        // with 40k `kty=RSA` decoys still walked the whole array.
192        // Cap the outer array up front at the same threshold, and
193        // keep the inner cap as defense-in-depth against a future
194        // parser that stops short-circuiting on non-Ed25519 entries.
195        const MAX_JWKS_KEYS: usize = 256;
196        let keys = document
197            .get("keys")
198            .and_then(serde_json::Value::as_array)
199            .ok_or_else(|| IdentityError::Jwks("missing keys array".to_owned()))?;
200        if keys.len() > MAX_JWKS_KEYS {
201            return Err(IdentityError::Jwks(format!(
202                "JWKS keys array carries {} entries; refusing to walk more than {MAX_JWKS_KEYS} (round-15 F5: fires before the inner parser regardless of `kty`)",
203                keys.len()
204            )));
205        }
206        let mut parsed = Vec::new();
207        // Round-12 F6: refuse duplicate `kid` within a single JWKS.
208        // HashMap's insert-with-overwrite semantics would otherwise
209        // silently accept a poisoned refresh where an attacker mixes
210        // an alien public key with the same kid as a legitimate one —
211        // the array's LAST entry silently wins verification with no
212        // log line, no counter, no error.
213        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
214        for key in keys {
215            if key.get("kty").and_then(serde_json::Value::as_str) != Some("OKP")
216                || key.get("crv").and_then(serde_json::Value::as_str) != Some("Ed25519")
217            {
218                continue;
219            }
220            // Round-25 F1: respect RFC 7517 §4.2/§4.4. A JWK's
221            // `use` (public key use) member — when present — MUST
222            // be "sig" for verification material; "enc" keys are
223            // encryption-only and MUST NOT be installed for
224            // signature verification. Similarly `alg` — when
225            // present — MUST identify the algorithm intended for
226            // this key. For OKP/Ed25519 that's exclusively
227            // "EdDSA" (RFC 8037 §3.1). An IdP that ships an
228            // encryption-only key with a signing-domain kid
229            // (misconfig or partial compromise) would otherwise
230            // be silently installed as a verifier. Signature
231            // correctness is still protected by the alg/kty
232            // check at verify time, so this is defence-in-depth
233            // rather than a direct forgery close, but it aligns
234            // with the IdP's stated policy so audits can rely on
235            // it.
236            let kid_for_diag = key
237                .get("kid")
238                .and_then(serde_json::Value::as_str)
239                .unwrap_or("<missing>");
240            if let Some(use_) = key.get("use").and_then(serde_json::Value::as_str) {
241                if use_ != "sig" {
242                    return Err(IdentityError::Jwks(format!(
243                        "kid {kid_for_diag:?} declares use={use_:?}; only \"sig\" is accepted"
244                    )));
245                }
246            }
247            if let Some(alg) = key.get("alg").and_then(serde_json::Value::as_str) {
248                if alg != "EdDSA" {
249                    return Err(IdentityError::Jwks(format!(
250                        "kid {kid_for_diag:?} declares alg={alg:?}; only \"EdDSA\" is accepted for OKP/Ed25519"
251                    )));
252                }
253            }
254            let kid = key
255                .get("kid")
256                .and_then(serde_json::Value::as_str)
257                .filter(|value| !value.is_empty())
258                .ok_or_else(|| IdentityError::Jwks("Ed25519 key missing kid".to_owned()))?;
259            if !seen.insert(kid.to_owned()) {
260                return Err(IdentityError::Jwks(format!(
261                    "duplicate kid {kid:?} in JWKS document; refusing to accept a poisoned key set"
262                )));
263            }
264            let x = key
265                .get("x")
266                .and_then(serde_json::Value::as_str)
267                .filter(|value| !value.is_empty())
268                .ok_or_else(|| IdentityError::Jwks(format!("key {kid:?} missing x")))?;
269            parsed.push((kid.to_owned(), KeyMaterial::Ed25519Jwk(x.to_owned())));
270            // Round-16 F9: the outer `keys.len() > MAX_JWKS_KEYS`
271            // guard at the top of the function already bounds
272            // `parsed.len()` (parsed is a subset of the outer
273            // array). This inner check is therefore unreachable in
274            // practice — kept as defense-in-depth so a future
275            // refactor that drops the outer guard cannot silently
276            // reopen the write-lock stall vector.
277            debug_assert!(
278                parsed.len() <= MAX_JWKS_KEYS,
279                "outer keys.len() cap should have already refused this document"
280            );
281            if parsed.len() > MAX_JWKS_KEYS {
282                return Err(IdentityError::Jwks(format!(
283                    "JWKS declares more than {MAX_JWKS_KEYS} Ed25519 OKP keys; refusing to install"
284                )));
285            }
286        }
287        if parsed.is_empty() {
288            return Err(IdentityError::Jwks("no Ed25519 OKP keys found".to_owned()));
289        }
290        let mut loaded = self.keys.write();
291        let mut prior = self.jwks_kids.write();
292        // A manually-added key must not be silently converted to a
293        // JWKS-tracked entry: without this refusal, the next JWKS refresh
294        // that no longer carries the colliding kid would retire (delete)
295        // an operator-configured key. Report the conflict — the operator
296        // can rename either side.
297        for (kid, _) in &parsed {
298            if loaded.contains_key(kid) && !prior.contains(kid) {
299                return Err(IdentityError::Jwks(format!(
300                    "JWKS kid {kid:?} conflicts with a manually-registered key; rename one"
301                )));
302            }
303        }
304        for kid in prior.drain() {
305            loaded.remove(&kid);
306        }
307        for (kid, material) in &parsed {
308            loaded.insert(kid.clone(), material.clone());
309            prior.insert(kid.clone());
310        }
311        Ok(parsed.len())
312    }
313
314    /// Number of verification keys currently loaded.
315    pub fn key_count(&self) -> usize {
316        self.keys.read().len()
317    }
318
319    /// Restrict accepted issuers.
320    pub fn allow_issuers(&mut self, issuers: Vec<String>) {
321        self.allowed_issuers = Some(issuers);
322    }
323
324    /// Override the delegation-depth cap (default 4).
325    pub fn set_max_chain_depth(&mut self, depth: usize) {
326        self.max_chain_depth = depth;
327    }
328
329    /// Validate a token and its full delegation chain.
330    pub fn validate(&self, token: &str) -> Result<ValidatedIdentity, IdentityError> {
331        let leaf = self.validate_single(token)?;
332        let mut depth = 0usize;
333        let mut child = leaf.clone();
334        let mut parent_token = leaf.parent_token.clone();
335        while let Some(pt) = parent_token {
336            depth += 1;
337            if depth > self.max_chain_depth {
338                return Err(IdentityError::ChainTooDeep(self.max_chain_depth));
339            }
340            let parent = self.validate_single(&pt)?;
341            // Scope inheritance: child ⊆ parent.
342            if let Some(escalated) = child
343                .scopes
344                .iter()
345                .find(|s| !parent.scopes.iter().any(|p| p == *s))
346            {
347                return Err(IdentityError::ScopeEscalation {
348                    scope: escalated.clone(),
349                });
350            }
351            // Child must not outlive parent.
352            if child.exp > parent.exp {
353                return Err(IdentityError::ExpEscalation {
354                    child: child.exp,
355                    parent: parent.exp,
356                });
357            }
358            parent_token = parent.parent_token.clone();
359            child = parent;
360        }
361        let now_s = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
362        Ok(ValidatedIdentity {
363            ttl_remaining_s: leaf.exp.saturating_sub(now_s),
364            chain_depth: depth,
365            claims: leaf,
366        })
367    }
368
369    /// Validate one JWT, in order: pre-auth 8 KiB size cap, header sanity,
370    /// kid lookup, alg/key-type match, signature,
371    /// exp/aud/sub/iss required (+ `nbf` when present), `exp > iat`
372    /// consistency, future-iat, TTL cap, field presence,
373    /// bidi/zero-width spoofing guard, issuer allowlist (when configured).
374    fn validate_single(&self, token: &str) -> Result<NhiClaims, IdentityError> {
375        // Reject oversized tokens up front so an unauthenticated caller
376        // cannot amplify their pre-auth memory footprint through
377        // `jsonwebtoken::decode_header`, which base64-decodes the
378        // header segment before signature verification. RFC-realistic
379        // NHI JWTs are at most a few KiB; 8 KiB is a comfortable
380        // ceiling that blocks the amplification while accepting real
381        // tokens with generous claim sets.
382        const MAX_JWT_BYTES: usize = 8 * 1024;
383        if token.len() > MAX_JWT_BYTES {
384            return Err(IdentityError::Malformed(format!(
385                "token is {} bytes, exceeds pre-auth cap of {MAX_JWT_BYTES}",
386                token.len()
387            )));
388        }
389        let header =
390            jsonwebtoken::decode_header(token).map_err(|e| IdentityError::Malformed(e.to_string()))?;
391        let kid = header.kid.ok_or(IdentityError::MissingKid)?;
392        let keys = self.keys.read();
393        let key = keys
394            .get(&kid)
395            .ok_or_else(|| IdentityError::UnknownKid(kid.clone()))?;
396
397        // Algorithm-confusion defense: the key's type dictates the only
398        // acceptable alg; the token's stated alg must equal it exactly.
399        let (expected_alg, decoding_key) = match key {
400            KeyMaterial::Ed25519Pem(pem) => (
401                Algorithm::EdDSA,
402                DecodingKey::from_ed_pem(pem.as_bytes())
403                    .map_err(|e| IdentityError::Malformed(format!("bad key for kid {kid}: {e}")))?,
404            ),
405            KeyMaterial::Ed25519Jwk(x) => (
406                Algorithm::EdDSA,
407                DecodingKey::from_ed_components(x)
408                    .map_err(|e| IdentityError::Malformed(format!("bad JWK for kid {kid}: {e}")))?,
409            ),
410            KeyMaterial::HmacSecret(secret) => (Algorithm::HS256, DecodingKey::from_secret(secret)),
411        };
412        if header.alg != expected_alg {
413            return Err(IdentityError::AlgorithmRejected {
414                alg: format!("{:?}", header.alg),
415                kid,
416            });
417        }
418
419        let mut validation = Validation::new(expected_alg);
420        validation.set_audience(std::slice::from_ref(&self.audience));
421        validation.set_required_spec_claims(&["exp", "aud", "sub", "iss"]);
422        validation.leeway = self.leeway_secs;
423        validation.validate_nbf = true;
424
425        let data = jsonwebtoken::decode::<NhiClaims>(token, &decoding_key, &validation)
426            .map_err(|e| IdentityError::Verification(e.to_string()))?;
427        let claims = data.claims;
428
429        if claims.exp <= claims.iat {
430            return Err(IdentityError::BadTimestamps {
431                iat: claims.iat,
432                exp: claims.exp,
433            });
434        }
435        let now_s = av_core::time::now_ms() / av_core::units::MS_PER_SEC;
436        if claims.iat > now_s.saturating_add(self.leeway_secs) {
437            return Err(IdentityError::FutureIat {
438                iat: claims.iat,
439                now: now_s,
440            });
441        }
442        let ttl = claims.exp - claims.iat;
443        if ttl > MAX_TTL_SECS {
444            return Err(IdentityError::TtlTooLong(ttl));
445        }
446        if claims.instance_uid.is_empty() {
447            return Err(IdentityError::EmptyField("instance_uid"));
448        }
449        if claims.charter.is_empty() {
450            return Err(IdentityError::EmptyField("charter"));
451        }
452        if claims.version.is_empty() {
453            return Err(IdentityError::EmptyField("version"));
454        }
455        // Trojan-Source guard: any bidi override or zero-width character in
456        // a rendered identity field would spoof how it looks in operator
457        // logs, receipts, and event chains while remaining part of the raw
458        // bytes on the wire.
459        for (name, value) in [
460            ("instance_uid", claims.instance_uid.as_str()),
461            ("charter", claims.charter.as_str()),
462            ("version", claims.version.as_str()),
463            ("sub", claims.sub.as_str()),
464            ("iss", claims.iss.as_str()),
465            ("jti", claims.jti.as_str()),
466        ] {
467            if av_core::text::contains_bidi_or_zero_width(value) {
468                return Err(IdentityError::SpoofingCharacter(name));
469            }
470        }
471        if let Some(allowed) = &self.allowed_issuers {
472            if !allowed.contains(&claims.iss) {
473                return Err(IdentityError::BadIssuer(claims.iss));
474            }
475        }
476        Ok(claims)
477    }
478}