1use std::collections::BTreeMap;
2
3use base64::Engine;
4use base64::engine::general_purpose::URL_SAFE_NO_PAD;
5use chrono::Utc;
6use serde::{Deserialize, Serialize};
7
8use super::{DidKey, DidKeyError};
9use crate::governance::GovernanceLookup;
10use crate::id::{IdentifierError, PilotId};
11use crate::keys::PilotIdentity;
12use crate::util::is_iso_3166_alpha2_country_code;
13
14const VC_JWT_TYP: &str = "vc+jwt";
15const VC_JWT_ALG: &str = "EdDSA";
16const VC_CONTEXT: &str = "https://www.w3.org/2018/credentials/v1";
17const VC_TYPE: &str = "VerifiableCredential";
18const PILOT_PROFILE_CREDENTIAL_TYPE: &str = "PilotProfileCredential";
19
20pub trait Clock {
21 fn now_unix_seconds(&self) -> i64;
22}
23
24#[derive(Debug, Clone, Copy, Default)]
25pub struct SystemClock;
26
27impl Clock for SystemClock {
28 fn now_unix_seconds(&self) -> i64 {
29 Utc::now().timestamp()
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct FixedClock {
35 now_unix_seconds: i64,
36}
37
38impl FixedClock {
39 pub const fn new(now_unix_seconds: i64) -> Self {
40 Self { now_unix_seconds }
41 }
42}
43
44impl Clock for FixedClock {
45 fn now_unix_seconds(&self) -> i64 {
46 self.now_unix_seconds
47 }
48}
49
50#[derive(Debug, thiserror::Error)]
51pub enum PilotProfileCredentialError {
52 #[error("base64url: {0}")]
53 Base64(#[from] base64::DecodeError),
54 #[error("JSON: {0}")]
55 Json(#[from] serde_json::Error),
56 #[error("identifier: {0}")]
57 Identifier(#[from] IdentifierError),
58 #[error("did:key: {0}")]
59 DidKey(#[from] DidKeyError),
60 #[error("governance: {0}")]
61 Governance(#[from] crate::governance::GovernanceStoreError),
62 #[error("compact JWT must contain exactly 3 dot-separated segments")]
63 MalformedCompactJwt,
64 #[error("JOSE header alg must be {VC_JWT_ALG:?}, got {0:?}")]
65 UnsupportedAlgorithm(String),
66 #[error("JOSE header typ must be {VC_JWT_TYP:?}, got {0:?}")]
67 UnsupportedType(String),
68 #[error("JOSE header kid must equal issuer verification method {expected}, got {found}")]
69 KidMismatch { expected: String, found: String },
70 #[error("JWT signature must decode to 64 bytes")]
71 SignatureEncoding,
72 #[error("JWT signature verification failed")]
73 SignatureVerification,
74 #[error("credential jti must not be empty")]
75 EmptyJti,
76 #[error("credential audience must not be empty")]
77 EmptyAudience,
78 #[error("vc @context must include {VC_CONTEXT:?}")]
79 MissingContext,
80 #[error("vc type must include {VC_TYPE:?}")]
81 MissingVerifiableCredentialType,
82 #[error("vc type must include {PILOT_PROFILE_CREDENTIAL_TYPE:?}")]
83 MissingPilotProfileCredentialType,
84 #[error("credentialSubject.id must equal sub")]
85 SubjectIdMismatch,
86 #[error("credentialSubject.pilot_auth_did must equal iss")]
87 SubjectDidMismatch,
88 #[error("credentialSubject.country must use ISO 3166-1 alpha-2 uppercase syntax, got {0:?}")]
89 InvalidCountry(String),
90 #[error("exp must be greater than nbf when supplied")]
91 InvalidExpiryWindow,
92 #[error("credential is not yet valid until unix second {not_before}")]
93 NotYetValid { not_before: i64 },
94 #[error("credential expired at unix second {expires_at}")]
95 Expired { expires_at: i64 },
96 #[error("expected audience {expected:?} not present in aud claim")]
97 AudienceMismatch { expected: String },
98 #[error("no authoritative pilot_auth_did is available for pilot {0}")]
99 GovernanceUnavailable(PilotId),
100 #[error("pilot-auth-did governance state for {0} is incomplete and not high-trust")]
101 GovernanceIncomplete(PilotId),
102 #[error(
103 "local active pilot_auth_did {local} does not match authoritative governance DID {authoritative}"
104 )]
105 IssuanceDidMismatch {
106 local: DidKey,
107 authoritative: DidKey,
108 },
109 #[error(
110 "credential issuer DID {presented} does not match authoritative active DID {authoritative}"
111 )]
112 StaleCredential {
113 presented: DidKey,
114 authoritative: DidKey,
115 },
116 #[error(
117 "credential issuer DID {presented} is a retired historical pilot_auth_did; current authoritative DID is {authoritative}"
118 )]
119 HistoricalCredential {
120 presented: DidKey,
121 authoritative: DidKey,
122 },
123 #[error("credential expiration overflowed supported unix time")]
124 ExpiryOverflow,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
128pub struct PilotProfileCredentialJoseHeader {
129 pub alg: String,
130 pub typ: String,
131 pub kid: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub struct PilotProfileCredentialClaims {
136 pub iss: DidKey,
137 pub sub: PilotId,
138 pub nbf: i64,
139 pub iat: i64,
140 #[serde(skip_serializing_if = "Option::is_none")]
141 pub exp: Option<i64>,
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub aud: Option<JwtAudience>,
144 pub jti: String,
145 pub vc: PilotProfileCredentialVc,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
149pub struct PilotProfileCredentialVc {
150 #[serde(rename = "@context")]
151 pub context: Vec<String>,
152 #[serde(rename = "type")]
153 pub credential_type: Vec<String>,
154 #[serde(rename = "credentialSubject")]
155 pub credential_subject: PilotProfileCredentialSubject,
156}
157
158#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
159pub struct PilotProfileCredentialSubject {
160 pub id: PilotId,
161 pub pilot_auth_did: DidKey,
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub name: Option<String>,
164 #[serde(skip_serializing_if = "Option::is_none")]
165 pub country: Option<String>,
166 #[serde(default, flatten)]
167 pub additional_fields: BTreeMap<String, serde_json::Value>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171#[serde(untagged)]
172pub enum JwtAudience {
173 One(String),
174 Many(Vec<String>),
175}
176
177impl JwtAudience {
178 fn validate(&self) -> Result<(), PilotProfileCredentialError> {
179 match self {
180 Self::One(value) => {
181 if value.is_empty() {
182 return Err(PilotProfileCredentialError::EmptyAudience);
183 }
184 }
185 Self::Many(values) => {
186 if values.is_empty() || values.iter().any(String::is_empty) {
187 return Err(PilotProfileCredentialError::EmptyAudience);
188 }
189 }
190 }
191 Ok(())
192 }
193
194 fn contains(&self, expected: &str) -> bool {
195 match self {
196 Self::One(value) => value == expected,
197 Self::Many(values) => values.iter().any(|value| value == expected),
198 }
199 }
200}
201
202#[derive(Debug, Clone, Default, PartialEq, Eq)]
203pub struct PilotProfileCredentialSubjectDraft {
204 pub name: Option<String>,
205 pub country: Option<String>,
206 pub additional_fields: BTreeMap<String, serde_json::Value>,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq)]
210pub struct PilotProfileCredentialRequest {
211 pub subject: PilotProfileCredentialSubjectDraft,
212 pub jti: String,
213 pub audience: Option<String>,
214 pub expires_in_seconds: Option<u64>,
215}
216
217#[derive(Debug, Clone, PartialEq, Eq)]
218pub struct PilotProfileCredentialJwt {
219 compact: String,
220 signing_input: String,
221 signature: [u8; 64],
222 header: PilotProfileCredentialJoseHeader,
223 claims: PilotProfileCredentialClaims,
224}
225
226impl PilotProfileCredentialJwt {
227 pub fn issue(
228 pilot_auth_secret_key: &iroh::SecretKey,
229 claims: PilotProfileCredentialClaims,
230 ) -> Result<Self, PilotProfileCredentialError> {
231 validate_claims(&claims)?;
232 let header = PilotProfileCredentialJoseHeader {
233 alg: VC_JWT_ALG.to_string(),
234 typ: VC_JWT_TYP.to_string(),
235 kid: claims.iss.key_id(),
236 };
237 validate_header(&header, &claims.iss)?;
238
239 let header_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header)?);
240 let payload_segment = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims)?);
241 let signing_input = format!("{header_segment}.{payload_segment}");
242 let signature = pilot_auth_secret_key
243 .sign(signing_input.as_bytes())
244 .to_bytes();
245 let signature_segment = URL_SAFE_NO_PAD.encode(signature);
246
247 Ok(Self {
248 compact: format!("{signing_input}.{signature_segment}"),
249 signing_input,
250 signature,
251 header,
252 claims,
253 })
254 }
255
256 pub fn parse(compact: &str) -> Result<Self, PilotProfileCredentialError> {
257 let mut segments = compact.split('.');
258 let header_segment = segments
259 .next()
260 .ok_or(PilotProfileCredentialError::MalformedCompactJwt)?;
261 let payload_segment = segments
262 .next()
263 .ok_or(PilotProfileCredentialError::MalformedCompactJwt)?;
264 let signature_segment = segments
265 .next()
266 .ok_or(PilotProfileCredentialError::MalformedCompactJwt)?;
267 if segments.next().is_some() {
268 return Err(PilotProfileCredentialError::MalformedCompactJwt);
269 }
270
271 let header_bytes = URL_SAFE_NO_PAD.decode(header_segment)?;
272 let payload_bytes = URL_SAFE_NO_PAD.decode(payload_segment)?;
273 let signature_bytes = URL_SAFE_NO_PAD.decode(signature_segment)?;
274 let signature: [u8; 64] = signature_bytes
275 .try_into()
276 .map_err(|_| PilotProfileCredentialError::SignatureEncoding)?;
277
278 let header: PilotProfileCredentialJoseHeader = serde_json::from_slice(&header_bytes)?;
279 let claims: PilotProfileCredentialClaims = serde_json::from_slice(&payload_bytes)?;
280 validate_header(&header, &claims.iss)?;
281 validate_claims(&claims)?;
282
283 Ok(Self {
284 compact: compact.to_string(),
285 signing_input: format!("{header_segment}.{payload_segment}"),
286 signature,
287 header,
288 claims,
289 })
290 }
291
292 pub fn compact(&self) -> &str {
293 &self.compact
294 }
295
296 pub fn header(&self) -> &PilotProfileCredentialJoseHeader {
297 &self.header
298 }
299
300 pub fn claims(&self) -> &PilotProfileCredentialClaims {
301 &self.claims
302 }
303
304 pub fn verify_signature(&self) -> Result<(), PilotProfileCredentialError> {
305 let signature = iroh::Signature::from_bytes(&self.signature);
306 self.claims
307 .iss
308 .public_key()
309 .verify(self.signing_input.as_bytes(), &signature)
310 .map_err(|_| PilotProfileCredentialError::SignatureVerification)
311 }
312
313 pub fn verify_authoritative<G: GovernanceLookup, C: Clock>(
314 &self,
315 governance: &G,
316 clock: &C,
317 expected_audience: Option<&str>,
318 ) -> Result<(), PilotProfileCredentialError> {
319 let now = clock.now_unix_seconds();
320 if self.claims.nbf > now {
321 return Err(PilotProfileCredentialError::NotYetValid {
322 not_before: self.claims.nbf,
323 });
324 }
325 if let Some(expires_at) = self.claims.exp
326 && now >= expires_at
327 {
328 return Err(PilotProfileCredentialError::Expired { expires_at });
329 }
330 if let Some(expected) = expected_audience {
331 match &self.claims.aud {
332 Some(audience) if audience.contains(expected) => {}
333 _ => {
334 return Err(PilotProfileCredentialError::AudienceMismatch {
335 expected: expected.to_string(),
336 });
337 }
338 }
339 }
340
341 let state = governance.resolve_pilot_auth_did_state(&self.claims.sub)?;
342 if state.requires_catch_up() {
343 return Err(PilotProfileCredentialError::GovernanceIncomplete(
344 self.claims.sub.clone(),
345 ));
346 }
347 let authoritative = state.authoritative.ok_or_else(|| {
348 PilotProfileCredentialError::GovernanceUnavailable(self.claims.sub.clone())
349 })?;
350 if authoritative.pilot_auth_did != self.claims.iss {
351 let records = governance.load_pilot_auth_did_records(&self.claims.sub)?;
352 if issuer_is_retired_authoritative_did(&records, &authoritative, &self.claims.iss) {
353 return Err(PilotProfileCredentialError::HistoricalCredential {
354 presented: self.claims.iss.clone(),
355 authoritative: authoritative.pilot_auth_did,
356 });
357 }
358 return Err(PilotProfileCredentialError::StaleCredential {
359 presented: self.claims.iss.clone(),
360 authoritative: authoritative.pilot_auth_did,
361 });
362 }
363
364 Ok(())
365 }
366}
367
368pub fn issue_pilot_profile_credential<G: GovernanceLookup, C: Clock>(
369 governance: &G,
370 pilot_identity: &PilotIdentity,
371 request: PilotProfileCredentialRequest,
372 clock: &C,
373) -> Result<PilotProfileCredentialJwt, PilotProfileCredentialError> {
374 if request.jti.is_empty() {
375 return Err(PilotProfileCredentialError::EmptyJti);
376 }
377 if let Some(audience) = &request.audience
378 && audience.is_empty()
379 {
380 return Err(PilotProfileCredentialError::EmptyAudience);
381 }
382
383 let pilot_id = pilot_identity.pilot_id();
384 let state = governance.resolve_pilot_auth_did_state(&pilot_id)?;
385 if state.requires_catch_up() {
386 return Err(PilotProfileCredentialError::GovernanceIncomplete(pilot_id));
387 }
388 let authoritative = state
389 .authoritative
390 .ok_or_else(|| PilotProfileCredentialError::GovernanceUnavailable(pilot_id.clone()))?;
391 let local_active_did = pilot_identity.active_pilot_auth_did();
392 if authoritative.pilot_auth_did != local_active_did {
393 return Err(PilotProfileCredentialError::IssuanceDidMismatch {
394 local: local_active_did,
395 authoritative: authoritative.pilot_auth_did,
396 });
397 }
398
399 let issued_at = clock.now_unix_seconds();
400 let expires_at = match request.expires_in_seconds {
401 Some(seconds) => Some(
402 issued_at
403 .checked_add(
404 seconds
405 .try_into()
406 .map_err(|_| PilotProfileCredentialError::ExpiryOverflow)?,
407 )
408 .ok_or(PilotProfileCredentialError::ExpiryOverflow)?,
409 ),
410 None => None,
411 };
412 let issuer_did = authoritative.pilot_auth_did;
413
414 let claims = PilotProfileCredentialClaims {
415 iss: issuer_did.clone(),
416 sub: pilot_id.clone(),
417 nbf: issued_at,
418 iat: issued_at,
419 exp: expires_at,
420 aud: request.audience.map(JwtAudience::One),
421 jti: request.jti,
422 vc: PilotProfileCredentialVc {
423 context: vec![VC_CONTEXT.to_string()],
424 credential_type: vec![
425 VC_TYPE.to_string(),
426 PILOT_PROFILE_CREDENTIAL_TYPE.to_string(),
427 ],
428 credential_subject: PilotProfileCredentialSubject {
429 id: pilot_id,
430 pilot_auth_did: issuer_did,
431 name: request.subject.name,
432 country: request.subject.country,
433 additional_fields: request.subject.additional_fields,
434 },
435 },
436 };
437
438 PilotProfileCredentialJwt::issue(&pilot_identity.active_pilot_auth_secret_key(), claims)
439}
440
441pub fn verify_pilot_profile_credential<G: GovernanceLookup, C: Clock>(
442 compact_jwt: &str,
443 governance: &G,
444 clock: &C,
445 expected_audience: Option<&str>,
446) -> Result<PilotProfileCredentialJwt, PilotProfileCredentialError> {
447 let jwt = PilotProfileCredentialJwt::parse(compact_jwt)?;
448 jwt.verify_signature()?;
449 jwt.verify_authoritative(governance, clock, expected_audience)?;
450 Ok(jwt)
451}
452
453fn validate_header(
454 header: &PilotProfileCredentialJoseHeader,
455 issuer_did: &DidKey,
456) -> Result<(), PilotProfileCredentialError> {
457 if header.alg != VC_JWT_ALG {
458 return Err(PilotProfileCredentialError::UnsupportedAlgorithm(
459 header.alg.clone(),
460 ));
461 }
462 if header.typ != VC_JWT_TYP {
463 return Err(PilotProfileCredentialError::UnsupportedType(
464 header.typ.clone(),
465 ));
466 }
467 let expected_kid = issuer_did.key_id();
468 if header.kid != expected_kid {
469 return Err(PilotProfileCredentialError::KidMismatch {
470 expected: expected_kid,
471 found: header.kid.clone(),
472 });
473 }
474 Ok(())
475}
476
477fn validate_claims(
478 claims: &PilotProfileCredentialClaims,
479) -> Result<(), PilotProfileCredentialError> {
480 if claims.jti.is_empty() {
481 return Err(PilotProfileCredentialError::EmptyJti);
482 }
483 if let Some(expires_at) = claims.exp
484 && expires_at <= claims.nbf
485 {
486 return Err(PilotProfileCredentialError::InvalidExpiryWindow);
487 }
488 if let Some(audience) = &claims.aud {
489 audience.validate()?;
490 }
491 if !claims.vc.context.iter().any(|value| value == VC_CONTEXT) {
492 return Err(PilotProfileCredentialError::MissingContext);
493 }
494 if !claims
495 .vc
496 .credential_type
497 .iter()
498 .any(|value| value == VC_TYPE)
499 {
500 return Err(PilotProfileCredentialError::MissingVerifiableCredentialType);
501 }
502 if !claims
503 .vc
504 .credential_type
505 .iter()
506 .any(|value| value == PILOT_PROFILE_CREDENTIAL_TYPE)
507 {
508 return Err(PilotProfileCredentialError::MissingPilotProfileCredentialType);
509 }
510 if claims.vc.credential_subject.id != claims.sub {
511 return Err(PilotProfileCredentialError::SubjectIdMismatch);
512 }
513 if claims.vc.credential_subject.pilot_auth_did != claims.iss {
514 return Err(PilotProfileCredentialError::SubjectDidMismatch);
515 }
516 if let Some(country) = &claims.vc.credential_subject.country
517 && !is_iso_3166_alpha2_country_code(country)
518 {
519 return Err(PilotProfileCredentialError::InvalidCountry(country.clone()));
520 }
521 Ok(())
522}
523
524fn issuer_is_retired_authoritative_did(
525 records: &[crate::governance::PilotAuthDidRecord],
526 authoritative: &crate::governance::PilotAuthDidRecord,
527 issuer: &DidKey,
528) -> bool {
529 use std::collections::HashMap;
530
531 let records_by_id = records
532 .iter()
533 .map(|record| (record.record_id.clone(), record))
534 .collect::<HashMap<_, _>>();
535 let mut current = authoritative;
536 while let Some(previous_id) = current.supersedes.as_ref() {
537 let Some(previous) = records_by_id.get(previous_id) else {
538 return false;
539 };
540 if &previous.pilot_auth_did == issuer {
541 return true;
542 }
543 current = previous;
544 }
545 false
546}
547
548#[cfg(test)]
549mod tests {
550 use super::*;
551 use crate::governance::PilotAuthDidRecord;
552 use crate::identity::DidKey;
553 use crate::identity::test_helpers::{
554 deterministic_secret_key, sample_request, seed_identity_and_governance,
555 temp_governance_store,
556 };
557
558 #[test]
559 fn fixture_shape_matches_models() {
560 let fixture: serde_json::Value = serde_json::from_str(include_str!(
561 "../../../../specs/fixtures/v0.3/profile_vc_jwt.json"
562 ))
563 .unwrap();
564 let header: PilotProfileCredentialJoseHeader =
565 serde_json::from_value(fixture["header"].clone()).unwrap();
566 let claims: PilotProfileCredentialClaims =
567 serde_json::from_value(fixture["payload"].clone()).unwrap();
568
569 validate_header(&header, &claims.iss).unwrap();
570 validate_claims(&claims).unwrap();
571 assert_eq!(claims.iss.key_id(), header.kid);
572 }
573
574 #[test]
575 fn signed_jwt_round_trips_and_verifies() {
576 let (identity, store, _dir) = seed_identity_and_governance(21, 22);
577 let clock = FixedClock::new(1_745_572_800);
578 let issued =
579 issue_pilot_profile_credential(&store, &identity, sample_request(), &clock).unwrap();
580 assert_eq!(
581 issued.compact(),
582 "eyJhbGciOiJFZERTQSIsInR5cCI6InZjK2p3dCIsImtpZCI6ImRpZDprZXk6ejZNa2p1dDFtdWU0WFJXRHJOa3ZFdXhjMk1MdUI5Y044THBYYTVMZks4N1c1SktiI3o2TWtqdXQxbXVlNFhSV0RyTmt2RXV4YzJNTHVCOWNOOExwWGE1TGZLODdXNUpLYiJ9.eyJpc3MiOiJkaWQ6a2V5Ono2TWtqdXQxbXVlNFhSV0RyTmt2RXV4YzJNTHVCOWNOOExwWGE1TGZLODdXNUpLYiIsInN1YiI6ImlnY25ldDppZDpkNTQyMDdkYTE5NDk3N2RjZjQ2YWRiZmVjMmJjMmU3NWI1MmQ1YThhNDIxODRmZWRmZGMwMDAyNGYwZTNlOGRhIiwibmJmIjoxNzQ1NTcyODAwLCJpYXQiOjE3NDU1NzI4MDAsImp0aSI6InVybjp1dWlkOmY0N2FjMTBiLTU4Y2MtNDM3Mi1hNTY3LTBlMDJiMmMzZDQ3OSIsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJQaWxvdFByb2ZpbGVDcmVkZW50aWFsIl0sImNyZWRlbnRpYWxTdWJqZWN0Ijp7ImlkIjoiaWdjbmV0OmlkOmQ1NDIwN2RhMTk0OTc3ZGNmNDZhZGJmZWMyYmMyZTc1YjUyZDVhOGE0MjE4NGZlZGZkYzAwMDI0ZjBlM2U4ZGEiLCJwaWxvdF9hdXRoX2RpZCI6ImRpZDprZXk6ejZNa2p1dDFtdWU0WFJXRHJOa3ZFdXhjMk1MdUI5Y044THBYYTVMZks4N1c1SktiIiwibmFtZSI6IkFsaWNlIEV4YW1wbGUiLCJjb3VudHJ5IjoiTk8ifX19.mWr58Qj7akzE-tfZJPF9JcSB3cxbzCmR3TaP5JgnrPvzN-AJG0qkqarc1pO3H3oqoI-Cvse6xGI1hYuGWVd4DA"
583 );
584
585 let parsed = PilotProfileCredentialJwt::parse(issued.compact()).unwrap();
586 assert_eq!(parsed.claims(), issued.claims());
587 parsed.verify_signature().unwrap();
588 parsed.verify_authoritative(&store, &clock, None).unwrap();
589 }
590
591 #[test]
592 fn rejects_wrong_jose_alg() {
593 let issuer = DidKey::from_public_key(deterministic_secret_key(31).public());
594 let header = PilotProfileCredentialJoseHeader {
595 alg: "HS256".to_string(),
596 typ: VC_JWT_TYP.to_string(),
597 kid: issuer.key_id(),
598 };
599
600 let err = validate_header(&header, &issuer).unwrap_err();
601 assert!(matches!(
602 err,
603 PilotProfileCredentialError::UnsupportedAlgorithm(value) if value == "HS256"
604 ));
605 }
606
607 #[test]
608 fn rejects_tentative_governance_for_high_trust_issue_and_verify() {
609 let pilot_root = deterministic_secret_key(41);
610 let pilot_auth = deterministic_secret_key(42);
611 let identity = PilotIdentity::from_secret_keys(pilot_root.clone(), pilot_auth.clone());
612 let (store, _dir) = temp_governance_store();
613 let incomplete = PilotAuthDidRecord::issue(
614 &pilot_root,
615 DidKey::from_public_key(pilot_auth.public()),
616 Some(crate::id::Blake3Hex::parse("a".repeat(64)).unwrap()),
617 "2026-05-01T09:14:00Z",
618 )
619 .unwrap();
620 store.persist_pilot_auth_did_record(&incomplete).unwrap();
621 let clock = FixedClock::new(1_745_572_800);
622
623 let issue_err = issue_pilot_profile_credential(&store, &identity, sample_request(), &clock)
624 .unwrap_err();
625 assert!(matches!(
626 issue_err,
627 PilotProfileCredentialError::GovernanceIncomplete(pilot_id)
628 if pilot_id == identity.pilot_id()
629 ));
630
631 let claims = PilotProfileCredentialClaims {
632 iss: identity.active_pilot_auth_did(),
633 sub: identity.pilot_id(),
634 nbf: clock.now_unix_seconds(),
635 iat: clock.now_unix_seconds(),
636 exp: None,
637 aud: None,
638 jti: "tentative".to_string(),
639 vc: PilotProfileCredentialVc {
640 context: vec![VC_CONTEXT.to_string()],
641 credential_type: vec![
642 VC_TYPE.to_string(),
643 PILOT_PROFILE_CREDENTIAL_TYPE.to_string(),
644 ],
645 credential_subject: PilotProfileCredentialSubject {
646 id: identity.pilot_id(),
647 pilot_auth_did: identity.active_pilot_auth_did(),
648 name: Some("Alice".to_string()),
649 country: Some("NO".to_string()),
650 additional_fields: BTreeMap::new(),
651 },
652 },
653 };
654 let jwt =
655 PilotProfileCredentialJwt::issue(&identity.active_pilot_auth_secret_key(), claims)
656 .unwrap();
657 let verify_err = jwt.verify_authoritative(&store, &clock, None).unwrap_err();
658 assert!(matches!(
659 verify_err,
660 PilotProfileCredentialError::GovernanceIncomplete(pilot_id)
661 if pilot_id == identity.pilot_id()
662 ));
663 }
664
665 #[test]
666 fn stale_credential_is_rejected_after_rotation() {
667 let (identity, store, _dir) = seed_identity_and_governance(51, 52);
668 let clock = FixedClock::new(1_745_572_800);
669 let issued =
670 issue_pilot_profile_credential(&store, &identity, sample_request(), &clock).unwrap();
671 issued.verify_authoritative(&store, &clock, None).unwrap();
672
673 let rotated = PilotAuthDidRecord::issue(
674 &identity.pilot_id_secret_key(),
675 DidKey::from_public_key(deterministic_secret_key(53).public()),
676 Some(
677 store
678 .resolve_pilot_auth_did_state(&identity.pilot_id())
679 .unwrap()
680 .authoritative
681 .unwrap()
682 .record_id,
683 ),
684 "2026-05-01T10:14:00Z",
685 )
686 .unwrap();
687 store.persist_pilot_auth_did_record(&rotated).unwrap();
688
689 let err = issued
690 .verify_authoritative(&store, &clock, None)
691 .unwrap_err();
692 assert!(matches!(
693 err,
694 PilotProfileCredentialError::HistoricalCredential {
695 presented,
696 authoritative
697 } if presented == identity.active_pilot_auth_did()
698 && authoritative == rotated.pilot_auth_did
699 ));
700 }
701
702 #[test]
703 fn unknown_non_historical_issuer_is_reported_as_stale() {
704 let (identity, store, _dir) = seed_identity_and_governance(54, 55);
705 let clock = FixedClock::new(1_745_572_800);
706 let unrelated_secret = deterministic_secret_key(56);
707 let unrelated_did = DidKey::from_public_key(unrelated_secret.public());
708 let jwt = PilotProfileCredentialJwt::issue(
709 &unrelated_secret,
710 PilotProfileCredentialClaims {
711 iss: unrelated_did.clone(),
712 sub: identity.pilot_id(),
713 nbf: clock.now_unix_seconds(),
714 iat: clock.now_unix_seconds(),
715 exp: None,
716 aud: None,
717 jti: "unknown-stale".to_string(),
718 vc: PilotProfileCredentialVc {
719 context: vec![VC_CONTEXT.to_string()],
720 credential_type: vec![
721 VC_TYPE.to_string(),
722 PILOT_PROFILE_CREDENTIAL_TYPE.to_string(),
723 ],
724 credential_subject: PilotProfileCredentialSubject {
725 id: identity.pilot_id(),
726 pilot_auth_did: unrelated_did,
727 name: Some("Alice".to_string()),
728 country: Some("NO".to_string()),
729 additional_fields: BTreeMap::new(),
730 },
731 },
732 },
733 )
734 .unwrap();
735
736 let err = jwt.verify_authoritative(&store, &clock, None).unwrap_err();
737 assert!(matches!(
738 err,
739 PilotProfileCredentialError::StaleCredential {
740 presented,
741 authoritative
742 } if presented == DidKey::from_public_key(unrelated_secret.public())
743 && authoritative == identity.active_pilot_auth_did()
744 ));
745 }
746
747 #[test]
748 fn exp_is_accepted_before_expiry_and_rejected_after() {
749 let (identity, store, _dir) = seed_identity_and_governance(61, 62);
750 let request = PilotProfileCredentialRequest {
751 expires_in_seconds: Some(60),
752 ..sample_request()
753 };
754 let issued_at = 1_745_572_800;
755 let issued =
756 issue_pilot_profile_credential(&store, &identity, request, &FixedClock::new(issued_at))
757 .unwrap();
758
759 issued
760 .verify_authoritative(&store, &FixedClock::new(issued_at + 59), None)
761 .unwrap();
762 let err = issued
763 .verify_authoritative(&store, &FixedClock::new(issued_at + 60), None)
764 .unwrap_err();
765 assert!(matches!(
766 err,
767 PilotProfileCredentialError::Expired {
768 expires_at
769 } if expires_at == issued_at + 60
770 ));
771 }
772
773 #[test]
774 fn audience_must_match_when_expected() {
775 let (identity, store, _dir) = seed_identity_and_governance(71, 72);
776 let request = PilotProfileCredentialRequest {
777 audience: Some("portal-a".to_string()),
778 ..sample_request()
779 };
780 let clock = FixedClock::new(1_745_572_800);
781 let issued = issue_pilot_profile_credential(&store, &identity, request, &clock).unwrap();
782
783 issued
784 .verify_authoritative(&store, &clock, Some("portal-a"))
785 .unwrap();
786 let err = issued
787 .verify_authoritative(&store, &clock, Some("portal-b"))
788 .unwrap_err();
789 assert!(matches!(
790 err,
791 PilotProfileCredentialError::AudienceMismatch { expected } if expected == "portal-b"
792 ));
793 }
794
795 #[test]
796 fn rejects_unknown_but_well_formed_country_code() {
797 let (identity, store, _dir) = seed_identity_and_governance(81, 82);
798 let clock = FixedClock::new(1_745_572_800);
799 let request = PilotProfileCredentialRequest {
800 subject: PilotProfileCredentialSubjectDraft {
801 country: Some("ZZ".to_string()),
802 ..PilotProfileCredentialSubjectDraft::default()
803 },
804 ..sample_request()
805 };
806
807 let err = issue_pilot_profile_credential(&store, &identity, request, &clock).unwrap_err();
808 assert!(matches!(
809 err,
810 PilotProfileCredentialError::InvalidCountry(value) if value == "ZZ"
811 ));
812 }
813}