Skip to main content

igc_net/identity/
vc_jwt.rs

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}