Skip to main content

chio_core/
identity_network.rs

1//! Chio public identity and wallet network contracts.
2//!
3//! These contracts widen Chio's outward-facing identity claim without replacing
4//! Chio's native `did:chio` provenance anchor. Broader DID methods, credential
5//! families, wallet directory entries, and routing manifests remain explicit
6//! and fail closed.
7
8use 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}