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}