Skip to main content

igc_net/
verify.rs

1use base64::Engine;
2use base64::engine::general_purpose::URL_SAFE_NO_PAD;
3use serde::Deserialize;
4
5use crate::governance::GovernanceLookup;
6use crate::id::PilotId;
7use crate::identity::{
8    Clock, DidWebResolutionError, DidWebResolver, PilotProfileCredentialError,
9    PilotProfileCredentialJwt,
10};
11
12#[derive(Debug, thiserror::Error)]
13pub enum HighTrustVerificationError {
14    #[error("{0}")]
15    Credential(#[from] PilotProfileCredentialError),
16    #[error("unsupported issuer DID method for PilotProfileCredential: {issuer}")]
17    UnsupportedIssuerMethod { issuer: String },
18}
19
20#[derive(Debug)]
21pub enum PilotProfileCredentialVerification {
22    ValidAuthoritative(PilotProfileCredentialJwt),
23    Invalid {
24        credential: Option<PilotProfileCredentialJwt>,
25        error: HighTrustVerificationError,
26    },
27    UnverifiableGovernanceIncomplete {
28        credential: PilotProfileCredentialJwt,
29        pilot_id: PilotId,
30    },
31    UnverifiableGovernanceUnavailable {
32        credential: PilotProfileCredentialJwt,
33        pilot_id: PilotId,
34    },
35    UnverifiableDidWebResolution {
36        issuer: String,
37        error: DidWebResolutionError,
38    },
39}
40
41pub struct PilotProfileCredentialVerifier<'a, G, C> {
42    governance: &'a G,
43    clock: &'a C,
44    did_web_resolver: Option<&'a dyn DidWebResolver>,
45    expected_audience: Option<&'a str>,
46}
47
48impl<'a, G, C> PilotProfileCredentialVerifier<'a, G, C>
49where
50    G: GovernanceLookup,
51    C: Clock,
52{
53    pub fn new(governance: &'a G, clock: &'a C) -> Self {
54        Self {
55            governance,
56            clock,
57            did_web_resolver: None,
58            expected_audience: None,
59        }
60    }
61
62    pub fn with_did_web_resolver(mut self, resolver: &'a dyn DidWebResolver) -> Self {
63        self.did_web_resolver = Some(resolver);
64        self
65    }
66
67    pub fn with_expected_audience(mut self, expected_audience: &'a str) -> Self {
68        self.expected_audience = Some(expected_audience);
69        self
70    }
71
72    pub fn verify(&self, compact_jwt: &str) -> PilotProfileCredentialVerification {
73        let raw = match RawCompactJwt::decode(compact_jwt) {
74            Ok(raw) => raw,
75            Err(error) => {
76                return PilotProfileCredentialVerification::Invalid {
77                    credential: None,
78                    error: HighTrustVerificationError::Credential(error),
79                };
80            }
81        };
82
83        match issuer_method(&raw.claims.iss) {
84            IssuerDidMethod::DidKey => self.verify_did_key(compact_jwt),
85            IssuerDidMethod::DidWeb => {
86                let resolution = match self.did_web_resolver {
87                    Some(resolver) => resolver
88                        .resolve_verification_method(&raw.claims.iss, raw.header.kid.as_deref()),
89                    None => Err(DidWebResolutionError::ResolverUnavailable),
90                };
91                match resolution {
92                    Ok(_) => PilotProfileCredentialVerification::Invalid {
93                        credential: None,
94                        error: HighTrustVerificationError::UnsupportedIssuerMethod {
95                            issuer: raw.claims.iss,
96                        },
97                    },
98                    Err(error) => {
99                        PilotProfileCredentialVerification::UnverifiableDidWebResolution {
100                            issuer: raw.claims.iss,
101                            error,
102                        }
103                    }
104                }
105            }
106            IssuerDidMethod::Other => PilotProfileCredentialVerification::Invalid {
107                credential: None,
108                error: HighTrustVerificationError::UnsupportedIssuerMethod {
109                    issuer: raw.claims.iss,
110                },
111            },
112        }
113    }
114
115    fn verify_did_key(&self, compact_jwt: &str) -> PilotProfileCredentialVerification {
116        let jwt = match PilotProfileCredentialJwt::parse(compact_jwt) {
117            Ok(jwt) => jwt,
118            Err(error) => {
119                return PilotProfileCredentialVerification::Invalid {
120                    credential: None,
121                    error: HighTrustVerificationError::Credential(error),
122                };
123            }
124        };
125        if let Err(error) = jwt.verify_signature() {
126            return PilotProfileCredentialVerification::Invalid {
127                credential: Some(jwt),
128                error: HighTrustVerificationError::Credential(error),
129            };
130        }
131
132        match jwt.verify_authoritative(self.governance, self.clock, self.expected_audience) {
133            Ok(()) => PilotProfileCredentialVerification::ValidAuthoritative(jwt),
134            Err(PilotProfileCredentialError::GovernanceIncomplete(pilot_id)) => {
135                PilotProfileCredentialVerification::UnverifiableGovernanceIncomplete {
136                    credential: jwt,
137                    pilot_id,
138                }
139            }
140            Err(PilotProfileCredentialError::GovernanceUnavailable(pilot_id)) => {
141                PilotProfileCredentialVerification::UnverifiableGovernanceUnavailable {
142                    credential: jwt,
143                    pilot_id,
144                }
145            }
146            Err(error) => PilotProfileCredentialVerification::Invalid {
147                credential: Some(jwt),
148                error: HighTrustVerificationError::Credential(error),
149            },
150        }
151    }
152}
153
154pub fn verify_pilot_profile_credential_high_trust<G, C>(
155    compact_jwt: &str,
156    governance: &G,
157    clock: &C,
158) -> PilotProfileCredentialVerification
159where
160    G: GovernanceLookup,
161    C: Clock,
162{
163    PilotProfileCredentialVerifier::new(governance, clock).verify(compact_jwt)
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167enum IssuerDidMethod {
168    DidKey,
169    DidWeb,
170    Other,
171}
172
173fn issuer_method(issuer: &str) -> IssuerDidMethod {
174    if issuer.starts_with("did:key:") {
175        IssuerDidMethod::DidKey
176    } else if issuer.starts_with("did:web:") {
177        IssuerDidMethod::DidWeb
178    } else {
179        IssuerDidMethod::Other
180    }
181}
182
183#[derive(Deserialize)]
184struct RawJoseHeader {
185    #[serde(default)]
186    kid: Option<String>,
187}
188
189#[derive(Deserialize)]
190struct RawClaims {
191    iss: String,
192}
193
194struct RawCompactJwt {
195    header: RawJoseHeader,
196    claims: RawClaims,
197}
198
199impl RawCompactJwt {
200    fn decode(compact_jwt: &str) -> Result<Self, PilotProfileCredentialError> {
201        let mut segments = compact_jwt.split('.');
202        let header_segment = segments
203            .next()
204            .ok_or(PilotProfileCredentialError::MalformedCompactJwt)?;
205        let payload_segment = segments
206            .next()
207            .ok_or(PilotProfileCredentialError::MalformedCompactJwt)?;
208        let _signature_segment = segments
209            .next()
210            .ok_or(PilotProfileCredentialError::MalformedCompactJwt)?;
211        if segments.next().is_some() {
212            return Err(PilotProfileCredentialError::MalformedCompactJwt);
213        }
214
215        let header_bytes = URL_SAFE_NO_PAD.decode(header_segment)?;
216        let payload_bytes = URL_SAFE_NO_PAD.decode(payload_segment)?;
217        Ok(Self {
218            header: serde_json::from_slice(&header_bytes)?,
219            claims: serde_json::from_slice(&payload_bytes)?,
220        })
221    }
222}
223
224#[cfg(test)]
225mod tests {
226    use std::collections::BTreeMap;
227
228    use super::*;
229    use crate::governance::PilotAuthDidRecord;
230    use crate::identity::{
231        DidKey, FixedClock, issue_pilot_profile_credential,
232        test_helpers::{
233            deterministic_secret_key, sample_request, seed_identity_and_governance,
234            temp_governance_store,
235        },
236    };
237    use crate::keys::PilotIdentity;
238
239    struct FailingDidWebResolver;
240
241    impl DidWebResolver for FailingDidWebResolver {
242        fn resolve_verification_method(
243            &self,
244            _did: &str,
245            _kid: Option<&str>,
246        ) -> Result<crate::ResolvedDidWebVerificationMethod, DidWebResolutionError> {
247            Err(DidWebResolutionError::Fetch("network down".to_string()))
248        }
249    }
250
251    struct SuccessfulDidWebResolver(iroh::PublicKey);
252
253    impl DidWebResolver for SuccessfulDidWebResolver {
254        fn resolve_verification_method(
255            &self,
256            did: &str,
257            kid: Option<&str>,
258        ) -> Result<crate::ResolvedDidWebVerificationMethod, DidWebResolutionError> {
259            Ok(crate::ResolvedDidWebVerificationMethod {
260                did: did.to_string(),
261                kid: kid.map(str::to_string),
262                public_key: self.0,
263            })
264        }
265    }
266
267    #[test]
268    fn valid_credential_is_reported_as_authoritative() {
269        let (identity, store, _dir) = seed_identity_and_governance(91, 92);
270        let clock = FixedClock::new(1_745_572_800);
271        let jwt =
272            issue_pilot_profile_credential(&store, &identity, sample_request(), &clock).unwrap();
273
274        let outcome = PilotProfileCredentialVerifier::new(&store, &clock).verify(jwt.compact());
275        assert!(matches!(
276            outcome,
277            PilotProfileCredentialVerification::ValidAuthoritative(_)
278        ));
279    }
280
281    #[test]
282    fn malformed_compact_jwt_is_invalid() {
283        let (store, _dir) = temp_governance_store();
284        let clock = FixedClock::new(1_745_572_800);
285
286        let outcome = PilotProfileCredentialVerifier::new(&store, &clock).verify("not-a-jwt");
287        assert!(matches!(
288            outcome,
289            PilotProfileCredentialVerification::Invalid {
290                credential: None,
291                error: HighTrustVerificationError::Credential(
292                    PilotProfileCredentialError::MalformedCompactJwt
293                )
294            }
295        ));
296    }
297
298    #[test]
299    fn incomplete_governance_is_unverifiable() {
300        let pilot_root = deterministic_secret_key(101);
301        let pilot_auth = deterministic_secret_key(102);
302        let identity = PilotIdentity::from_secret_keys(pilot_root.clone(), pilot_auth.clone());
303        let (store, _dir) = temp_governance_store();
304        let incomplete = PilotAuthDidRecord::issue(
305            &pilot_root,
306            DidKey::from_public_key(pilot_auth.public()),
307            Some(crate::Blake3Hex::parse("a".repeat(64)).unwrap()),
308            "2026-05-01T09:14:00Z",
309        )
310        .unwrap();
311        store.persist_pilot_auth_did_record(&incomplete).unwrap();
312        let clock = FixedClock::new(1_745_572_800);
313        let claims = crate::PilotProfileCredentialClaims {
314            iss: identity.active_pilot_auth_did(),
315            sub: identity.pilot_id(),
316            nbf: clock.now_unix_seconds(),
317            iat: clock.now_unix_seconds(),
318            exp: None,
319            aud: None,
320            jti: "tentative".to_string(),
321            vc: crate::PilotProfileCredentialVc {
322                context: vec!["https://www.w3.org/2018/credentials/v1".to_string()],
323                credential_type: vec![
324                    "VerifiableCredential".to_string(),
325                    "PilotProfileCredential".to_string(),
326                ],
327                credential_subject: crate::PilotProfileCredentialSubject {
328                    id: identity.pilot_id(),
329                    pilot_auth_did: identity.active_pilot_auth_did(),
330                    name: Some("Alice".to_string()),
331                    country: Some("NO".to_string()),
332                    additional_fields: BTreeMap::new(),
333                },
334            },
335        };
336        let jwt = crate::PilotProfileCredentialJwt::issue(
337            &identity.active_pilot_auth_secret_key(),
338            claims,
339        )
340        .unwrap();
341
342        let outcome = PilotProfileCredentialVerifier::new(&store, &clock).verify(jwt.compact());
343        assert!(matches!(
344            outcome,
345            PilotProfileCredentialVerification::UnverifiableGovernanceIncomplete {
346                pilot_id,
347                ..
348            } if pilot_id == identity.pilot_id()
349        ));
350    }
351
352    #[test]
353    fn missing_governance_is_unverifiable() {
354        let (identity, _store_with_state, _dir1) = seed_identity_and_governance(111, 112);
355        let (empty_store, _dir2) = temp_governance_store();
356        let clock = FixedClock::new(1_745_572_800);
357        let jwt = crate::PilotProfileCredentialJwt::issue(
358            &identity.active_pilot_auth_secret_key(),
359            crate::PilotProfileCredentialClaims {
360                iss: identity.active_pilot_auth_did(),
361                sub: identity.pilot_id(),
362                nbf: clock.now_unix_seconds(),
363                iat: clock.now_unix_seconds(),
364                exp: None,
365                aud: None,
366                jti: "missing".to_string(),
367                vc: crate::PilotProfileCredentialVc {
368                    context: vec!["https://www.w3.org/2018/credentials/v1".to_string()],
369                    credential_type: vec![
370                        "VerifiableCredential".to_string(),
371                        "PilotProfileCredential".to_string(),
372                    ],
373                    credential_subject: crate::PilotProfileCredentialSubject {
374                        id: identity.pilot_id(),
375                        pilot_auth_did: identity.active_pilot_auth_did(),
376                        name: Some("Alice".to_string()),
377                        country: Some("NO".to_string()),
378                        additional_fields: BTreeMap::new(),
379                    },
380                },
381            },
382        )
383        .unwrap();
384
385        let outcome =
386            PilotProfileCredentialVerifier::new(&empty_store, &clock).verify(jwt.compact());
387        assert!(matches!(
388            outcome,
389            PilotProfileCredentialVerification::UnverifiableGovernanceUnavailable {
390                pilot_id,
391                ..
392            } if pilot_id == identity.pilot_id()
393        ));
394    }
395
396    #[test]
397    fn stale_credential_is_invalid() {
398        let (identity, store, _dir) = seed_identity_and_governance(121, 122);
399        let clock = FixedClock::new(1_745_572_800);
400        let jwt =
401            issue_pilot_profile_credential(&store, &identity, sample_request(), &clock).unwrap();
402        let rotated = PilotAuthDidRecord::issue(
403            &identity.pilot_id_secret_key(),
404            DidKey::from_public_key(deterministic_secret_key(123).public()),
405            Some(
406                store
407                    .resolve_pilot_auth_did_state(&identity.pilot_id())
408                    .unwrap()
409                    .authoritative
410                    .unwrap()
411                    .record_id,
412            ),
413            "2026-05-01T10:14:00Z",
414        )
415        .unwrap();
416        store.persist_pilot_auth_did_record(&rotated).unwrap();
417
418        let outcome = PilotProfileCredentialVerifier::new(&store, &clock).verify(jwt.compact());
419        assert!(matches!(
420            outcome,
421            PilotProfileCredentialVerification::Invalid {
422                error: HighTrustVerificationError::Credential(
423                    PilotProfileCredentialError::HistoricalCredential { .. }
424                ),
425                ..
426            }
427        ));
428    }
429
430    #[test]
431    fn did_web_resolution_failure_is_unverifiable() {
432        let (store, _dir) = temp_governance_store();
433        let clock = FixedClock::new(1_745_572_800);
434        let header = serde_json::json!({
435            "alg": "EdDSA",
436            "typ": "vc+jwt",
437            "kid": "did:web:pilot.example#key-1"
438        });
439        let payload = serde_json::json!({
440            "iss": "did:web:pilot.example",
441            "sub": "igcnet:id:d54207da194977dcf46adbfec2bc2e75b52d5a8a42184fedfdc00024f0e3e8da"
442        });
443        let compact = format!(
444            "{}.{}.{}",
445            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
446            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()),
447            URL_SAFE_NO_PAD.encode([0u8; 64]),
448        );
449
450        let outcome = PilotProfileCredentialVerifier::new(&store, &clock)
451            .with_did_web_resolver(&FailingDidWebResolver)
452            .verify(&compact);
453        assert!(matches!(
454            outcome,
455            PilotProfileCredentialVerification::UnverifiableDidWebResolution {
456                issuer,
457                error: DidWebResolutionError::Fetch(reason)
458            } if issuer == "did:web:pilot.example" && reason == "network down"
459        ));
460    }
461
462    #[test]
463    fn resolved_did_web_issuer_is_still_invalid_for_pilot_profile_credential() {
464        let (store, _dir) = temp_governance_store();
465        let clock = FixedClock::new(1_745_572_800);
466        let header = serde_json::json!({
467            "alg": "EdDSA",
468            "typ": "vc+jwt",
469            "kid": "did:web:pilot.example#key-1"
470        });
471        let payload = serde_json::json!({
472            "iss": "did:web:pilot.example",
473            "sub": "igcnet:id:d54207da194977dcf46adbfec2bc2e75b52d5a8a42184fedfdc00024f0e3e8da"
474        });
475        let compact = format!(
476            "{}.{}.{}",
477            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()),
478            URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()),
479            URL_SAFE_NO_PAD.encode([0u8; 64]),
480        );
481
482        let outcome = PilotProfileCredentialVerifier::new(&store, &clock)
483            .with_did_web_resolver(&SuccessfulDidWebResolver(
484                iroh::SecretKey::from_bytes(&[88u8; 32]).public(),
485            ))
486            .verify(&compact);
487        assert!(matches!(
488            outcome,
489            PilotProfileCredentialVerification::Invalid {
490                credential: None,
491                error: HighTrustVerificationError::UnsupportedIssuerMethod { issuer }
492            } if issuer == "did:web:pilot.example"
493        ));
494    }
495}