1use crate::claims::{NhiClaims, MAX_TTL_SECS};
4use jsonwebtoken::{Algorithm, DecodingKey, Validation};
5use parking_lot::RwLock;
6use std::collections::{HashMap, HashSet};
7
8#[derive(Clone)]
10pub enum KeyMaterial {
11 Ed25519Pem(String),
13 Ed25519Jwk(String),
15 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(..)"), }
26 }
27}
28
29#[derive(Debug, thiserror::Error, PartialEq, Eq)]
31#[non_exhaustive]
32pub enum IdentityError {
33 #[error("malformed token: {0}")]
35 Malformed(String),
36 #[error("token header has no kid")]
38 MissingKid,
39 #[error("unknown kid {0:?}")]
41 UnknownKid(String),
42 #[error("algorithm {alg:?} not permitted for kid {kid:?}")]
45 AlgorithmRejected {
46 alg: String,
48 kid: String,
50 },
51 #[error("verification failed: {0}")]
54 Verification(String),
55 #[error("ttl {0}s exceeds the {MAX_TTL_SECS}s NHI cap")]
57 TtlTooLong(u64),
58 #[error("inconsistent timestamps (iat {iat}, exp {exp})")]
60 BadTimestamps {
61 iat: u64,
63 exp: u64,
65 },
66 #[error("issued-at timestamp {iat} is in the future (now {now})")]
68 FutureIat {
69 iat: u64,
71 now: u64,
73 },
74 #[error("empty identity field {0}")]
76 EmptyField(&'static str),
77 #[error("identity field {0} carries a bidi/zero-width spoofing character")]
80 SpoofingCharacter(&'static str),
81 #[error("issuer {0:?} not allowed")]
83 BadIssuer(String),
84 #[error("invalid JWKS: {0}")]
86 Jwks(String),
87 #[error("scope escalation: {scope:?} not granted by parent")]
89 ScopeEscalation {
90 scope: String,
92 },
93 #[error("child exp {child} outlives parent exp {parent}")]
95 ExpEscalation {
96 child: u64,
98 parent: u64,
100 },
101 #[error("delegation chain deeper than {0}")]
103 ChainTooDeep(usize),
104}
105
106#[derive(Debug, Clone)]
108pub struct ValidatedIdentity {
109 pub claims: NhiClaims,
111 pub chain_depth: usize,
113 pub ttl_remaining_s: u64,
115}
116
117impl ValidatedIdentity {
118 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
129pub 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 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 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 pub fn add_jwks(&self, document: &serde_json::Value) -> Result<usize, IdentityError> {
185 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 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 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 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 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 pub fn key_count(&self) -> usize {
316 self.keys.read().len()
317 }
318
319 pub fn allow_issuers(&mut self, issuers: Vec<String>) {
321 self.allowed_issuers = Some(issuers);
322 }
323
324 pub fn set_max_chain_depth(&mut self, depth: usize) {
326 self.max_chain_depth = depth;
327 }
328
329 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 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 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 fn validate_single(&self, token: &str) -> Result<NhiClaims, IdentityError> {
375 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 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 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}