1use std::collections::HashSet;
9
10use serde::{Deserialize, Serialize};
11use url::Url;
12
13use crate::receipt::SignedExportEnvelope;
14
15pub const CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA: &str = "chio.public-identity-profile.v1";
16pub const CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_SCHEMA: &str = "chio.public-wallet-directory-entry.v1";
17pub const CHIO_PUBLIC_WALLET_ROUTING_MANIFEST_SCHEMA: &str =
18 "chio.public-wallet-routing-manifest.v1";
19pub const CHIO_IDENTITY_INTEROP_QUALIFICATION_MATRIX_SCHEMA: &str =
20 "chio.identity-interop-qualification-matrix.v1";
21
22const IDENTITY_NETWORK_REQUIRED_REQUIREMENTS: [&str; 5] =
23 ["IDMAX-01", "IDMAX-02", "IDMAX-03", "IDMAX-04", "IDMAX-05"];
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum IdentityArtifactKind {
28 PortableTrustProfile,
29 Oid4vciIssuerMetadata,
30 Oid4vpVerifierMetadata,
31 PublicIssuerDiscovery,
32 PublicVerifierDiscovery,
33 WalletExchangeDescriptor,
34 PublicIdentityProfile,
35 PublicWalletDirectoryEntry,
36 PublicWalletRoutingManifest,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct IdentityArtifactReference {
42 pub kind: IdentityArtifactKind,
43 pub schema: String,
44 pub artifact_id: String,
45 pub operator_id: String,
46 pub sha256: String,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub uri: Option<String>,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub enum IdentityDidMethod {
53 #[serde(rename = "did:chio")]
54 DidChio,
55 #[serde(rename = "did:web")]
56 DidWeb,
57 #[serde(rename = "did:key")]
58 DidKey,
59 #[serde(rename = "did:jwk")]
60 DidJwk,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub enum IdentityCredentialFamily {
65 #[serde(rename = "chio-agent-passport+json")]
66 ChioAgentPassportJson,
67 #[serde(rename = "application/dc+sd-jwt")]
68 DcSdJwt,
69 #[serde(rename = "jwt_vc_json")]
70 JwtVcJson,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
74pub enum IdentityProofFamily {
75 #[serde(rename = "ed25519-signature-2020")]
76 Ed25519Signature2020,
77 #[serde(rename = "dc+sd-jwt")]
78 DcSdJwt,
79 #[serde(rename = "jwt_vc_json")]
80 JwtVcJson,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
84pub enum WalletTransportMode {
85 #[serde(rename = "openid4vp-same-device")]
86 Oid4vpSameDevice,
87 #[serde(rename = "openid4vp-cross-device")]
88 Oid4vpCrossDevice,
89 #[serde(rename = "openid4vp-relay")]
90 Oid4vpRelay,
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "camelCase", deny_unknown_fields)]
95pub struct IdentityBindingPolicy {
96 pub requires_chio_subject_provenance: bool,
97 pub requires_chio_issuer_provenance: bool,
98 pub requires_same_subject_across_credentials: bool,
99 pub manual_subject_rebinding_required: bool,
100 pub unsupported_mappings_fail_closed: bool,
101}
102
103impl Default for IdentityBindingPolicy {
104 fn default() -> Self {
105 Self {
106 requires_chio_subject_provenance: true,
107 requires_chio_issuer_provenance: true,
108 requires_same_subject_across_credentials: true,
109 manual_subject_rebinding_required: true,
110 unsupported_mappings_fail_closed: true,
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct PublicIdentityProfileArtifact {
118 pub schema: String,
119 pub profile_id: String,
120 pub issued_at: u64,
121 pub supported_subject_methods: Vec<IdentityDidMethod>,
122 pub supported_issuer_methods: Vec<IdentityDidMethod>,
123 pub supported_credential_families: Vec<IdentityCredentialFamily>,
124 pub supported_proof_families: Vec<IdentityProofFamily>,
125 pub supported_transports: Vec<WalletTransportMode>,
126 pub basis_refs: Vec<IdentityArtifactReference>,
127 #[serde(default)]
128 pub binding_policy: IdentityBindingPolicy,
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub note: Option<String>,
131}
132
133pub type SignedPublicIdentityProfile = SignedExportEnvelope<PublicIdentityProfileArtifact>;
134
135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
136#[serde(rename_all = "camelCase", deny_unknown_fields)]
137pub struct WalletDirectoryLookupGuardrails {
138 pub requires_explicit_verifier_binding: bool,
139 pub requires_manual_subject_binding_review: bool,
140 pub reject_ambient_directory_trust: bool,
141 pub fail_closed_on_unknown_wallet_family: bool,
142}
143
144impl Default for WalletDirectoryLookupGuardrails {
145 fn default() -> Self {
146 Self {
147 requires_explicit_verifier_binding: true,
148 requires_manual_subject_binding_review: true,
149 reject_ambient_directory_trust: true,
150 fail_closed_on_unknown_wallet_family: true,
151 }
152 }
153}
154
155#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase", deny_unknown_fields)]
157pub struct PublicWalletDirectoryEntryArtifact {
158 pub schema: String,
159 pub entry_id: String,
160 pub issued_at: u64,
161 pub directory_operator_id: String,
162 pub wallet_id: String,
163 pub supported_subject_methods: Vec<IdentityDidMethod>,
164 pub supported_issuer_methods: Vec<IdentityDidMethod>,
165 pub supported_credential_families: Vec<IdentityCredentialFamily>,
166 pub supported_proof_families: Vec<IdentityProofFamily>,
167 pub discovery_ref: IdentityArtifactReference,
168 pub profile_ref: IdentityArtifactReference,
169 pub metadata_url: String,
170 pub request_uri_prefix: String,
171 #[serde(default)]
172 pub lookup_guardrails: WalletDirectoryLookupGuardrails,
173 #[serde(default, skip_serializing_if = "Option::is_none")]
174 pub note: Option<String>,
175}
176
177pub type SignedPublicWalletDirectoryEntry =
178 SignedExportEnvelope<PublicWalletDirectoryEntryArtifact>;
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(rename_all = "camelCase", deny_unknown_fields)]
182pub struct WalletRoutingGuardrails {
183 pub requires_explicit_verifier_binding: bool,
184 pub requires_replay_safe_exchange: bool,
185 pub fail_closed_on_subject_mismatch: bool,
186 pub fail_closed_on_cross_operator_issuer_mismatch: bool,
187}
188
189impl Default for WalletRoutingGuardrails {
190 fn default() -> Self {
191 Self {
192 requires_explicit_verifier_binding: true,
193 requires_replay_safe_exchange: true,
194 fail_closed_on_subject_mismatch: true,
195 fail_closed_on_cross_operator_issuer_mismatch: true,
196 }
197 }
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
201#[serde(rename_all = "camelCase", deny_unknown_fields)]
202pub struct PublicWalletRoutingManifestArtifact {
203 pub schema: String,
204 pub route_id: String,
205 pub issued_at: u64,
206 pub directory_entry_ref: IdentityArtifactReference,
207 pub verifier_id: String,
208 pub response_uri_prefix: String,
209 pub relay_url: String,
210 pub transport_modes: Vec<WalletTransportMode>,
211 pub requires_signed_request_object: bool,
212 pub requires_replay_anchors: bool,
213 #[serde(default)]
214 pub routing_guardrails: WalletRoutingGuardrails,
215 #[serde(default, skip_serializing_if = "Option::is_none")]
216 pub note: Option<String>,
217}
218
219pub type SignedPublicWalletRoutingManifest =
220 SignedExportEnvelope<PublicWalletRoutingManifestArtifact>;
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
223#[serde(rename_all = "snake_case")]
224pub enum IdentityInteropScenarioKind {
225 UnsupportedDidMethod,
226 UnsupportedCredentialFamily,
227 DirectoryPoisoning,
228 RouteReplay,
229 MultiWalletSelection,
230 CrossOperatorIssuerMismatch,
231 ReleaseBoundaryClosure,
232}
233
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum IdentityQualificationOutcome {
237 Pass,
238 FailClosed,
239}
240
241#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242#[serde(rename_all = "camelCase", deny_unknown_fields)]
243pub struct IdentityInteropQualificationCase {
244 pub id: String,
245 pub name: String,
246 pub requirement_ids: Vec<String>,
247 pub scenario: IdentityInteropScenarioKind,
248 pub expected_outcome: IdentityQualificationOutcome,
249 pub observed_outcome: IdentityQualificationOutcome,
250 #[serde(default, skip_serializing_if = "Vec::is_empty")]
251 pub notes: Vec<String>,
252}
253
254#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "camelCase", deny_unknown_fields)]
256pub struct IdentityInteropQualificationMatrix {
257 pub schema: String,
258 pub profile_ref: IdentityArtifactReference,
259 pub directory_entry_ref: IdentityArtifactReference,
260 pub routing_manifest_ref: IdentityArtifactReference,
261 pub cases: Vec<IdentityInteropQualificationCase>,
262}
263
264pub type SignedIdentityInteropQualificationMatrix =
265 SignedExportEnvelope<IdentityInteropQualificationMatrix>;
266
267#[derive(Debug, thiserror::Error, PartialEq, Eq)]
268pub enum IdentityNetworkContractError {
269 #[error("unsupported schema `{0}`")]
270 UnsupportedSchema(String),
271 #[error("missing required field `{0}`")]
272 MissingField(&'static str),
273 #[error("duplicate value `{0}`")]
274 DuplicateValue(String),
275 #[error("invalid reference `{0}`")]
276 InvalidReference(String),
277 #[error("invalid identity profile `{0}`")]
278 InvalidProfile(String),
279 #[error("invalid wallet directory entry `{0}`")]
280 InvalidDirectoryEntry(String),
281 #[error("invalid wallet routing manifest `{0}`")]
282 InvalidRouting(String),
283 #[error("invalid qualification case `{0}`")]
284 InvalidQualificationCase(String),
285}
286
287pub fn validate_public_identity_profile(
288 profile: &PublicIdentityProfileArtifact,
289) -> Result<(), IdentityNetworkContractError> {
290 if profile.schema != CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA {
291 return Err(IdentityNetworkContractError::UnsupportedSchema(
292 profile.schema.clone(),
293 ));
294 }
295 ensure_non_empty(&profile.profile_id, "profile_id")?;
296 ensure_unique_copy_values(
297 &profile.supported_subject_methods,
298 "supported_subject_methods",
299 )?;
300 ensure_unique_copy_values(
301 &profile.supported_issuer_methods,
302 "supported_issuer_methods",
303 )?;
304 ensure_unique_copy_values(
305 &profile.supported_credential_families,
306 "supported_credential_families",
307 )?;
308 ensure_unique_copy_values(
309 &profile.supported_proof_families,
310 "supported_proof_families",
311 )?;
312 ensure_unique_copy_values(&profile.supported_transports, "supported_transports")?;
313 ensure_refs_present(&profile.basis_refs, "basis_refs")?;
314 validate_identity_binding_policy(&profile.binding_policy)?;
315
316 if !profile
317 .supported_subject_methods
318 .contains(&IdentityDidMethod::DidChio)
319 || !profile
320 .supported_issuer_methods
321 .contains(&IdentityDidMethod::DidChio)
322 {
323 return Err(IdentityNetworkContractError::InvalidProfile(
324 "public identity profiles must retain did:chio provenance in both subject and issuer support".to_string(),
325 ));
326 }
327 if !contains_non_chio_method(&profile.supported_subject_methods)
328 && !contains_non_chio_method(&profile.supported_issuer_methods)
329 {
330 return Err(IdentityNetworkContractError::InvalidProfile(
331 "public identity profiles must support at least one non-did:chio method".to_string(),
332 ));
333 }
334 if !profile
335 .supported_credential_families
336 .contains(&IdentityCredentialFamily::ChioAgentPassportJson)
337 {
338 return Err(IdentityNetworkContractError::InvalidProfile(
339 "public identity profiles must retain chio-agent-passport+json compatibility"
340 .to_string(),
341 ));
342 }
343 if !profile.supported_credential_families.iter().any(|family| {
344 matches!(
345 family,
346 IdentityCredentialFamily::DcSdJwt | IdentityCredentialFamily::JwtVcJson
347 )
348 }) {
349 return Err(IdentityNetworkContractError::InvalidProfile(
350 "public identity profiles must advertise at least one portable VC family".to_string(),
351 ));
352 }
353 ensure_required_transports(&profile.supported_transports, "supported_transports")?;
354
355 let mut required_kinds = HashSet::from([
356 IdentityArtifactKind::PortableTrustProfile,
357 IdentityArtifactKind::Oid4vciIssuerMetadata,
358 IdentityArtifactKind::Oid4vpVerifierMetadata,
359 ]);
360 for reference in &profile.basis_refs {
361 validate_identity_artifact_reference(reference)?;
362 required_kinds.remove(&reference.kind);
363 }
364 if !required_kinds.is_empty() {
365 return Err(IdentityNetworkContractError::InvalidProfile(
366 "public identity profiles must reference portable trust, OID4VCI, and OID4VP basis artifacts".to_string(),
367 ));
368 }
369
370 Ok(())
371}
372
373pub fn validate_public_wallet_directory_entry(
374 entry: &PublicWalletDirectoryEntryArtifact,
375) -> Result<(), IdentityNetworkContractError> {
376 if entry.schema != CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_SCHEMA {
377 return Err(IdentityNetworkContractError::UnsupportedSchema(
378 entry.schema.clone(),
379 ));
380 }
381 ensure_non_empty(&entry.entry_id, "entry_id")?;
382 ensure_non_empty(&entry.directory_operator_id, "directory_operator_id")?;
383 ensure_non_empty(&entry.wallet_id, "wallet_id")?;
384 ensure_unique_copy_values(
385 &entry.supported_subject_methods,
386 "supported_subject_methods",
387 )?;
388 ensure_unique_copy_values(&entry.supported_issuer_methods, "supported_issuer_methods")?;
389 ensure_unique_copy_values(
390 &entry.supported_credential_families,
391 "supported_credential_families",
392 )?;
393 ensure_unique_copy_values(&entry.supported_proof_families, "supported_proof_families")?;
394 validate_identity_artifact_reference(&entry.discovery_ref)?;
395 validate_identity_artifact_reference(&entry.profile_ref)?;
396 validate_wallet_directory_lookup_guardrails(&entry.lookup_guardrails)?;
397 validate_https_url(&entry.metadata_url, "metadata_url")?;
398 validate_https_url(&entry.request_uri_prefix, "request_uri_prefix")?;
399
400 if entry.discovery_ref.kind != IdentityArtifactKind::PublicVerifierDiscovery {
401 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
402 "wallet directory discovery_ref must point to public verifier discovery".to_string(),
403 ));
404 }
405 if entry.profile_ref.kind != IdentityArtifactKind::PublicIdentityProfile {
406 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
407 "wallet directory profile_ref must point to a public identity profile".to_string(),
408 ));
409 }
410 if !contains_non_chio_method(&entry.supported_subject_methods)
411 || !contains_non_chio_method(&entry.supported_issuer_methods)
412 {
413 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
414 "wallet directory entries must advertise at least one broader subject and issuer method".to_string(),
415 ));
416 }
417 if !entry.supported_credential_families.iter().any(|family| {
418 matches!(
419 family,
420 IdentityCredentialFamily::DcSdJwt | IdentityCredentialFamily::JwtVcJson
421 )
422 }) {
423 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
424 "wallet directory entries must advertise at least one portable credential family"
425 .to_string(),
426 ));
427 }
428
429 Ok(())
430}
431
432pub fn validate_public_wallet_routing_manifest(
433 manifest: &PublicWalletRoutingManifestArtifact,
434) -> Result<(), IdentityNetworkContractError> {
435 if manifest.schema != CHIO_PUBLIC_WALLET_ROUTING_MANIFEST_SCHEMA {
436 return Err(IdentityNetworkContractError::UnsupportedSchema(
437 manifest.schema.clone(),
438 ));
439 }
440 ensure_non_empty(&manifest.route_id, "route_id")?;
441 ensure_non_empty(&manifest.verifier_id, "verifier_id")?;
442 validate_identity_artifact_reference(&manifest.directory_entry_ref)?;
443 validate_https_url(&manifest.verifier_id, "verifier_id")?;
444 validate_https_url(&manifest.response_uri_prefix, "response_uri_prefix")?;
445 validate_https_url(&manifest.relay_url, "relay_url")?;
446 ensure_required_transports(&manifest.transport_modes, "transport_modes")?;
447 validate_wallet_routing_guardrails(&manifest.routing_guardrails)?;
448
449 if manifest.directory_entry_ref.kind != IdentityArtifactKind::PublicWalletDirectoryEntry {
450 return Err(IdentityNetworkContractError::InvalidRouting(
451 "wallet routing manifest directory_entry_ref must point to a wallet directory entry"
452 .to_string(),
453 ));
454 }
455 if !manifest.requires_signed_request_object {
456 return Err(IdentityNetworkContractError::InvalidRouting(
457 "wallet routing manifests must require signed request objects".to_string(),
458 ));
459 }
460 if !manifest.requires_replay_anchors {
461 return Err(IdentityNetworkContractError::InvalidRouting(
462 "wallet routing manifests must require replay anchors".to_string(),
463 ));
464 }
465
466 Ok(())
467}
468
469pub fn validate_identity_interop_qualification_matrix(
470 matrix: &IdentityInteropQualificationMatrix,
471) -> Result<(), IdentityNetworkContractError> {
472 if matrix.schema != CHIO_IDENTITY_INTEROP_QUALIFICATION_MATRIX_SCHEMA {
473 return Err(IdentityNetworkContractError::UnsupportedSchema(
474 matrix.schema.clone(),
475 ));
476 }
477 validate_identity_artifact_reference(&matrix.profile_ref)?;
478 validate_identity_artifact_reference(&matrix.directory_entry_ref)?;
479 validate_identity_artifact_reference(&matrix.routing_manifest_ref)?;
480 if matrix.profile_ref.kind != IdentityArtifactKind::PublicIdentityProfile {
481 return Err(IdentityNetworkContractError::InvalidQualificationCase(
482 "qualification matrix profile_ref must point to a public identity profile".to_string(),
483 ));
484 }
485 if matrix.directory_entry_ref.kind != IdentityArtifactKind::PublicWalletDirectoryEntry {
486 return Err(IdentityNetworkContractError::InvalidQualificationCase(
487 "qualification matrix directory_entry_ref must point to a wallet directory entry"
488 .to_string(),
489 ));
490 }
491 if matrix.routing_manifest_ref.kind != IdentityArtifactKind::PublicWalletRoutingManifest {
492 return Err(IdentityNetworkContractError::InvalidQualificationCase(
493 "qualification matrix routing_manifest_ref must point to a wallet routing manifest"
494 .to_string(),
495 ));
496 }
497 if matrix.cases.is_empty() {
498 return Err(IdentityNetworkContractError::MissingField("cases"));
499 }
500
501 let mut case_ids = HashSet::new();
502 let mut covered_requirements = HashSet::new();
503 for case in &matrix.cases {
504 ensure_non_empty(&case.id, "case.id")?;
505 ensure_non_empty(&case.name, "case.name")?;
506 if !case_ids.insert(case.id.as_str()) {
507 return Err(IdentityNetworkContractError::DuplicateValue(format!(
508 "case.id:{}",
509 case.id
510 )));
511 }
512 if case.expected_outcome != case.observed_outcome {
513 return Err(IdentityNetworkContractError::InvalidQualificationCase(
514 format!(
515 "case `{}` expected and observed outcomes must match",
516 case.id
517 ),
518 ));
519 }
520 ensure_unique_strings(&case.requirement_ids, "case.requirement_ids")?;
521 for requirement_id in &case.requirement_ids {
522 covered_requirements.insert(requirement_id.as_str());
523 }
524 for note in &case.notes {
525 ensure_non_empty(note, "case.notes")?;
526 }
527 }
528
529 for requirement_id in IDENTITY_NETWORK_REQUIRED_REQUIREMENTS {
530 if !covered_requirements.contains(requirement_id) {
531 return Err(IdentityNetworkContractError::InvalidQualificationCase(
532 format!("qualification matrix must cover `{requirement_id}`"),
533 ));
534 }
535 }
536
537 Ok(())
538}
539
540fn validate_identity_artifact_reference(
541 reference: &IdentityArtifactReference,
542) -> Result<(), IdentityNetworkContractError> {
543 ensure_non_empty(&reference.schema, "reference.schema")?;
544 ensure_non_empty(&reference.artifact_id, "reference.artifact_id")?;
545 ensure_non_empty(&reference.operator_id, "reference.operator_id")?;
546 validate_hex_digest(&reference.sha256, "reference.sha256")?;
547 if let Some(uri) = reference.uri.as_ref() {
548 validate_https_url(uri, "reference.uri")?;
549 }
550 Ok(())
551}
552
553fn validate_identity_binding_policy(
554 policy: &IdentityBindingPolicy,
555) -> Result<(), IdentityNetworkContractError> {
556 if !policy.requires_chio_subject_provenance {
557 return Err(IdentityNetworkContractError::InvalidProfile(
558 "public identity profiles must require Chio subject provenance".to_string(),
559 ));
560 }
561 if !policy.requires_chio_issuer_provenance {
562 return Err(IdentityNetworkContractError::InvalidProfile(
563 "public identity profiles must require Chio issuer provenance".to_string(),
564 ));
565 }
566 if !policy.requires_same_subject_across_credentials {
567 return Err(IdentityNetworkContractError::InvalidProfile(
568 "public identity profiles must require the same subject across credentials".to_string(),
569 ));
570 }
571 if !policy.manual_subject_rebinding_required {
572 return Err(IdentityNetworkContractError::InvalidProfile(
573 "public identity profiles must require manual subject rebinding review".to_string(),
574 ));
575 }
576 if !policy.unsupported_mappings_fail_closed {
577 return Err(IdentityNetworkContractError::InvalidProfile(
578 "public identity profiles must fail closed on unsupported mappings".to_string(),
579 ));
580 }
581 Ok(())
582}
583
584fn validate_wallet_directory_lookup_guardrails(
585 guardrails: &WalletDirectoryLookupGuardrails,
586) -> Result<(), IdentityNetworkContractError> {
587 if !guardrails.requires_explicit_verifier_binding {
588 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
589 "wallet directory entries must require explicit verifier binding".to_string(),
590 ));
591 }
592 if !guardrails.requires_manual_subject_binding_review {
593 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
594 "wallet directory entries must require manual subject binding review".to_string(),
595 ));
596 }
597 if !guardrails.reject_ambient_directory_trust {
598 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
599 "wallet directory entries must reject ambient directory trust".to_string(),
600 ));
601 }
602 if !guardrails.fail_closed_on_unknown_wallet_family {
603 return Err(IdentityNetworkContractError::InvalidDirectoryEntry(
604 "wallet directory entries must fail closed on unknown wallet families".to_string(),
605 ));
606 }
607 Ok(())
608}
609
610fn validate_wallet_routing_guardrails(
611 guardrails: &WalletRoutingGuardrails,
612) -> Result<(), IdentityNetworkContractError> {
613 if !guardrails.requires_explicit_verifier_binding {
614 return Err(IdentityNetworkContractError::InvalidRouting(
615 "wallet routing manifests must require explicit verifier binding".to_string(),
616 ));
617 }
618 if !guardrails.requires_replay_safe_exchange {
619 return Err(IdentityNetworkContractError::InvalidRouting(
620 "wallet routing manifests must require replay-safe exchange".to_string(),
621 ));
622 }
623 if !guardrails.fail_closed_on_subject_mismatch {
624 return Err(IdentityNetworkContractError::InvalidRouting(
625 "wallet routing manifests must fail closed on subject mismatch".to_string(),
626 ));
627 }
628 if !guardrails.fail_closed_on_cross_operator_issuer_mismatch {
629 return Err(IdentityNetworkContractError::InvalidRouting(
630 "wallet routing manifests must fail closed on cross-operator issuer mismatch"
631 .to_string(),
632 ));
633 }
634 Ok(())
635}
636
637fn validate_https_url(
638 value: &str,
639 field: &'static str,
640) -> Result<(), IdentityNetworkContractError> {
641 ensure_non_empty(value, field)?;
642 let parsed = Url::parse(value).map_err(|error| {
643 IdentityNetworkContractError::InvalidReference(format!("{field}: {error}"))
644 })?;
645 if parsed.scheme() != "https" {
646 return Err(IdentityNetworkContractError::InvalidReference(format!(
647 "{field}: expected https URL"
648 )));
649 }
650 Ok(())
651}
652
653fn validate_hex_digest(
654 value: &str,
655 field: &'static str,
656) -> Result<(), IdentityNetworkContractError> {
657 if value.len() != 64 || !value.chars().all(|ch| ch.is_ascii_hexdigit()) {
658 return Err(IdentityNetworkContractError::InvalidReference(format!(
659 "{field}: expected 64 hex characters"
660 )));
661 }
662 Ok(())
663}
664
665fn contains_non_chio_method(methods: &[IdentityDidMethod]) -> bool {
666 methods
667 .iter()
668 .any(|method| *method != IdentityDidMethod::DidChio)
669}
670
671fn ensure_required_transports(
672 transports: &[WalletTransportMode],
673 field: &'static str,
674) -> Result<(), IdentityNetworkContractError> {
675 ensure_unique_copy_values(transports, field)?;
676 let transport_set: HashSet<_> = transports.iter().copied().collect();
677 let required = HashSet::from([
678 WalletTransportMode::Oid4vpSameDevice,
679 WalletTransportMode::Oid4vpCrossDevice,
680 WalletTransportMode::Oid4vpRelay,
681 ]);
682 if transport_set != required {
683 return Err(IdentityNetworkContractError::DuplicateValue(format!(
684 "{field}:must include same-device, cross-device, and relay"
685 )));
686 }
687 Ok(())
688}
689
690fn ensure_refs_present(
691 references: &[IdentityArtifactReference],
692 field: &'static str,
693) -> Result<(), IdentityNetworkContractError> {
694 if references.is_empty() {
695 return Err(IdentityNetworkContractError::MissingField(field));
696 }
697 let composite_ids = references
698 .iter()
699 .map(|reference| format!("{}:{}", reference.operator_id, reference.artifact_id))
700 .collect::<Vec<_>>();
701 ensure_unique_strings(&composite_ids, field)?;
702 Ok(())
703}
704
705fn ensure_non_empty(value: &str, field: &'static str) -> Result<(), IdentityNetworkContractError> {
706 if value.trim().is_empty() {
707 return Err(IdentityNetworkContractError::MissingField(field));
708 }
709 Ok(())
710}
711
712fn ensure_unique_strings(
713 values: &[String],
714 field: &'static str,
715) -> Result<(), IdentityNetworkContractError> {
716 let mut seen = HashSet::new();
717 for value in values {
718 if value.trim().is_empty() {
719 return Err(IdentityNetworkContractError::MissingField(field));
720 }
721 if !seen.insert(value.as_str()) {
722 return Err(IdentityNetworkContractError::DuplicateValue(format!(
723 "{field}:{value}"
724 )));
725 }
726 }
727 Ok(())
728}
729
730fn ensure_unique_copy_values<T>(
731 values: &[T],
732 field: &'static str,
733) -> Result<(), IdentityNetworkContractError>
734where
735 T: Copy + Eq + std::hash::Hash + std::fmt::Debug,
736{
737 let mut seen = HashSet::new();
738 for value in values {
739 if !seen.insert(*value) {
740 return Err(IdentityNetworkContractError::DuplicateValue(format!(
741 "{field}:{value:?}"
742 )));
743 }
744 }
745 Ok(())
746}
747
748#[cfg(test)]
749mod tests {
750 use super::*;
751
752 fn hex(seed: char) -> String {
753 std::iter::repeat_n(seed, 64).collect()
754 }
755
756 fn sample_reference(
757 kind: IdentityArtifactKind,
758 schema: &str,
759 artifact_id: &str,
760 operator_id: &str,
761 seed: char,
762 ) -> IdentityArtifactReference {
763 IdentityArtifactReference {
764 kind,
765 schema: schema.to_string(),
766 artifact_id: artifact_id.to_string(),
767 operator_id: operator_id.to_string(),
768 sha256: hex(seed),
769 uri: Some(format!("https://example.com/{artifact_id}")),
770 }
771 }
772
773 fn sample_profile() -> PublicIdentityProfileArtifact {
774 PublicIdentityProfileArtifact {
775 schema: CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA.to_string(),
776 profile_id: "pip-1".to_string(),
777 issued_at: 1_710_000_000,
778 supported_subject_methods: vec![
779 IdentityDidMethod::DidChio,
780 IdentityDidMethod::DidWeb,
781 IdentityDidMethod::DidKey,
782 ],
783 supported_issuer_methods: vec![
784 IdentityDidMethod::DidChio,
785 IdentityDidMethod::DidWeb,
786 IdentityDidMethod::DidJwk,
787 ],
788 supported_credential_families: vec![
789 IdentityCredentialFamily::ChioAgentPassportJson,
790 IdentityCredentialFamily::DcSdJwt,
791 IdentityCredentialFamily::JwtVcJson,
792 ],
793 supported_proof_families: vec![
794 IdentityProofFamily::Ed25519Signature2020,
795 IdentityProofFamily::DcSdJwt,
796 IdentityProofFamily::JwtVcJson,
797 ],
798 supported_transports: vec![
799 WalletTransportMode::Oid4vpSameDevice,
800 WalletTransportMode::Oid4vpCrossDevice,
801 WalletTransportMode::Oid4vpRelay,
802 ],
803 basis_refs: vec![
804 sample_reference(
805 IdentityArtifactKind::PortableTrustProfile,
806 "chio.portable-trust-profile.v1",
807 "ptp-1",
808 "chio",
809 'a',
810 ),
811 sample_reference(
812 IdentityArtifactKind::Oid4vciIssuerMetadata,
813 "openid-credential-issuer-metadata",
814 "oid4vci-1",
815 "issuer-operator-1",
816 'b',
817 ),
818 sample_reference(
819 IdentityArtifactKind::Oid4vpVerifierMetadata,
820 "chio.oid4vp-verifier-metadata.v1",
821 "oid4vp-1",
822 "verifier-operator-1",
823 'c',
824 ),
825 ],
826 binding_policy: IdentityBindingPolicy::default(),
827 note: Some("bounded broader identity support".to_string()),
828 }
829 }
830
831 fn sample_directory_entry() -> PublicWalletDirectoryEntryArtifact {
832 PublicWalletDirectoryEntryArtifact {
833 schema: CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_SCHEMA.to_string(),
834 entry_id: "wde-1".to_string(),
835 issued_at: 1_710_000_010,
836 directory_operator_id: "wallet-operator-1".to_string(),
837 wallet_id: "wallet.example".to_string(),
838 supported_subject_methods: vec![
839 IdentityDidMethod::DidChio,
840 IdentityDidMethod::DidWeb,
841 IdentityDidMethod::DidKey,
842 ],
843 supported_issuer_methods: vec![
844 IdentityDidMethod::DidChio,
845 IdentityDidMethod::DidWeb,
846 IdentityDidMethod::DidJwk,
847 ],
848 supported_credential_families: vec![
849 IdentityCredentialFamily::DcSdJwt,
850 IdentityCredentialFamily::JwtVcJson,
851 ],
852 supported_proof_families: vec![
853 IdentityProofFamily::DcSdJwt,
854 IdentityProofFamily::JwtVcJson,
855 ],
856 discovery_ref: sample_reference(
857 IdentityArtifactKind::PublicVerifierDiscovery,
858 "chio.public-verifier-discovery.v1",
859 "pvd-1",
860 "verifier-operator-1",
861 'd',
862 ),
863 profile_ref: sample_reference(
864 IdentityArtifactKind::PublicIdentityProfile,
865 CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA,
866 "pip-1",
867 "chio",
868 'e',
869 ),
870 metadata_url: "https://wallet.example/.well-known/openid-credential-wallet".to_string(),
871 request_uri_prefix: "https://wallet.example/wallet-exchanges/".to_string(),
872 lookup_guardrails: WalletDirectoryLookupGuardrails::default(),
873 note: Some("verifier-scoped public wallet routing".to_string()),
874 }
875 }
876
877 fn sample_routing_manifest() -> PublicWalletRoutingManifestArtifact {
878 PublicWalletRoutingManifestArtifact {
879 schema: CHIO_PUBLIC_WALLET_ROUTING_MANIFEST_SCHEMA.to_string(),
880 route_id: "wrm-1".to_string(),
881 issued_at: 1_710_000_020,
882 directory_entry_ref: sample_reference(
883 IdentityArtifactKind::PublicWalletDirectoryEntry,
884 CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_SCHEMA,
885 "wde-1",
886 "wallet-operator-1",
887 'f',
888 ),
889 verifier_id: "https://verifier.example.com".to_string(),
890 response_uri_prefix:
891 "https://verifier.example.com/v1/public/passport/wallet-exchanges/".to_string(),
892 relay_url: "https://wallet.example/relay".to_string(),
893 transport_modes: vec![
894 WalletTransportMode::Oid4vpSameDevice,
895 WalletTransportMode::Oid4vpCrossDevice,
896 WalletTransportMode::Oid4vpRelay,
897 ],
898 requires_signed_request_object: true,
899 requires_replay_anchors: true,
900 routing_guardrails: WalletRoutingGuardrails::default(),
901 note: Some("bounded public wallet routing".to_string()),
902 }
903 }
904
905 fn sample_matrix() -> IdentityInteropQualificationMatrix {
906 IdentityInteropQualificationMatrix {
907 schema: CHIO_IDENTITY_INTEROP_QUALIFICATION_MATRIX_SCHEMA.to_string(),
908 profile_ref: sample_reference(
909 IdentityArtifactKind::PublicIdentityProfile,
910 CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA,
911 "pip-1",
912 "chio",
913 'a',
914 ),
915 directory_entry_ref: sample_reference(
916 IdentityArtifactKind::PublicWalletDirectoryEntry,
917 CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_SCHEMA,
918 "wde-1",
919 "wallet-operator-1",
920 'b',
921 ),
922 routing_manifest_ref: sample_reference(
923 IdentityArtifactKind::PublicWalletRoutingManifest,
924 CHIO_PUBLIC_WALLET_ROUTING_MANIFEST_SCHEMA,
925 "wrm-1",
926 "wallet-operator-1",
927 'c',
928 ),
929 cases: vec![
930 IdentityInteropQualificationCase {
931 id: "method-support".to_string(),
932 name: "Unsupported DID methods fail closed".to_string(),
933 requirement_ids: vec!["IDMAX-01".to_string()],
934 scenario: IdentityInteropScenarioKind::UnsupportedDidMethod,
935 expected_outcome: IdentityQualificationOutcome::FailClosed,
936 observed_outcome: IdentityQualificationOutcome::FailClosed,
937 notes: vec!["Unsupported method families are rejected explicitly".to_string()],
938 },
939 IdentityInteropQualificationCase {
940 id: "directory-poisoning".to_string(),
941 name: "Directory poisoning fails closed".to_string(),
942 requirement_ids: vec!["IDMAX-02".to_string()],
943 scenario: IdentityInteropScenarioKind::DirectoryPoisoning,
944 expected_outcome: IdentityQualificationOutcome::FailClosed,
945 observed_outcome: IdentityQualificationOutcome::FailClosed,
946 notes: vec!["Directory entries stay verifier-bound and non-ambient".to_string()],
947 },
948 IdentityInteropQualificationCase {
949 id: "multi-wallet".to_string(),
950 name: "Multi-wallet selection remains replay safe".to_string(),
951 requirement_ids: vec!["IDMAX-03".to_string()],
952 scenario: IdentityInteropScenarioKind::MultiWalletSelection,
953 expected_outcome: IdentityQualificationOutcome::Pass,
954 observed_outcome: IdentityQualificationOutcome::Pass,
955 notes: vec![
956 "Supported multi-wallet routing completes inside explicit guardrails"
957 .to_string(),
958 ],
959 },
960 IdentityInteropQualificationCase {
961 id: "cross-operator-boundary".to_string(),
962 name: "Cross-operator issuer mismatch fails closed".to_string(),
963 requirement_ids: vec!["IDMAX-04".to_string()],
964 scenario: IdentityInteropScenarioKind::CrossOperatorIssuerMismatch,
965 expected_outcome: IdentityQualificationOutcome::FailClosed,
966 observed_outcome: IdentityQualificationOutcome::FailClosed,
967 notes: vec!["Issuer and admission boundaries remain explicit".to_string()],
968 },
969 IdentityInteropQualificationCase {
970 id: "release-closure".to_string(),
971 name: "Release boundary stays honest".to_string(),
972 requirement_ids: vec!["IDMAX-05".to_string()],
973 scenario: IdentityInteropScenarioKind::ReleaseBoundaryClosure,
974 expected_outcome: IdentityQualificationOutcome::Pass,
975 observed_outcome: IdentityQualificationOutcome::Pass,
976 notes: vec!["Final public claim remains bounded and specific".to_string()],
977 },
978 ],
979 }
980 }
981
982 #[test]
983 fn profile_validation_rejects_remaining_schema_reference_and_policy_errors() {
984 let mut profile = sample_profile();
985 profile.schema = "chio.public-identity-profile.v9".to_string();
986 assert!(matches!(
987 validate_public_identity_profile(&profile),
988 Err(IdentityNetworkContractError::UnsupportedSchema(_))
989 ));
990
991 let mut profile = sample_profile();
992 profile.profile_id.clear();
993 assert!(matches!(
994 validate_public_identity_profile(&profile),
995 Err(IdentityNetworkContractError::MissingField("profile_id"))
996 ));
997
998 let mut profile = sample_profile();
999 profile
1000 .supported_subject_methods
1001 .push(IdentityDidMethod::DidChio);
1002 assert!(matches!(
1003 validate_public_identity_profile(&profile),
1004 Err(IdentityNetworkContractError::DuplicateValue(_))
1005 ));
1006
1007 let mut profile = sample_profile();
1008 profile
1009 .supported_credential_families
1010 .push(IdentityCredentialFamily::DcSdJwt);
1011 assert!(matches!(
1012 validate_public_identity_profile(&profile),
1013 Err(IdentityNetworkContractError::DuplicateValue(_))
1014 ));
1015
1016 let mut profile = sample_profile();
1017 profile
1018 .supported_proof_families
1019 .push(IdentityProofFamily::DcSdJwt);
1020 assert!(matches!(
1021 validate_public_identity_profile(&profile),
1022 Err(IdentityNetworkContractError::DuplicateValue(_))
1023 ));
1024
1025 let mut profile = sample_profile();
1026 profile
1027 .supported_transports
1028 .push(WalletTransportMode::Oid4vpRelay);
1029 assert!(matches!(
1030 validate_public_identity_profile(&profile),
1031 Err(IdentityNetworkContractError::DuplicateValue(_))
1032 ));
1033
1034 let mut profile = sample_profile();
1035 profile.basis_refs.clear();
1036 assert!(matches!(
1037 validate_public_identity_profile(&profile),
1038 Err(IdentityNetworkContractError::MissingField("basis_refs"))
1039 ));
1040
1041 let mut profile = sample_profile();
1042 profile.binding_policy.requires_chio_issuer_provenance = false;
1043 assert!(matches!(
1044 validate_public_identity_profile(&profile),
1045 Err(IdentityNetworkContractError::InvalidProfile(_))
1046 ));
1047
1048 let mut profile = sample_profile();
1049 profile
1050 .binding_policy
1051 .requires_same_subject_across_credentials = false;
1052 assert!(matches!(
1053 validate_public_identity_profile(&profile),
1054 Err(IdentityNetworkContractError::InvalidProfile(_))
1055 ));
1056
1057 let mut profile = sample_profile();
1058 profile.binding_policy.manual_subject_rebinding_required = false;
1059 assert!(matches!(
1060 validate_public_identity_profile(&profile),
1061 Err(IdentityNetworkContractError::InvalidProfile(_))
1062 ));
1063
1064 let mut profile = sample_profile();
1065 profile.binding_policy.unsupported_mappings_fail_closed = false;
1066 assert!(matches!(
1067 validate_public_identity_profile(&profile),
1068 Err(IdentityNetworkContractError::InvalidProfile(_))
1069 ));
1070
1071 let mut profile = sample_profile();
1072 profile.supported_subject_methods = vec![IdentityDidMethod::DidWeb];
1073 assert!(matches!(
1074 validate_public_identity_profile(&profile),
1075 Err(IdentityNetworkContractError::InvalidProfile(_))
1076 ));
1077
1078 let mut profile = sample_profile();
1079 profile.supported_credential_families = vec![IdentityCredentialFamily::DcSdJwt];
1080 assert!(matches!(
1081 validate_public_identity_profile(&profile),
1082 Err(IdentityNetworkContractError::InvalidProfile(_))
1083 ));
1084
1085 let mut profile = sample_profile();
1086 profile.supported_credential_families =
1087 vec![IdentityCredentialFamily::ChioAgentPassportJson];
1088 assert!(matches!(
1089 validate_public_identity_profile(&profile),
1090 Err(IdentityNetworkContractError::InvalidProfile(_))
1091 ));
1092
1093 let mut profile = sample_profile();
1094 profile.basis_refs.remove(0);
1095 assert!(matches!(
1096 validate_public_identity_profile(&profile),
1097 Err(IdentityNetworkContractError::InvalidProfile(_))
1098 ));
1099
1100 let mut profile = sample_profile();
1101 profile.basis_refs[0].sha256 = "abcd".to_string();
1102 assert!(matches!(
1103 validate_public_identity_profile(&profile),
1104 Err(IdentityNetworkContractError::InvalidReference(_))
1105 ));
1106 }
1107
1108 #[test]
1109 fn profile_requires_chio_anchor_and_broader_support() {
1110 let mut profile = sample_profile();
1111 profile.binding_policy.requires_chio_subject_provenance = false;
1112 let error = validate_public_identity_profile(&profile)
1113 .expect_err("missing chio subject provenance");
1114 assert!(matches!(
1115 error,
1116 IdentityNetworkContractError::InvalidProfile(_)
1117 ));
1118
1119 let mut profile = sample_profile();
1120 profile.supported_subject_methods = vec![IdentityDidMethod::DidChio];
1121 profile.supported_issuer_methods = vec![IdentityDidMethod::DidChio];
1122 let error = validate_public_identity_profile(&profile).expect_err("missing broader method");
1123 assert!(matches!(
1124 error,
1125 IdentityNetworkContractError::InvalidProfile(_)
1126 ));
1127 }
1128
1129 #[test]
1130 fn wallet_directory_requires_verifier_guardrails() {
1131 let mut entry = sample_directory_entry();
1132 entry.lookup_guardrails.requires_explicit_verifier_binding = false;
1133 let error = validate_public_wallet_directory_entry(&entry)
1134 .expect_err("missing verifier binding guardrail");
1135 assert!(matches!(
1136 error,
1137 IdentityNetworkContractError::InvalidDirectoryEntry(_)
1138 ));
1139 }
1140
1141 #[test]
1142 fn wallet_directory_validation_rejects_remaining_reference_url_and_guardrail_errors() {
1143 let mut entry = sample_directory_entry();
1144 entry.schema = "chio.public-wallet-directory-entry.v9".to_string();
1145 assert!(matches!(
1146 validate_public_wallet_directory_entry(&entry),
1147 Err(IdentityNetworkContractError::UnsupportedSchema(_))
1148 ));
1149
1150 let mut entry = sample_directory_entry();
1151 entry.entry_id.clear();
1152 assert!(matches!(
1153 validate_public_wallet_directory_entry(&entry),
1154 Err(IdentityNetworkContractError::MissingField("entry_id"))
1155 ));
1156
1157 let mut entry = sample_directory_entry();
1158 entry
1159 .supported_subject_methods
1160 .push(IdentityDidMethod::DidChio);
1161 assert!(matches!(
1162 validate_public_wallet_directory_entry(&entry),
1163 Err(IdentityNetworkContractError::DuplicateValue(_))
1164 ));
1165
1166 let mut entry = sample_directory_entry();
1167 entry.discovery_ref.kind = IdentityArtifactKind::PublicIdentityProfile;
1168 assert!(matches!(
1169 validate_public_wallet_directory_entry(&entry),
1170 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1171 ));
1172
1173 let mut entry = sample_directory_entry();
1174 entry.profile_ref.kind = IdentityArtifactKind::PortableTrustProfile;
1175 assert!(matches!(
1176 validate_public_wallet_directory_entry(&entry),
1177 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1178 ));
1179
1180 let mut entry = sample_directory_entry();
1181 entry.metadata_url = "http://wallet.example/metadata".to_string();
1182 assert!(matches!(
1183 validate_public_wallet_directory_entry(&entry),
1184 Err(IdentityNetworkContractError::InvalidReference(_))
1185 ));
1186
1187 let mut entry = sample_directory_entry();
1188 entry.request_uri_prefix = "https://".to_string();
1189 assert!(matches!(
1190 validate_public_wallet_directory_entry(&entry),
1191 Err(IdentityNetworkContractError::InvalidReference(_))
1192 ));
1193
1194 let mut entry = sample_directory_entry();
1195 entry
1196 .lookup_guardrails
1197 .requires_manual_subject_binding_review = false;
1198 assert!(matches!(
1199 validate_public_wallet_directory_entry(&entry),
1200 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1201 ));
1202
1203 let mut entry = sample_directory_entry();
1204 entry.lookup_guardrails.reject_ambient_directory_trust = false;
1205 assert!(matches!(
1206 validate_public_wallet_directory_entry(&entry),
1207 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1208 ));
1209
1210 let mut entry = sample_directory_entry();
1211 entry.lookup_guardrails.fail_closed_on_unknown_wallet_family = false;
1212 assert!(matches!(
1213 validate_public_wallet_directory_entry(&entry),
1214 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1215 ));
1216
1217 let mut entry = sample_directory_entry();
1218 entry.supported_subject_methods = vec![IdentityDidMethod::DidChio];
1219 assert!(matches!(
1220 validate_public_wallet_directory_entry(&entry),
1221 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1222 ));
1223
1224 let mut entry = sample_directory_entry();
1225 entry.supported_credential_families = vec![IdentityCredentialFamily::ChioAgentPassportJson];
1226 assert!(matches!(
1227 validate_public_wallet_directory_entry(&entry),
1228 Err(IdentityNetworkContractError::InvalidDirectoryEntry(_))
1229 ));
1230 }
1231
1232 #[test]
1233 fn routing_manifest_requires_all_transports() {
1234 let mut manifest = sample_routing_manifest();
1235 manifest.transport_modes = vec![
1236 WalletTransportMode::Oid4vpSameDevice,
1237 WalletTransportMode::Oid4vpCrossDevice,
1238 ];
1239 let error = validate_public_wallet_routing_manifest(&manifest)
1240 .expect_err("missing relay transport");
1241 assert!(matches!(
1242 error,
1243 IdentityNetworkContractError::DuplicateValue(_)
1244 ));
1245 }
1246
1247 #[test]
1248 fn routing_manifest_validation_rejects_remaining_guardrails_and_reference_errors() {
1249 let mut manifest = sample_routing_manifest();
1250 manifest.schema = "chio.public-wallet-routing-manifest.v9".to_string();
1251 assert!(matches!(
1252 validate_public_wallet_routing_manifest(&manifest),
1253 Err(IdentityNetworkContractError::UnsupportedSchema(_))
1254 ));
1255
1256 let mut manifest = sample_routing_manifest();
1257 manifest.route_id.clear();
1258 assert!(matches!(
1259 validate_public_wallet_routing_manifest(&manifest),
1260 Err(IdentityNetworkContractError::MissingField("route_id"))
1261 ));
1262
1263 let mut manifest = sample_routing_manifest();
1264 manifest.directory_entry_ref.kind = IdentityArtifactKind::PublicIdentityProfile;
1265 assert!(matches!(
1266 validate_public_wallet_routing_manifest(&manifest),
1267 Err(IdentityNetworkContractError::InvalidRouting(_))
1268 ));
1269
1270 let mut manifest = sample_routing_manifest();
1271 manifest.verifier_id = "not-a-url".to_string();
1272 assert!(matches!(
1273 validate_public_wallet_routing_manifest(&manifest),
1274 Err(IdentityNetworkContractError::InvalidReference(_))
1275 ));
1276
1277 let mut manifest = sample_routing_manifest();
1278 manifest.response_uri_prefix = "http://verifier.example.com/response".to_string();
1279 assert!(matches!(
1280 validate_public_wallet_routing_manifest(&manifest),
1281 Err(IdentityNetworkContractError::InvalidReference(_))
1282 ));
1283
1284 let mut manifest = sample_routing_manifest();
1285 manifest.relay_url = "https://".to_string();
1286 assert!(matches!(
1287 validate_public_wallet_routing_manifest(&manifest),
1288 Err(IdentityNetworkContractError::InvalidReference(_))
1289 ));
1290
1291 let mut manifest = sample_routing_manifest();
1292 manifest.routing_guardrails.requires_replay_safe_exchange = false;
1293 assert!(matches!(
1294 validate_public_wallet_routing_manifest(&manifest),
1295 Err(IdentityNetworkContractError::InvalidRouting(_))
1296 ));
1297
1298 let mut manifest = sample_routing_manifest();
1299 manifest.routing_guardrails.fail_closed_on_subject_mismatch = false;
1300 assert!(matches!(
1301 validate_public_wallet_routing_manifest(&manifest),
1302 Err(IdentityNetworkContractError::InvalidRouting(_))
1303 ));
1304
1305 let mut manifest = sample_routing_manifest();
1306 manifest
1307 .routing_guardrails
1308 .fail_closed_on_cross_operator_issuer_mismatch = false;
1309 assert!(matches!(
1310 validate_public_wallet_routing_manifest(&manifest),
1311 Err(IdentityNetworkContractError::InvalidRouting(_))
1312 ));
1313
1314 let mut manifest = sample_routing_manifest();
1315 manifest.requires_signed_request_object = false;
1316 assert!(matches!(
1317 validate_public_wallet_routing_manifest(&manifest),
1318 Err(IdentityNetworkContractError::InvalidRouting(_))
1319 ));
1320
1321 let mut manifest = sample_routing_manifest();
1322 manifest.requires_replay_anchors = false;
1323 assert!(matches!(
1324 validate_public_wallet_routing_manifest(&manifest),
1325 Err(IdentityNetworkContractError::InvalidRouting(_))
1326 ));
1327 }
1328
1329 #[test]
1330 fn qualification_matrix_requires_requirement_coverage() {
1331 let mut matrix = sample_matrix();
1332 matrix.cases.pop();
1333 validate_identity_interop_qualification_matrix(&matrix)
1334 .expect_err("missing requirement coverage");
1335 }
1336
1337 #[test]
1338 fn qualification_matrix_rejects_remaining_reference_and_case_errors() {
1339 let mut matrix = sample_matrix();
1340 matrix.schema = "chio.identity-interop-qualification-matrix.v9".to_string();
1341 assert!(matches!(
1342 validate_identity_interop_qualification_matrix(&matrix),
1343 Err(IdentityNetworkContractError::UnsupportedSchema(_))
1344 ));
1345
1346 let mut matrix = sample_matrix();
1347 matrix.profile_ref.kind = IdentityArtifactKind::PublicWalletDirectoryEntry;
1348 assert!(matches!(
1349 validate_identity_interop_qualification_matrix(&matrix),
1350 Err(IdentityNetworkContractError::InvalidQualificationCase(_))
1351 ));
1352
1353 let mut matrix = sample_matrix();
1354 matrix.directory_entry_ref.kind = IdentityArtifactKind::PortableTrustProfile;
1355 assert!(matches!(
1356 validate_identity_interop_qualification_matrix(&matrix),
1357 Err(IdentityNetworkContractError::InvalidQualificationCase(_))
1358 ));
1359
1360 let mut matrix = sample_matrix();
1361 matrix.routing_manifest_ref.kind = IdentityArtifactKind::PublicIdentityProfile;
1362 assert!(matches!(
1363 validate_identity_interop_qualification_matrix(&matrix),
1364 Err(IdentityNetworkContractError::InvalidQualificationCase(_))
1365 ));
1366
1367 let mut matrix = sample_matrix();
1368 matrix.cases.clear();
1369 assert!(matches!(
1370 validate_identity_interop_qualification_matrix(&matrix),
1371 Err(IdentityNetworkContractError::MissingField("cases"))
1372 ));
1373
1374 let mut matrix = sample_matrix();
1375 matrix.cases[0].id.clear();
1376 assert!(matches!(
1377 validate_identity_interop_qualification_matrix(&matrix),
1378 Err(IdentityNetworkContractError::MissingField("case.id"))
1379 ));
1380
1381 let mut matrix = sample_matrix();
1382 matrix.cases[0].name.clear();
1383 assert!(matches!(
1384 validate_identity_interop_qualification_matrix(&matrix),
1385 Err(IdentityNetworkContractError::MissingField("case.name"))
1386 ));
1387
1388 let mut matrix = sample_matrix();
1389 matrix.cases[0].observed_outcome = IdentityQualificationOutcome::Pass;
1390 assert!(matches!(
1391 validate_identity_interop_qualification_matrix(&matrix),
1392 Err(IdentityNetworkContractError::InvalidQualificationCase(_))
1393 ));
1394
1395 let mut matrix = sample_matrix();
1396 matrix.cases[0].requirement_ids.push("IDMAX-01".to_string());
1397 assert!(matches!(
1398 validate_identity_interop_qualification_matrix(&matrix),
1399 Err(IdentityNetworkContractError::DuplicateValue(_))
1400 ));
1401
1402 let mut matrix = sample_matrix();
1403 matrix.cases[0].notes.push(" ".to_string());
1404 assert!(matches!(
1405 validate_identity_interop_qualification_matrix(&matrix),
1406 Err(IdentityNetworkContractError::MissingField("case.notes"))
1407 ));
1408
1409 let mut matrix = sample_matrix();
1410 matrix.cases.push(matrix.cases[0].clone());
1411 assert!(matches!(
1412 validate_identity_interop_qualification_matrix(&matrix),
1413 Err(IdentityNetworkContractError::DuplicateValue(_))
1414 ));
1415 }
1416
1417 #[test]
1418 fn identity_helper_validators_cover_remaining_reference_edges() {
1419 let mut reference = sample_reference(
1420 IdentityArtifactKind::PublicIdentityProfile,
1421 CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA,
1422 "pip-1",
1423 "chio",
1424 'j',
1425 );
1426 reference.schema.clear();
1427 assert!(matches!(
1428 validate_identity_artifact_reference(&reference),
1429 Err(IdentityNetworkContractError::MissingField(
1430 "reference.schema"
1431 ))
1432 ));
1433
1434 let mut reference = sample_reference(
1435 IdentityArtifactKind::PublicIdentityProfile,
1436 CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA,
1437 "pip-1",
1438 "chio",
1439 'k',
1440 );
1441 reference.artifact_id.clear();
1442 assert!(matches!(
1443 validate_identity_artifact_reference(&reference),
1444 Err(IdentityNetworkContractError::MissingField(
1445 "reference.artifact_id"
1446 ))
1447 ));
1448
1449 let mut reference = sample_reference(
1450 IdentityArtifactKind::PublicIdentityProfile,
1451 CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA,
1452 "pip-1",
1453 "chio",
1454 'l',
1455 );
1456 reference.operator_id.clear();
1457 assert!(matches!(
1458 validate_identity_artifact_reference(&reference),
1459 Err(IdentityNetworkContractError::MissingField(
1460 "reference.operator_id"
1461 ))
1462 ));
1463
1464 assert!(matches!(
1465 validate_https_url("mailto:test@example.com", "reference.uri"),
1466 Err(IdentityNetworkContractError::InvalidReference(_))
1467 ));
1468 assert!(matches!(
1469 validate_https_url("https://", "reference.uri"),
1470 Err(IdentityNetworkContractError::InvalidReference(_))
1471 ));
1472 assert!(matches!(
1473 validate_hex_digest("zzzz", "reference.sha256"),
1474 Err(IdentityNetworkContractError::InvalidReference(_))
1475 ));
1476 assert!(!contains_non_chio_method(&[IdentityDidMethod::DidChio]));
1477 assert!(contains_non_chio_method(&[
1478 IdentityDidMethod::DidChio,
1479 IdentityDidMethod::DidWeb,
1480 ]));
1481
1482 assert!(matches!(
1483 ensure_required_transports(
1484 &[
1485 WalletTransportMode::Oid4vpSameDevice,
1486 WalletTransportMode::Oid4vpSameDevice,
1487 WalletTransportMode::Oid4vpRelay,
1488 ],
1489 "transport_modes",
1490 ),
1491 Err(IdentityNetworkContractError::DuplicateValue(_))
1492 ));
1493 assert!(matches!(
1494 ensure_refs_present(&[], "basis_refs"),
1495 Err(IdentityNetworkContractError::MissingField("basis_refs"))
1496 ));
1497
1498 let duplicate_refs = vec![
1499 sample_reference(
1500 IdentityArtifactKind::PublicIdentityProfile,
1501 CHIO_PUBLIC_IDENTITY_PROFILE_SCHEMA,
1502 "pip-1",
1503 "chio",
1504 'm',
1505 ),
1506 sample_reference(
1507 IdentityArtifactKind::PublicWalletDirectoryEntry,
1508 CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_SCHEMA,
1509 "pip-1",
1510 "chio",
1511 'n',
1512 ),
1513 ];
1514 assert!(matches!(
1515 ensure_refs_present(&duplicate_refs, "basis_refs"),
1516 Err(IdentityNetworkContractError::DuplicateValue(_))
1517 ));
1518 }
1519
1520 #[test]
1521 fn reference_artifacts_parse_and_validate() {
1522 let profile: PublicIdentityProfileArtifact = serde_json::from_str(include_str!(
1523 "../../../docs/standards/CHIO_PUBLIC_IDENTITY_PROFILE.json"
1524 ))
1525 .unwrap();
1526 validate_public_identity_profile(&profile).unwrap();
1527
1528 let entry: PublicWalletDirectoryEntryArtifact = serde_json::from_str(include_str!(
1529 "../../../docs/standards/CHIO_PUBLIC_WALLET_DIRECTORY_ENTRY_EXAMPLE.json"
1530 ))
1531 .unwrap();
1532 validate_public_wallet_directory_entry(&entry).unwrap();
1533
1534 let routing: PublicWalletRoutingManifestArtifact = serde_json::from_str(include_str!(
1535 "../../../docs/standards/CHIO_PUBLIC_WALLET_ROUTING_EXAMPLE.json"
1536 ))
1537 .unwrap();
1538 validate_public_wallet_routing_manifest(&routing).unwrap();
1539
1540 let matrix: IdentityInteropQualificationMatrix = serde_json::from_str(include_str!(
1541 "../../../docs/standards/CHIO_PUBLIC_IDENTITY_QUALIFICATION_MATRIX.json"
1542 ))
1543 .unwrap();
1544 validate_identity_interop_qualification_matrix(&matrix).unwrap();
1545 }
1546}