From dcf24dcecd887055385f709becbbc7152cb38599 Mon Sep 17 00:00:00 2001 From: Vladimir Burdukov Date: Tue, 21 Apr 2026 12:12:42 +0300 Subject: [PATCH] feat(rust): IdentityAttribute enum + IdentityCheck preset + identity_attested field --- Cargo.lock | 54 ++-- Cargo.toml | 2 +- rust/core/src/bridge.rs | 392 ++++++++++++++++++++---- rust/core/src/preset.rs | 339 +++++++++++++------- rust/core/src/types.rs | 204 ++++++++++++ rust/core/src/wasm_bindings.rs | 36 ++- swift/Tests/IDKitTests/IDKitTests.swift | 13 +- 7 files changed, 830 insertions(+), 210 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9315589a..4917f822 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1124,11 +1124,11 @@ dependencies = [ [[package]] name = "askama" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4744ed2eef2645831b441d8f5459689ade2ab27c854488fbab1fbe94fce1a7" +checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4" dependencies = [ - "askama_derive 0.13.1", + "askama_derive 0.14.0", "itoa", "percent-encoding", "serde", @@ -1150,11 +1150,11 @@ dependencies = [ [[package]] name = "askama_derive" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d661e0f57be36a5c14c48f78d09011e67e0cb618f269cca9f2fd8d15b68c46ac" +checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f" dependencies = [ - "askama_parser 0.13.0", + "askama_parser 0.14.0", "basic-toml", "memchr", "proc-macro2", @@ -1193,9 +1193,9 @@ dependencies = [ [[package]] name = "askama_parser" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf315ce6524c857bb129ff794935cf6d42c82a6cff60526fe2a63593de4d0d4f" +checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358" dependencies = [ "memchr", "serde", @@ -4073,9 +4073,9 @@ dependencies = [ [[package]] name = "siphasher" -version = "0.3.11" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" [[package]] name = "slab" @@ -4854,9 +4854,9 @@ checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" [[package]] name = "uniffi" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c866f627c3f04c3df068b68bb2d725492caaa539dd313e2a9d26bb85b1a32f4e" +checksum = "dc5f2297ee5b893405bed1a6929faec4713a061df158ecf5198089f23910d470" dependencies = [ "anyhow", "camino", @@ -4877,12 +4877,12 @@ dependencies = [ [[package]] name = "uniffi_bindgen" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c8ca600167641ebe7c8ba9254af40492dda3397c528cc3b2f511bd23e8541a5" +checksum = "8bc0c60a9607e7ab77a2ad47ec5530178015014839db25af7512447d2238016c" dependencies = [ "anyhow", - "askama 0.13.1", + "askama 0.14.0", "camino", "cargo_metadata", "fs-err", @@ -4903,9 +4903,9 @@ dependencies = [ [[package]] name = "uniffi_core" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7a5a038ebffe8f4cf91416b154ef3c2468b18e828b7009e01b1b99938089f9" +checksum = "77baf5d539fe2e1ad6805e942dbc5dbdeb2b83eb5f2b3a6535d422ca4b02a12f" dependencies = [ "anyhow", "bytes", @@ -4915,9 +4915,9 @@ dependencies = [ [[package]] name = "uniffi_internal_macros" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c2a6f93e7b73726e2015696ece25ca0ac5a5f1cf8d6a7ab5214dd0a01d2edf" +checksum = "b4b42137524f4be6400fcaca9d02c1d4ecb6ad917e4013c0b93235526d8396e5" dependencies = [ "anyhow", "indexmap 2.13.0", @@ -4928,9 +4928,9 @@ dependencies = [ [[package]] name = "uniffi_macros" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64c6309fc36c7992afc03bc0c5b059c656bccbef3f2a4bc362980017f8936141" +checksum = "d9273ec45330d8fe9a3701b7b983cea7a4e218503359831967cb95d26b873561" dependencies = [ "camino", "fs-err", @@ -4945,9 +4945,9 @@ dependencies = [ [[package]] name = "uniffi_meta" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a138823392dba19b0aa494872689f97d0ee157de5852e2bec157ce6de9cdc22" +checksum = "431d2f443e7828a6c29d188de98b6771a6491ee98bba2d4372643bf93f988a18" dependencies = [ "anyhow", "siphasher", @@ -4957,9 +4957,9 @@ dependencies = [ [[package]] name = "uniffi_pipeline" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c27c4b515d25f8e53cc918e238c39a79c3144a40eaf2e51c4a7958973422c29" +checksum = "761ef74f6175e15603d0424cc5f98854c5baccfe7bf4ccb08e5816f9ab8af689" dependencies = [ "anyhow", "heck", @@ -4970,9 +4970,9 @@ dependencies = [ [[package]] name = "uniffi_udl" -version = "0.30.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0adacdd848aeed7af4f5af7d2f621d5e82531325d405e29463482becfdeafca" +checksum = "68773ec0e1c067b6505a73bbf6a5782f31a7f9209333a0df97b87565c46bf370" dependencies = [ "anyhow", "textwrap", diff --git a/Cargo.toml b/Cargo.toml index c93b7f0a..ce2f0727 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ ruint = "1.11.1" taceo-oprf = { version = "0.8", default-features = false, features = ["types"] } # UniFFI -uniffi = "0.30" +uniffi = "0.31" # WASM wasm-bindgen = "0.2" diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index ef44a86d..bc090fd1 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -6,7 +6,8 @@ use crate::{ crypto::{base64_decode, base64_encode, decrypt, encrypt}, error::{AppError, Error, Result}, types::{ - AppId, BridgeResponseV1, BridgeUrl, IDKitResult, ResponseItem, RpContext, VerificationLevel, + AppId, BridgeResponseV1, BridgeUrl, IDKitResult, IdentityAttribute, ResponseItem, + RpContext, VerificationLevel, }, ConstraintNode, Signal, }; @@ -111,6 +112,11 @@ struct BridgeRequestPayload { #[serde(skip_serializing_if = "Option::is_none")] proof_request: Option, + /// Optional identity attribute filters for identity-attestation presets. + /// Only present for World ID 4.0 identity check requests. + #[serde(skip_serializing_if = "Option::is_none")] + identity_attributes: Option>, + /// Whether to accept legacy (v3) proofs as fallback. /// - `true`: Accept both v3 and v4 proofs. Use during migration. /// - `false`: Only accept v4 proofs. Use after migration cutoff or for new apps. @@ -221,6 +227,12 @@ enum BridgeResponse { /// World ID 4.0 protocol response ResponseV2(world_id_primitives::ProofResponse), + /// World ID 4.0 protocol response with extensions + ResponseV2_1 { + proof_response: world_id_primitives::ProofResponse, + identity_attested: Option, + }, + /// Multi-credential legacy: bridge sends v3 proofs via `legacy_responses` MultiLegacyResponse { legacy_responses: Vec, @@ -265,6 +277,8 @@ pub struct BridgeConnectionParams { pub return_to: Option, /// Optional environment override (defaults to Production when not specified) pub environment: Option, + /// Present only on World ID 4.0 requests created from `IdentityCheck` presets + pub identity_attributes: Option>, } /// A helper struct to cache the signal hashes of a request @@ -440,6 +454,7 @@ pub fn build_request_payload( action: action_str, action_description: params.action_description.clone(), proof_request, + identity_attributes: params.identity_attributes.clone(), verification_level: params.legacy_verification_level, signal: legacy_signal_hash, timestamp, @@ -667,49 +682,12 @@ impl BridgeConnection { match bridge_response { BridgeResponse::Error { error_code } => Ok(Status::Failed(error_code)), BridgeResponse::ResponseV2(proof_response) => { - // Check for protocol-level error - if let Some(error_code) = proof_response.error.as_deref() { - return Ok(Status::Failed(AppError::from_code(error_code))); - } - - let responses: Vec = proof_response - .responses - .into_iter() - .map(|item| { - let signal_hash = self.cached_signal_hashes.get(&item.identifier); - ResponseItem::from_protocol_item(item, signal_hash) - }) - .collect::>>()?; - - Ok(Status::Confirmed( - if let Some(session_id) = proof_response.session_id { - IDKitResult::new_session( - self.nonce.clone(), - serde_json::to_value(session_id)? - .as_str() - .ok_or_else(|| { - Error::InvalidConfiguration( - "SessionId did not serialize as a string" - .to_string(), - ) - })? - .to_owned(), - self.action_description.clone(), - responses, - self.environment.as_ref(), - ) - } else { - IDKitResult::new( - "4.0", - self.nonce.clone(), - self.action.clone(), - self.action_description.clone(), - responses, - self.environment.as_ref(), - ) - }, - )) + self.handle_bridge_v2_response(proof_response, None) } + BridgeResponse::ResponseV2_1 { + proof_response, + identity_attested, + } => self.handle_bridge_v2_response(proof_response, identity_attested), BridgeResponse::MultiLegacyResponse { legacy_responses } => { let responses: Vec = legacy_responses .into_iter() @@ -752,6 +730,56 @@ impl BridgeConnection { } } + fn handle_bridge_v2_response( + &self, + proof_response: world_id_primitives::ProofResponse, + identity_attested: Option, + ) -> Result { + // Check for protocol-level error + if let Some(error_code) = proof_response.error.as_deref() { + return Ok(Status::Failed(AppError::from_code(error_code))); + } + + let responses: Vec = proof_response + .responses + .into_iter() + .map(|item| { + let signal_hash = self.cached_signal_hashes.get(&item.identifier); + ResponseItem::from_protocol_item(item, signal_hash) + }) + .collect::>>()?; + + Ok(Status::Confirmed( + if let Some(session_id) = proof_response.session_id { + IDKitResult::new_session( + self.nonce.clone(), + serde_json::to_value(session_id)? + .as_str() + .ok_or_else(|| { + Error::InvalidConfiguration( + "SessionId did not serialize as a string".to_string(), + ) + })? + .to_owned(), + self.action_description.clone(), + responses, + self.environment.as_ref(), + ) + } else { + let mut result = IDKitResult::new( + "4.0", + self.nonce.clone(), + self.action.clone(), + self.action_description.clone(), + responses, + self.environment.as_ref(), + ); + result.identity_attested = identity_attested; + result + }, + )) + } + /// Returns the request ID for this request #[must_use] pub const fn request_id(&self) -> Uuid { @@ -869,6 +897,7 @@ impl IDKitConfig { override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, + identity_attributes: None, }) } Self::CreateSession(config) => { @@ -893,6 +922,7 @@ impl IDKitConfig { override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, + identity_attributes: None, }) } Self::ProveSession { session_id, config } => { @@ -919,6 +949,7 @@ impl IDKitConfig { override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, + identity_attributes: None, }) } } @@ -937,7 +968,14 @@ impl IDKitConfig { }); } - let (_constraints, legacy_verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); + + // Default to Device so existing World App versions can still parse + // the bridge payload. V4 requests use `proof_request` for real + // credential selection; this field is only for v3 backwards compat. + let legacy_verification_level = bridge_params + .legacy_verification_level + .unwrap_or(VerificationLevel::Device); match self { Self::Request(config) => { @@ -948,21 +986,30 @@ impl IDKitConfig { .map(|url| BridgeUrl::new(url, &app_id)) .transpose()?; + let allow_legacy_proofs = + bridge_params + .allow_legacy_proofs_override + .unwrap_or(match self { + Self::Request(config) => config.allow_legacy_proofs, + Self::CreateSession(_) | Self::ProveSession { .. } => false, + }); + Ok(BridgeConnectionParams { app_id, kind: RequestKind::Uniqueness { action: config.action.clone(), }, - constraints: None, + constraints: bridge_params.constraints, rp_context: (*config.rp_context).clone(), action_description: config.action_description.clone(), legacy_verification_level, - legacy_signal: legacy_signal.unwrap_or_default(), + legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url, - allow_legacy_proofs: config.allow_legacy_proofs, + allow_legacy_proofs, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, + identity_attributes: bridge_params.identity_attributes, }) } Self::CreateSession(config) => { @@ -976,16 +1023,17 @@ impl IDKitConfig { Ok(BridgeConnectionParams { app_id, kind: RequestKind::CreateSession, - constraints: None, + constraints: bridge_params.constraints, rp_context: (*config.rp_context).clone(), action_description: config.action_description.clone(), legacy_verification_level, - legacy_signal: legacy_signal.unwrap_or_default(), + legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, + identity_attributes: bridge_params.identity_attributes, }) } Self::ProveSession { session_id, config } => { @@ -1001,16 +1049,17 @@ impl IDKitConfig { kind: RequestKind::ProveSession { session_id: session_id.clone(), }, - constraints: None, + constraints: bridge_params.constraints, rp_context: (*config.rp_context).clone(), action_description: config.action_description.clone(), legacy_verification_level, - legacy_signal: legacy_signal.unwrap_or_default(), + legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url, allow_legacy_proofs: false, override_connect_base_url: config.override_connect_base_url.clone(), return_to: config.return_to.clone(), environment: config.environment, + identity_attributes: bridge_params.identity_attributes, }) } } @@ -1318,6 +1367,7 @@ mod tests { verification_level: VerificationLevel::Device, timestamp: None, proof_request: Some(proof_request), + identity_attributes: None, allow_legacy_proofs: false, environment: Environment::Production, }; @@ -1331,6 +1381,55 @@ mod tests { assert!(json.contains("allow_legacy_proofs")); } + #[test] + fn test_build_request_payload_serializes_identity_attributes() { + let app_id = AppId::new("app_test").unwrap(); + let rp_context = RpContext::new( + "rp_1234567890abcdef", + "0x0000000000000000000000000000000000000000000000000000000000000001", + 1_700_000_000, + 1_700_003_600, + &("0x".to_string() + &"00".repeat(64) + "1b"), + ) + .unwrap(); + let constraints = ConstraintNode::any(vec![ + ConstraintNode::item(CredentialRequest::new(CredentialType::Passport, None)), + ConstraintNode::item(CredentialRequest::new(CredentialType::Mnc, None)), + ]); + + let params = BridgeConnectionParams { + app_id, + kind: RequestKind::Uniqueness { + action: "test-action".to_string(), + }, + constraints: Some(constraints), + rp_context, + action_description: Some("Identity check".to_string()), + legacy_verification_level: VerificationLevel::Device, + legacy_signal: String::new(), + bridge_url: None, + allow_legacy_proofs: false, + override_connect_base_url: None, + return_to: None, + environment: Some(Environment::Production), + identity_attributes: Some(vec![ + IdentityAttribute::MinimumAge(21), + IdentityAttribute::Nationality("JPN".to_string()), + ]), + }; + + let payload = build_request_payload(¶ms, false).unwrap(); + + assert_eq!( + payload["identity_attributes"], + serde_json::json!([ + {"type": "minimum_age", "value": 21}, + {"type": "nationality", "value": "JPN"} + ]) + ); + assert!(payload.get("proof_request").is_some()); + } + #[test] fn test_session_id_sdk_round_trip_uses_protocol_format() { let session_id = SessionId::default(); @@ -1479,6 +1578,172 @@ mod tests { assert!(matches!(response, BridgeResponse::Error { .. })); } + // ───────────────────────────────────────────────────────────────────────── + // BridgeResponse::ResponseV2 Parsing Tests + // ───────────────────────────────────────────────────────────────────────── + + const ZERO_PROOF: &str = "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"; + const ZERO_NULLIFIER: &str = + "nil_0000000000000000000000000000000000000000000000000000000000000000"; + + #[test] + fn test_bridge_response_v2_single_uniqueness_proof() { + let json = format!( + r#"{{ + "id": "req_abc123", + "version": 1, + "responses": [{{ + "identifier": "orb", + "issuer_schema_id": 1, + "proof": "{ZERO_PROOF}", + "nullifier": "{ZERO_NULLIFIER}", + "expires_at_min": 1735689600 + }}] + }}"# + ); + + let response: BridgeResponse = serde_json::from_str(&json).unwrap(); + match response { + BridgeResponse::ResponseV2(proof_response) => { + assert_eq!(proof_response.id, "req_abc123"); + assert!(proof_response.error.is_none()); + assert_eq!(proof_response.responses.len(), 1); + assert_eq!(proof_response.responses[0].identifier, "orb"); + assert_eq!(proof_response.responses[0].issuer_schema_id, 1); + assert!(proof_response.responses[0].nullifier.is_some()); + assert!(proof_response.responses[0].session_nullifier.is_none()); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + #[test] + fn test_bridge_response_v2_multiple_credentials() { + let json = format!( + r#"{{ + "id": "req_multi", + "version": 1, + "responses": [ + {{ + "identifier": "orb", + "issuer_schema_id": 1, + "proof": "{ZERO_PROOF}", + "nullifier": "{ZERO_NULLIFIER}", + "expires_at_min": 1735689600 + }}, + {{ + "identifier": "passport", + "issuer_schema_id": 9303, + "proof": "{ZERO_PROOF}", + "nullifier": "{ZERO_NULLIFIER}", + "expires_at_min": 1735689600 + }} + ] + }}"# + ); + + let response: BridgeResponse = serde_json::from_str(&json).unwrap(); + match response { + BridgeResponse::ResponseV2(proof_response) => { + assert_eq!(proof_response.responses.len(), 2); + assert_eq!(proof_response.responses[0].identifier, "orb"); + assert_eq!(proof_response.responses[1].identifier, "passport"); + assert_eq!(proof_response.responses[1].issuer_schema_id, 9303); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + #[test] + fn test_bridge_response_v2_protocol_level_error() { + let json = r#"{ + "id": "req_failed", + "version": 1, + "error": "credential_not_found", + "responses": [] + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::ResponseV2(proof_response) => { + assert!(proof_response.error.is_some()); + assert_eq!( + proof_response.error.as_deref(), + Some("credential_not_found") + ); + assert!(proof_response.responses.is_empty()); + } + other => panic!("Expected ResponseV2, got: {other:?}"), + } + } + + // ───────────────────────────────────────────────────────────────────────── + // BridgeResponse::MultiLegacyResponse Parsing Tests + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn test_bridge_response_multi_legacy_single() { + let json = r#"{ + "legacy_responses": [ + { + "proof": "0xproof", + "merkle_root": "0xroot", + "nullifier_hash": "0xnull", + "verification_level": "orb" + } + ] + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::MultiLegacyResponse { legacy_responses } => { + assert_eq!(legacy_responses.len(), 1); + assert_eq!( + legacy_responses[0].verification_level, + VerificationLevel::Orb + ); + assert_eq!(legacy_responses[0].proof, "0xproof"); + } + other => panic!("Expected MultiLegacyResponse, got: {other:?}"), + } + } + + #[test] + fn test_bridge_response_multi_legacy_multiple() { + let json = r#"{ + "legacy_responses": [ + { + "proof": "0xproof1", + "merkle_root": "0xroot1", + "nullifier_hash": "0xnull1", + "verification_level": "orb" + }, + { + "proof": "0xproof2", + "merkle_root": "0xroot2", + "nullifier_hash": "0xnull2", + "credential_type": "device" + } + ] + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::MultiLegacyResponse { legacy_responses } => { + assert_eq!(legacy_responses.len(), 2); + assert_eq!( + legacy_responses[0].verification_level, + VerificationLevel::Orb + ); + assert_eq!( + legacy_responses[1].verification_level, + VerificationLevel::Device + ); + } + other => panic!("Expected MultiLegacyResponse, got: {other:?}"), + } + } + #[test] fn test_bridge_response_new_error_codes_deserialization() { let invalid_signature: BridgeResponse = @@ -1525,7 +1790,7 @@ mod tests { #[test] fn test_selfie_check_legacy_preset_serializes_face_verification_level() { let preset = crate::preset::Preset::selfie_check_legacy(Some("face-signal".to_string())); - let (constraints, legacy_verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); let app_id = AppId::new("app_test").unwrap(); let signature = "0x".to_string() + &"00".repeat(64) + "1b"; @@ -1543,17 +1808,20 @@ mod tests { kind: RequestKind::Uniqueness { action: "test-action".to_string(), }, - constraints: Some(constraints), + constraints: bridge_params.constraints, rp_context, action_description: Some("Selfie check".to_string()), - legacy_verification_level, - legacy_signal: legacy_signal.unwrap_or_default(), + legacy_verification_level: bridge_params + .legacy_verification_level + .expect("this preset should return legacy_verification_level"), + legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url: None, allow_legacy_proofs: false, override_connect_base_url: None, return_to: None, environment: Some(Environment::Production), + identity_attributes: None, }; let payload = build_request_payload(¶ms, false).unwrap(); @@ -1563,7 +1831,7 @@ mod tests { #[test] fn test_device_legacy_preset_serializes_device_verification_level() { let preset = crate::preset::Preset::device_legacy(Some("device-signal".to_string())); - let (constraints, legacy_verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); let app_id = AppId::new("app_test").unwrap(); let signature = "0x".to_string() + &"00".repeat(64) + "1b"; @@ -1581,17 +1849,20 @@ mod tests { kind: RequestKind::Uniqueness { action: "test-action".to_string(), }, - constraints: Some(constraints), + constraints: bridge_params.constraints, rp_context, action_description: Some("Device check".to_string()), - legacy_verification_level, - legacy_signal: legacy_signal.unwrap_or_default(), + legacy_verification_level: bridge_params + .legacy_verification_level + .expect("this preset should return legacy_verification_level"), + legacy_signal: bridge_params.legacy_signal.unwrap_or_default(), bridge_url: None, allow_legacy_proofs: false, override_connect_base_url: None, return_to: None, environment: Some(Environment::Production), + identity_attributes: None, }; let payload = build_request_payload(¶ms, false).unwrap(); @@ -1654,6 +1925,7 @@ mod tests { override_connect_base_url: None, return_to: None, environment: None, + identity_attributes: None, }; let payload = build_native_v1_payload(¶ms).unwrap(); @@ -1698,6 +1970,7 @@ mod tests { override_connect_base_url: None, return_to: None, environment: None, + identity_attributes: None, }; // native=true includes timestamp @@ -1744,6 +2017,7 @@ mod tests { override_connect_base_url: None, return_to: None, environment: None, + identity_attributes: None, }; let cached = CachedSignalHashes::compute(¶ms); diff --git a/rust/core/src/preset.rs b/rust/core/src/preset.rs index 01588e94..808cfd12 100644 --- a/rust/core/src/preset.rs +++ b/rust/core/src/preset.rs @@ -3,7 +3,10 @@ //! Presets provide a simplified API for common credential request patterns, //! automatically handling both World ID 4.0 and 3.0 protocol formats. -use crate::types::{CredentialRequest, CredentialType, Signal, VerificationLevel}; +use crate::types::IdentityAttribute; +#[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] +use crate::types::{CredentialRequest, CredentialType, VerificationLevel}; +#[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] use crate::ConstraintNode; use serde::{Deserialize, Serialize}; @@ -73,6 +76,29 @@ pub enum Preset { /// Can be a plain string or hex-encoded ABI value (with 0x prefix). signal: Option, }, + + /// Document-based identity attestation (World ID 4.0) + /// + /// Requests passport or national identity card credentials, with optional + /// proof-of-humanity requirement. + /// + /// This preset requires World ID 4.0-compatible clients. It is not supported + /// for native v1 payloads or session flows. + IdentityCheck { + /// Identity attribute filters the verifier wants to assert. + attributes: Vec, + /// When `true`, also requires an orb-verified proof-of-humanity credential. + require_proof_of_humanity: bool, + }, +} + +#[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] +pub(crate) struct BridgeParams { + pub constraints: Option, + pub legacy_verification_level: Option, + pub legacy_signal: Option, + pub identity_attributes: Option>, + pub allow_legacy_proofs_override: Option, } impl Preset { @@ -108,69 +134,92 @@ impl Preset { Self::DeviceLegacy { signal } } + #[must_use] + pub fn identity_check( + attributes: Vec, + require_proof_of_humanity: bool, + ) -> Self { + Self::IdentityCheck { + attributes, + require_proof_of_humanity, + } + } + /// Converts the preset to bridge session parameters /// /// Returns a tuple of: /// - `ConstraintNode` - World ID 4.0 constraint tree /// - `VerificationLevel` - World ID 3.0 legacy verification level /// - `Option` - Legacy signal string (if configured) + /// - `Option>` - a list of identity attributes + /// - `Option` - override for `allow_legacy_proofs` (`None` = let caller decide) // TODO: This should be removed it was introduced to keep legacy preset compatible with proof_request // TODO: but we decided to keep legacy presets only 3.0, will tackle separately + #[cfg(any(test, feature = "ffi", feature = "wasm-bindings"))] #[must_use] - pub fn to_bridge_params(&self) -> (ConstraintNode, VerificationLevel, Option) { + pub(crate) fn into_bridge_params(self) -> BridgeParams { match self { - Self::OrbLegacy { signal } => { - let signal_opt = signal.as_ref().map(|s| Signal::from_string(s.clone())); - let orb = CredentialRequest::new(CredentialType::ProofOfHuman, signal_opt); - let constraints = ConstraintNode::Item(orb); // OrbLegacy doesn't need constraints - let legacy_verification_level = VerificationLevel::Orb; - let legacy_signal = signal.clone(); - - (constraints, legacy_verification_level, legacy_signal) - } - Self::SecureDocumentLegacy { signal } => { - let signal_opt = signal.as_ref().map(|s| Signal::from_string(s.clone())); - let orb = CredentialRequest::new(CredentialType::ProofOfHuman, signal_opt.clone()); - let passport = CredentialRequest::new(CredentialType::Passport, signal_opt); - let constraints = ConstraintNode::any(vec![ - ConstraintNode::Item(orb), - ConstraintNode::Item(passport), - ]); - let legacy_verification_level = VerificationLevel::SecureDocument; - let legacy_signal = signal.clone(); + Self::OrbLegacy { signal } => BridgeParams { + constraints: None, + legacy_verification_level: Some(VerificationLevel::Orb), + legacy_signal: signal, + identity_attributes: None, + allow_legacy_proofs_override: None, + }, + Self::SecureDocumentLegacy { signal } => BridgeParams { + constraints: None, + legacy_verification_level: Some(VerificationLevel::SecureDocument), + legacy_signal: signal, + identity_attributes: None, + allow_legacy_proofs_override: None, + }, + Self::DocumentLegacy { signal } => BridgeParams { + constraints: None, + legacy_verification_level: Some(VerificationLevel::Document), + legacy_signal: signal, + identity_attributes: None, + allow_legacy_proofs_override: None, + }, + Self::SelfieCheckLegacy { signal } => BridgeParams { + constraints: None, + legacy_verification_level: Some(VerificationLevel::Face), + legacy_signal: signal, + identity_attributes: None, + allow_legacy_proofs_override: None, + }, + Self::DeviceLegacy { signal } => BridgeParams { + constraints: None, + legacy_verification_level: Some(VerificationLevel::Device), + legacy_signal: signal, + identity_attributes: None, + allow_legacy_proofs_override: None, + }, + Self::IdentityCheck { + attributes, + require_proof_of_humanity, + } => { + let passport = CredentialRequest::new(CredentialType::Passport, None); + let mnc = CredentialRequest::new(CredentialType::Mnc, None); + let proof_of_human = CredentialRequest::new(CredentialType::ProofOfHuman, None); - (constraints, legacy_verification_level, legacy_signal) - } - Self::DocumentLegacy { signal } => { - let signal_opt = signal.as_ref().map(|s| Signal::from_string(s.clone())); - let orb = CredentialRequest::new(CredentialType::ProofOfHuman, signal_opt.clone()); - let passport = CredentialRequest::new(CredentialType::Passport, signal_opt); - let constraints = ConstraintNode::any(vec![ - ConstraintNode::Item(orb), - ConstraintNode::Item(passport), + let documents = ConstraintNode::any(vec![ + ConstraintNode::item(passport), + ConstraintNode::item(mnc), ]); - let legacy_verification_level = VerificationLevel::Document; - let legacy_signal = signal.clone(); - (constraints, legacy_verification_level, legacy_signal) - } - Self::SelfieCheckLegacy { signal } => { - let signal_opt = signal.as_ref().map(|s| Signal::from_string(s.clone())); - let face = CredentialRequest::new(CredentialType::Face, signal_opt); - let constraints = ConstraintNode::Item(face); - let legacy_verification_level = VerificationLevel::Face; - let legacy_signal = signal.clone(); - - (constraints, legacy_verification_level, legacy_signal) - } - Self::DeviceLegacy { signal } => { - let signal_opt = signal.as_ref().map(|s| Signal::from_string(s.clone())); - let orb = CredentialRequest::new(CredentialType::ProofOfHuman, signal_opt); - let constraints = ConstraintNode::Item(orb); - let legacy_verification_level = VerificationLevel::Device; - let legacy_signal = signal.clone(); - - (constraints, legacy_verification_level, legacy_signal) + let constraints = if require_proof_of_humanity { + ConstraintNode::all(vec![documents, ConstraintNode::item(proof_of_human)]) + } else { + documents + }; + + BridgeParams { + constraints: Some(constraints), + legacy_verification_level: None, + legacy_signal: None, + identity_attributes: Some(attributes), + allow_legacy_proofs_override: Some(false), + } } } } @@ -179,72 +228,155 @@ impl Preset { #[cfg(test)] mod tests { use super::*; + use crate::Signal; #[test] fn selfie_check_legacy_preset_builds_face_only_constraints_and_face_legacy_level() { let preset = Preset::selfie_check_legacy(Some("face-signal".to_string())); - let (constraints, verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); - assert_eq!(verification_level, VerificationLevel::Face); - assert_eq!(legacy_signal, Some("face-signal".to_string())); - - match constraints { - ConstraintNode::Item(item) => { - assert_eq!(item.credential_type, CredentialType::Face); - assert_eq!(item.signal, Some(Signal::from_string("face-signal"))); - } - _ => panic!("expected selfieCheckLegacy constraints to be a single item"), - } + assert_eq!( + bridge_params.legacy_verification_level, + Some(VerificationLevel::Face) + ); + assert_eq!(bridge_params.legacy_signal, Some("face-signal".to_string())); + assert_eq!(bridge_params.identity_attributes, None); + assert!(bridge_params.constraints.is_none()); } #[test] fn selfie_check_legacy_preset_without_signal_preserves_empty_signal() { let preset = Preset::selfie_check_legacy(None); - let (constraints, verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); - assert_eq!(verification_level, VerificationLevel::Face); - assert_eq!(legacy_signal, None); - - match constraints { - ConstraintNode::Item(item) => { - assert_eq!(item.credential_type, CredentialType::Face); - assert_eq!(item.signal, None); - } - _ => panic!("expected selfieCheckLegacy constraints to be a single item"), - } + assert_eq!( + bridge_params.legacy_verification_level, + Some(VerificationLevel::Face) + ); + assert_eq!(bridge_params.legacy_signal, None); + assert_eq!(bridge_params.identity_attributes, None); + assert!(bridge_params.constraints.is_none()); } #[test] fn device_legacy_preset_builds_orb_only_constraints_and_device_legacy_level() { let preset = Preset::device_legacy(Some("device-signal".to_string())); - let (constraints, verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); + + assert_eq!( + bridge_params.legacy_verification_level, + Some(VerificationLevel::Device) + ); + assert_eq!( + bridge_params.legacy_signal, + Some("device-signal".to_string()) + ); + assert_eq!(bridge_params.identity_attributes, None); + assert!(bridge_params.constraints.is_none()); + } + + #[test] + fn device_legacy_preset_without_signal_preserves_empty_signal() { + let preset = Preset::device_legacy(None); + let bridge_params = preset.into_bridge_params(); + + assert_eq!( + bridge_params.legacy_verification_level, + Some(VerificationLevel::Device) + ); + assert_eq!(bridge_params.legacy_signal, None); + assert_eq!(bridge_params.identity_attributes, None); + assert!(bridge_params.constraints.is_none()); + } + + #[test] + fn identity_check_preset_builds_document_constraints_and_preserves_attributes() { + let attributes = vec![ + IdentityAttribute::Nationality("JPN".to_string()), + IdentityAttribute::MinimumAge(21), + ]; + let preset = Preset::identity_check(attributes.clone(), false); + let bridge_params = preset.into_bridge_params(); - assert_eq!(verification_level, VerificationLevel::Device); - assert_eq!(legacy_signal, Some("device-signal".to_string())); + assert_eq!(bridge_params.legacy_verification_level, None); + assert_eq!(bridge_params.legacy_signal, None); + assert_eq!(bridge_params.identity_attributes, Some(attributes)); - match constraints { - ConstraintNode::Item(orb) => { - assert_eq!(orb.credential_type, CredentialType::ProofOfHuman); - assert_eq!(orb.signal, Some(Signal::from_string("device-signal"))); + match bridge_params.constraints { + Some(ConstraintNode::Any { any }) => { + assert_eq!(any.len(), 2); + + match &any[0] { + ConstraintNode::Item(item) => { + assert_eq!(item.credential_type, CredentialType::Passport); + assert_eq!(item.signal, None); + } + _ => panic!("expected first identityCheck constraint to be passport"), + } + + match &any[1] { + ConstraintNode::Item(item) => { + assert_eq!(item.credential_type, CredentialType::Mnc); + assert_eq!(item.signal, None); + } + _ => panic!("expected second identityCheck constraint to be mnc"), + } } - _ => panic!("expected deviceLegacy constraints to be a single orb item"), + _ => panic!("expected identityCheck constraints to be an any node"), } } #[test] - fn device_legacy_preset_without_signal_preserves_empty_signal() { - let preset = Preset::device_legacy(None); - let (constraints, verification_level, legacy_signal) = preset.to_bridge_params(); + fn identity_check_preset_with_orb_builds_enumerated_constraints_and_preserves_attributes() { + let attributes = vec![ + IdentityAttribute::IssuingCountry("JPN".to_string()), + IdentityAttribute::DocumentNumber("AB123456".to_string()), + ]; + let preset = Preset::identity_check(attributes.clone(), true); + let bridge_params = preset.into_bridge_params(); + + assert_eq!(bridge_params.legacy_verification_level, None); + assert_eq!(bridge_params.legacy_signal, None); + assert_eq!(bridge_params.identity_attributes, Some(attributes)); - assert_eq!(verification_level, VerificationLevel::Device); - assert_eq!(legacy_signal, None); + match bridge_params.constraints { + Some(ConstraintNode::All { all }) => { + assert_eq!(all.len(), 2); - match constraints { - ConstraintNode::Item(orb) => { - assert_eq!(orb.credential_type, CredentialType::ProofOfHuman); - assert_eq!(orb.signal, None); + match &all[0] { + ConstraintNode::Any { any } => { + assert_eq!(any.len(), 2); + + match &any[0] { + ConstraintNode::Item(item) => { + assert_eq!(item.credential_type, CredentialType::Passport); + assert_eq!(item.signal, None); + } + _ => panic!( + "expected first identityCheck with_orb branch to be passport" + ), + } + + match &any[1] { + ConstraintNode::Item(item) => { + assert_eq!(item.credential_type, CredentialType::Mnc); + assert_eq!(item.signal, None); + } + _ => panic!("expected second identityCheck with_orb branch to be mnc"), + } + } + _ => panic!("expected first identityCheck with_orb node to be any"), + } + + match &all[1] { + ConstraintNode::Item(item) => { + assert_eq!(item.credential_type, CredentialType::ProofOfHuman); + assert_eq!(item.signal, None); + } + _ => panic!("expected second identityCheck with_orb node to be orb"), + } } - _ => panic!("expected deviceLegacy constraints to be a single orb item"), + _ => panic!("expected identityCheck with_orb constraints to be an all node"), } } @@ -252,19 +384,20 @@ mod tests { fn orb_legacy_preset_decodes_address_shaped_signal_as_bytes() { let address = "0x3df41d9d0ba00d8fbe5a9896bb01efc4b3787b7c"; let preset = Preset::orb_legacy(Some(address.to_string())); - let (constraints, verification_level, legacy_signal) = preset.to_bridge_params(); + let bridge_params = preset.into_bridge_params(); - assert_eq!(verification_level, VerificationLevel::Orb); - assert_eq!(legacy_signal, Some(address.to_string())); + assert_eq!( + bridge_params.legacy_verification_level, + Some(VerificationLevel::Orb) + ); + assert_eq!(bridge_params.legacy_signal, Some(address.to_string())); + assert_eq!(bridge_params.identity_attributes, None); + assert!(bridge_params.constraints.is_none()); - match constraints { - ConstraintNode::Item(orb) => { - assert_eq!(orb.credential_type, CredentialType::ProofOfHuman); - let signal = orb.signal.expect("expected signal"); - assert!(matches!(signal, Signal::Bytes(_))); - assert_eq!(signal.as_bytes().len(), 20); - } - _ => panic!("expected orbLegacy constraints to be a single orb item"), - } + // Signal decoding (address → 20-byte Bytes) happens in the bridge layer + // via CachedSignalHashes::compute; verify the Signal type here independently. + let signal = Signal::from_string(address.to_string()); + assert!(matches!(signal, Signal::Bytes(_))); + assert_eq!(signal.as_bytes().len(), 20); } } diff --git a/rust/core/src/types.rs b/rust/core/src/types.rs index 3809fd2e..faa9f589 100644 --- a/rust/core/src/types.rs +++ b/rust/core/src/types.rs @@ -438,6 +438,125 @@ impl CredentialRequest { } } +/// A single identity attribute criterion for identity attestation. +/// +/// Each variant carries the expected value for that attribute. +/// Numeric variants (e.g. `MinimumAge`) serialize their value as a JSON integer; +/// all other variants serialize as a JSON string. +/// +/// Wire format: `{"type": "minimum_age", "value": 18}` +#[derive(Debug, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +pub enum IdentityAttribute { + /// The type of identity document presented + DocumentType(DocumentType), + /// Document number + DocumentNumber(String), + /// Issuing country (ISO 3166-1 alpha-3, e.g., "JPN") + IssuingCountry(String), + /// Full name as it appears on the document + FullName(String), + /// Minimum age in years + MinimumAge(u8), + /// Nationality (ISO 3166-1 alpha-3, e.g., "JPN") + Nationality(String), +} + +impl Serialize for IdentityAttribute { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + use serde::ser::SerializeMap; + let mut map = serializer.serialize_map(Some(2))?; + match self { + Self::DocumentType(v) => { + map.serialize_entry("type", "document_type")?; + map.serialize_entry("value", v)?; + } + Self::DocumentNumber(v) => { + map.serialize_entry("type", "document_number")?; + map.serialize_entry("value", v)?; + } + Self::IssuingCountry(v) => { + map.serialize_entry("type", "issuing_country")?; + map.serialize_entry("value", v)?; + } + Self::FullName(v) => { + map.serialize_entry("type", "full_name")?; + map.serialize_entry("value", v)?; + } + Self::MinimumAge(v) => { + map.serialize_entry("type", "minimum_age")?; + map.serialize_entry("value", v)?; + } + Self::Nationality(v) => { + map.serialize_entry("type", "nationality")?; + map.serialize_entry("value", v)?; + } + } + map.end() + } +} + +impl<'de> Deserialize<'de> for IdentityAttribute { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct Helper { + #[serde(rename = "type")] + attribute_type: String, + value: serde_json::Value, + } + + let h = Helper::deserialize(deserializer)?; + let str_val = |attr: &str| { + h.value.as_str().map(String::from).ok_or_else(|| { + serde::de::Error::custom(format!("expected string value for {attr}")) + }) + }; + let u8_val = |attr: &str| { + let n = h.value.as_u64().ok_or_else(|| { + serde::de::Error::custom(format!("expected integer value for {attr}")) + })?; + u8::try_from(n).map_err(|_| { + serde::de::Error::custom(format!("value {n} is out of range for {attr}")) + }) + }; + + match h.attribute_type.as_str() { + "document_type" => { + let doc_type = serde_json::from_value::(h.value) + .map_err(serde::de::Error::custom)?; + Ok(Self::DocumentType(doc_type)) + } + "document_number" => Ok(Self::DocumentNumber(str_val("document_number")?)), + "issuing_country" => Ok(Self::IssuingCountry(str_val("issuing_country")?)), + "full_name" => Ok(Self::FullName(str_val("full_name")?)), + "minimum_age" => Ok(Self::MinimumAge(u8_val("minimum_age")?)), + "nationality" => Ok(Self::Nationality(str_val("nationality")?)), + other => Err(serde::de::Error::custom(format!( + "unknown identity attribute type: {other}" + ))), + } + } +} + +/// Identity document type used in [`IdentityAttribute::DocumentType`]. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +pub enum DocumentType { + /// Biometric passport (ICAO 9303) + Passport, + /// National electronic identity card + Eid, + /// Japan's My Number Card + Mnc, +} + /// Legacy bridge response (protocol v1 / World ID v3) #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[cfg_attr(feature = "ffi", derive(uniffi::Record))] @@ -594,6 +713,11 @@ pub struct IDKitResult { /// The environment used for this request ("production" or "staging") pub environment: String, + + /// Whether identity attributes were attested. + /// Only present on responses from an `IdentityCheck` request. + #[serde(skip_serializing_if = "Option::is_none")] + pub identity_attested: Option, } impl IDKitResult { @@ -615,6 +739,7 @@ impl IDKitResult { session_id: None, responses, environment: environment.into(), + identity_attested: None, } } @@ -636,6 +761,7 @@ impl IDKitResult { session_id: Some(session_id), responses, environment: environment.into(), + identity_attested: None, } } @@ -1327,4 +1453,82 @@ mod tests { let prod_app = AppId::new("app_123").unwrap(); assert!(BridgeUrl::new("http://localhost:3000", &prod_app).is_err()); } + + // ───────────────────────────────────────────────────────────────────────── + // IdentityAttribute serialization / deserialization + // ───────────────────────────────────────────────────────────────────────── + + #[test] + fn test_identity_attribute_string_variants_serialize() { + let cases: &[(IdentityAttribute, &str)] = &[ + ( + IdentityAttribute::DocumentType(DocumentType::Passport), + r#"{"type":"document_type","value":"passport"}"#, + ), + ( + IdentityAttribute::DocumentType(DocumentType::Eid), + r#"{"type":"document_type","value":"eid"}"#, + ), + ( + IdentityAttribute::DocumentType(DocumentType::Mnc), + r#"{"type":"document_type","value":"mnc"}"#, + ), + ( + IdentityAttribute::DocumentNumber("X12345678".into()), + r#"{"type":"document_number","value":"X12345678"}"#, + ), + ( + IdentityAttribute::IssuingCountry("JPN".into()), + r#"{"type":"issuing_country","value":"JPN"}"#, + ), + ( + IdentityAttribute::FullName("John Smith".into()), + r#"{"type":"full_name","value":"John Smith"}"#, + ), + ( + IdentityAttribute::Nationality("JPN".into()), + r#"{"type":"nationality","value":"JPN"}"#, + ), + ]; + for (attr, expected) in cases { + assert_eq!(serde_json::to_string(attr).unwrap(), *expected); + } + } + + #[test] + fn test_identity_attribute_roundtrip() { + let attrs = vec![ + IdentityAttribute::DocumentType(DocumentType::Passport), + IdentityAttribute::DocumentType(DocumentType::Eid), + IdentityAttribute::DocumentType(DocumentType::Mnc), + IdentityAttribute::DocumentNumber("X12345678".into()), + IdentityAttribute::IssuingCountry("JPN".into()), + IdentityAttribute::FullName("John Smith".into()), + IdentityAttribute::MinimumAge(18), + IdentityAttribute::Nationality("JPN".into()), + ]; + for attr in &attrs { + let json = serde_json::to_string(attr).unwrap(); + let back: IdentityAttribute = serde_json::from_str(&json).unwrap(); + assert_eq!(attr, &back); + } + } + + #[test] + fn test_identity_attribute_full_array_matches_spec() { + let json = r#"[ + {"type":"document_type","value":"passport"}, + {"type":"document_number","value":"X12345678"}, + {"type":"issuing_country","value":"JPN"}, + {"type":"full_name","value":"John Smith"}, + {"type":"minimum_age","value":18}, + {"type":"nationality","value":"JPN"} + ]"#; + let attrs: Vec = serde_json::from_str(json).unwrap(); + assert_eq!( + attrs[0], + IdentityAttribute::DocumentType(DocumentType::Passport) + ); + assert_eq!(attrs[4], IdentityAttribute::MinimumAge(18)); + } } diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index 1cdbd9f3..e11fa0d6 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -507,7 +507,7 @@ impl IDKitConfigWasm { #[allow(clippy::too_many_lines)] fn to_params( &self, - constraints: ConstraintNode, + constraints: Option, ) -> Result { match self { Self::Request { @@ -534,7 +534,7 @@ impl IDKitConfigWasm { kind: crate::bridge::RequestKind::Uniqueness { action: action.clone(), }, - constraints: Some(constraints), + constraints, rp_context: rp_context.clone(), action_description: action_description.clone(), // Default to Device for v3 backwards compat — v4 uses proof_request instead. @@ -549,6 +549,7 @@ impl IDKitConfigWasm { "staging" => crate::bridge::Environment::Staging, _ => crate::bridge::Environment::Production, }), + identity_attributes: None, }) } Self::CreateSession { @@ -571,7 +572,7 @@ impl IDKitConfigWasm { Ok(crate::bridge::BridgeConnectionParams { app_id, kind: crate::bridge::RequestKind::CreateSession, - constraints: Some(constraints), + constraints, rp_context: rp_context.clone(), action_description: action_description.clone(), // Default to Device for v3 backwards compat — v4 uses proof_request instead. @@ -586,6 +587,7 @@ impl IDKitConfigWasm { "staging" => crate::bridge::Environment::Staging, _ => crate::bridge::Environment::Production, }), + identity_attributes: None, }) } Self::ProveSession { @@ -611,7 +613,7 @@ impl IDKitConfigWasm { kind: crate::bridge::RequestKind::ProveSession { session_id: session_id.clone(), }, - constraints: Some(constraints), + constraints, rp_context: rp_context.clone(), action_description: action_description.clone(), // Default to Device for v3 backwards compat — v4 uses proof_request instead. @@ -626,6 +628,7 @@ impl IDKitConfigWasm { "staging" => crate::bridge::Environment::Staging, _ => crate::bridge::Environment::Production, }), + identity_attributes: None, }) } } @@ -640,11 +643,16 @@ impl IDKitConfigWasm { "Presets are not supported for session flows. Use .constraints() instead.", )); } - let (constraints, legacy_verification_level, legacy_signal) = preset.to_bridge_params(); - let mut params = self.to_params(constraints)?; - params.constraints = None; - params.legacy_verification_level = legacy_verification_level; - params.legacy_signal = legacy_signal.unwrap_or_default(); + let bridge_params = preset.into_bridge_params(); + let mut params = self.to_params(bridge_params.constraints)?; + params.legacy_verification_level = bridge_params + .legacy_verification_level + .unwrap_or(crate::VerificationLevel::Device); + params.legacy_signal = bridge_params.legacy_signal.unwrap_or_default(); + params.identity_attributes = bridge_params.identity_attributes; + if let Some(v) = bridge_params.allow_legacy_proofs_override { + params.allow_legacy_proofs = v; + } Ok(params) } } @@ -752,7 +760,7 @@ impl IDKitBuilderWasm { let constraints: ConstraintNode = serde_wasm_bindgen::from_value(constraints_json) .map_err(|e| JsValue::from_str(&format!("Invalid constraints: {e}")))?; - let params = self.config.to_params(constraints)?; + let params = self.config.to_params(Some(constraints))?; let payload = crate::bridge::build_request_payload(¶ms, true) .map_err(|e| JsValue::from_str(&format!("Failed to build payload: {e}")))?; @@ -903,7 +911,7 @@ impl IDKitBuilderWasm { let constraints: ConstraintNode = serde_wasm_bindgen::from_value(constraints_json) .map_err(|e| JsValue::from_str(&format!("Invalid constraints: {e}")))?; - let params = config.to_params(constraints)?; + let params = config.to_params(Some(constraints))?; let connection = crate::bridge::BridgeConnection::create(params) .await .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; @@ -1447,7 +1455,7 @@ mod tests { }; let params = config - .to_params(ConstraintNode::Any { any: Vec::new() }) + .to_params(Some(ConstraintNode::Any { any: Vec::new() })) .expect("request params"); assert_eq!( @@ -1469,7 +1477,7 @@ mod tests { }; let params = config - .to_params(ConstraintNode::Any { any: Vec::new() }) + .to_params(Some(ConstraintNode::Any { any: Vec::new() })) .expect("create session params"); assert_eq!( @@ -1492,7 +1500,7 @@ mod tests { }; let params = config - .to_params(ConstraintNode::Any { any: Vec::new() }) + .to_params(Some(ConstraintNode::Any { any: Vec::new() })) .expect("prove session params"); assert_eq!( diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift index 7dd473e3..e1628e14 100644 --- a/swift/Tests/IDKitTests/IDKitTests.swift +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -25,7 +25,8 @@ private func sampleResult(sessionId: String? = nil) -> IDKitResult { actionDescription: "Sample action", sessionId: sessionId, responses: [], - environment: "production" + environment: "production", + identityAttested: nil ) } @@ -244,35 +245,35 @@ func legacyPresetHelpers() { switch orb { case .orbLegacy(let signal): #expect(signal == "x") - case .secureDocumentLegacy, .documentLegacy, .deviceLegacy, .selfieCheckLegacy: + case .secureDocumentLegacy, .documentLegacy, .deviceLegacy, .selfieCheckLegacy, .identityCheck: Issue.record("Expected orbLegacy preset") } switch secureDoc { case .secureDocumentLegacy(let signal): #expect(signal == "y") - case .orbLegacy, .documentLegacy, .deviceLegacy, .selfieCheckLegacy: + case .orbLegacy, .documentLegacy, .deviceLegacy, .selfieCheckLegacy, .identityCheck: Issue.record("Expected secureDocumentLegacy preset") } switch doc { case .documentLegacy(let signal): #expect(signal == "z") - case .orbLegacy, .secureDocumentLegacy, .deviceLegacy, .selfieCheckLegacy: + case .orbLegacy, .secureDocumentLegacy, .deviceLegacy, .selfieCheckLegacy, .identityCheck: Issue.record("Expected documentLegacy preset") } switch device { case .deviceLegacy(let signal): #expect(signal == "d") - case .orbLegacy, .secureDocumentLegacy, .documentLegacy, .selfieCheckLegacy: + case .orbLegacy, .secureDocumentLegacy, .documentLegacy, .selfieCheckLegacy, .identityCheck: Issue.record("Expected deviceLegacy preset") } switch face { case .selfieCheckLegacy(let signal): #expect(signal == "f") - case .orbLegacy, .secureDocumentLegacy, .documentLegacy, .deviceLegacy: + case .orbLegacy, .secureDocumentLegacy, .documentLegacy, .deviceLegacy, .identityCheck: Issue.record("Expected selfieCheckLegacy preset") } }