From be8ad619d4ae55b08736c386a83d1f76e153bec4 Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:24:16 -0700 Subject: [PATCH 1/3] fix(core): normalize selfie response identifier --- rust/core/src/bridge.rs | 83 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 6 deletions(-) diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 97593f2f..62b3dfd0 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -6,8 +6,8 @@ use crate::{ crypto::{base64_decode, base64_encode, decrypt, encrypt}, error::{AppError, Error, Result}, types::{ - AppId, BridgeResponseV1, BridgeUrl, IDKitResult, IdentityAttribute, IntegrityBundle, - ResponseItem, RpContext, VerificationLevel, + AppId, BridgeResponseV1, BridgeUrl, CredentialType, IDKitResult, IdentityAttribute, + IntegrityBundle, ResponseItem, RpContext, VerificationLevel, }, ConstraintNode, Signal, }; @@ -231,6 +231,17 @@ impl BridgeResponseV1 { } } +/// Normalizes the historical app-facing name for schema 11 at the SDK result +/// boundary. The bridge payload remains untouched, and World ID 3.0 continues +/// to use the legacy `face` verification level. +fn normalize_response_identifier(identifier: String, issuer_schema_id: u64) -> String { + if identifier == "face" && issuer_schema_id == CredentialType::Selfie.issuer_schema_id() { + CredentialType::Selfie.to_string() + } else { + identifier + } +} + impl ResponseItem { /// Converts a protocol `ResponseItem` to an `IDKit` `ResponseItem`. /// @@ -239,9 +250,11 @@ impl ResponseItem { item: world_id_primitives::ResponseItem, signal_hash: Option, ) -> Result { + let identifier = normalize_response_identifier(item.identifier, item.issuer_schema_id); + if let Some(session_nullifier) = item.session_nullifier { Ok(Self::Session { - identifier: item.identifier, + identifier, signal_hash, proof: item .proof @@ -257,7 +270,7 @@ impl ResponseItem { }) } else if let Some(nullifier) = item.nullifier { Ok(Self::V4 { - identifier: item.identifier, + identifier, signal_hash, proof: item .proof @@ -306,7 +319,9 @@ pub fn proof_response_to_idkit_result( .responses .into_iter() .map(|item| { - let signal_hash = context.signal_hashes.get(&item.identifier).cloned(); + let normalized_identifier = + normalize_response_identifier(item.identifier.clone(), item.issuer_schema_id); + let signal_hash = context.signal_hashes.get(&normalized_identifier).cloned(); ResponseItem::from_protocol_item(item, signal_hash) }) .collect::>>()?; @@ -2445,7 +2460,7 @@ mod tests { } #[test] - fn test_build_request_payload_serializes_selfie_v4_request() { + fn test_selfie_v4_request_and_response_use_normalized_identifier_and_signal_hash() { let app_id = AppId::new("app_test").unwrap(); let signature = "0x".to_string() + &"00".repeat(64) + "1b"; let rp_context = RpContext::new( @@ -2499,6 +2514,57 @@ mod tests { serde_json::json!(11) ); assert_eq!(payload["verification_level"], serde_json::json!("device")); + + let cached_signal_hashes = CachedSignalHashes::compute(¶ms); + let expected_signal_hash = + crate::crypto::hash_signal(&Signal::from_string("selfie-signal".to_string())); + assert_eq!( + cached_signal_hashes.signal_hashes.get("selfie"), + Some(&expected_signal_hash) + ); + assert!(!cached_signal_hashes.signal_hashes.contains_key("face")); + + let proof_response: ProofResponse = serde_json::from_str(&format!( + r#"{{ + "id": "req_selfie", + "version": 1, + "responses": [{{ + "identifier": "face", + "issuer_schema_id": 11, + "proof": "{ZERO_PROOF}", + "nullifier": "{ZERO_NULLIFIER}", + "expires_at_min": 1735689600 + }}] + }}"# + )) + .unwrap(); + let result = proof_response_to_idkit_result( + proof_response, + ProofResponseConversionContext { + nonce: "1".to_string(), + action: Some("test-action".to_string()), + action_description: Some("Selfie check".to_string()), + environment: Some(Environment::Production), + signal_hashes: &cached_signal_hashes.signal_hashes, + identity_attested: None, + user_presence_completed: false, + }, + ) + .unwrap(); + + match &result.responses[0] { + ResponseItem::V4 { + identifier, + signal_hash, + issuer_schema_id, + .. + } => { + assert_eq!(identifier, "selfie"); + assert_eq!(signal_hash.as_ref(), Some(&expected_signal_hash)); + assert_eq!(*issuer_schema_id, 11); + } + other => panic!("Expected V4 response, got: {other:?}"), + } } #[test] @@ -2999,6 +3065,11 @@ mod tests { const ZERO_NULLIFIER: &str = "nil_0000000000000000000000000000000000000000000000000000000000000000"; + #[test] + fn test_non_selfie_face_identifier_is_preserved() { + assert_eq!(normalize_response_identifier("face".to_string(), 1), "face"); + } + #[test] fn test_bridge_response_v2_single_uniqueness_proof() { let json = format!( From 976729222d1ecfa88ecb4a3a19f44960d53fce4b Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:09:36 -0700 Subject: [PATCH 2/3] fix(core): normalize legacy selfie identifier --- rust/core/src/bridge.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 62b3dfd0..d6c84273 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -221,8 +221,13 @@ pub struct BridgeDebugReport { impl BridgeResponseV1 { fn into_response_item(self, signal_hash: String) -> ResponseItem { + let identifier = match self.verification_level { + VerificationLevel::Face => CredentialType::Selfie.to_string(), + verification_level => verification_level.to_string(), + }; + ResponseItem::V3 { - identifier: self.verification_level.to_string(), + identifier, signal_hash, proof: self.proof, merkle_root: self.merkle_root, @@ -3070,6 +3075,27 @@ mod tests { assert_eq!(normalize_response_identifier("face".to_string(), 1), "face"); } + #[test] + fn test_legacy_face_response_uses_public_selfie_identifier() { + let response = BridgeResponseV1 { + proof: "proof".to_string(), + merkle_root: "root".to_string(), + nullifier_hash: "nullifier".to_string(), + verification_level: VerificationLevel::Face, + }; + + let item = response.into_response_item("signal-hash".to_string()); + + assert!(matches!( + item, + ResponseItem::V3 { + identifier, + signal_hash, + .. + } if identifier == "selfie" && signal_hash == "signal-hash" + )); + } + #[test] fn test_bridge_response_v2_single_uniqueness_proof() { let json = format!( From b773f1cc8cefca8f6e7bad185b36762fba571d78 Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:30:26 -0700 Subject: [PATCH 3/3] pr feedback --- .../core/src/transports/native.test.ts | 59 +++++++++++++++++++ js/packages/core/src/transports/native.ts | 39 ++++++++---- rust/core/src/bridge.rs | 52 +++++++++++++++- 3 files changed, 136 insertions(+), 14 deletions(-) diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts index 89d11518..bf0e708f 100644 --- a/js/packages/core/src/transports/native.test.ts +++ b/js/packages/core/src/transports/native.test.ts @@ -198,6 +198,65 @@ describe("native transport request lifecycle", () => { } }); + it("normalizes legacy single face responses and prefers the selfie signal hash", async () => { + const signalHashes = { + selfie: hashSignal("selfie-signal"), + face: hashSignal("face-signal"), + }; + const req = createNativeRequest({}, baseConfig, signalHashes, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verification_level: "face", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.responses[0]).toMatchObject({ + identifier: "selfie", + signal_hash: signalHashes.selfie, + }); + } + }); + + it("normalizes legacy multi face responses and falls back to the face signal hash", async () => { + const signalHashes = { face: hashSignal("face-signal") }; + const req = createNativeRequest({}, baseConfig, signalHashes, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verifications: [ + { + verification_level: "face", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + }, + ], + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.responses[0]).toMatchObject({ + identifier: "selfie", + signal_hash: signalHashes.face, + }); + } + }); + it("uses per-identifier signal hashes when response omits signal_hash", async () => { const signalHashes = { proof_of_human: hashSignal("poh-signal"), diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index d0f4b008..7738d0da 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -459,6 +459,10 @@ class NativeIDKitRequest implements IDKitRequest { // Incoming response mapping // ───────────────────────────────────────────────────────────────────────────── +function normalizeLegacyResponseIdentifier(identifier: string): string { + return identifier === "face" ? "selfie" : identifier; +} + function nativeResultToIDKitResult( payload: unknown, config: BuilderConfig, @@ -514,16 +518,23 @@ function nativeResultToIDKitResult( protocol_version: "3.0" as const, nonce: rpNonce, action: config.action ?? "", - responses: verifications.map((v) => ({ - identifier: v.verification_level, - signal_hash: - v.signal_hash ?? - signalHashes[v.verification_level] ?? - legacySignalHash, - proof: v.proof, - merkle_root: v.merkle_root, - nullifier: v.nullifier_hash, - })), + responses: verifications.map((v) => { + const incomingIdentifier = v.verification_level as string; + const identifier = + normalizeLegacyResponseIdentifier(incomingIdentifier); + + return { + identifier, + signal_hash: + v.signal_hash ?? + signalHashes[identifier] ?? + signalHashes[incomingIdentifier] ?? + legacySignalHash, + proof: v.proof, + merkle_root: v.merkle_root, + nullifier: v.nullifier_hash, + }; + }), user_presence_completed: userPresenceCompleted, environment: config.environment ?? "production", integrity_bundle, @@ -531,16 +542,20 @@ function nativeResultToIDKitResult( } // Legacy single verification response (v3 format from World App). + const incomingIdentifier = p.verification_level as string; + const identifier = normalizeLegacyResponseIdentifier(incomingIdentifier); + return { protocol_version: "3.0" as const, nonce: rpNonce, action: config.action ?? "", responses: [ { - identifier: p.verification_level, + identifier, signal_hash: p.signal_hash ?? - signalHashes[p.verification_level] ?? + signalHashes[identifier] ?? + signalHashes[incomingIdentifier] ?? legacySignalHash, proof: p.proof, merkle_root: p.merkle_root, diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index d6c84273..e49e468d 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -324,9 +324,14 @@ pub fn proof_response_to_idkit_result( .responses .into_iter() .map(|item| { + let incoming_identifier = item.identifier.clone(); let normalized_identifier = - normalize_response_identifier(item.identifier.clone(), item.issuer_schema_id); - let signal_hash = context.signal_hashes.get(&normalized_identifier).cloned(); + normalize_response_identifier(incoming_identifier.clone(), item.issuer_schema_id); + let signal_hash = context + .signal_hashes + .get(&normalized_identifier) + .or_else(|| context.signal_hashes.get(&incoming_identifier)) + .cloned(); ResponseItem::from_protocol_item(item, signal_hash) }) .collect::>>()?; @@ -3075,6 +3080,49 @@ mod tests { assert_eq!(normalize_response_identifier("face".to_string(), 1), "face"); } + #[test] + fn test_selfie_response_falls_back_to_incoming_identifier_signal_hash() { + let proof_response: ProofResponse = serde_json::from_str(&format!( + r#"{{ + "id": "req_selfie", + "version": 1, + "responses": [{{ + "identifier": "face", + "issuer_schema_id": 11, + "proof": "{ZERO_PROOF}", + "nullifier": "{ZERO_NULLIFIER}", + "expires_at_min": 1735689600 + }}] + }}"# + )) + .unwrap(); + let signal_hashes = + std::collections::HashMap::from([("face".to_string(), "signal-hash".to_string())]); + + let result = proof_response_to_idkit_result( + proof_response, + ProofResponseConversionContext { + nonce: "1".to_string(), + action: Some("test-action".to_string()), + action_description: None, + environment: Some(Environment::Production), + signal_hashes: &signal_hashes, + identity_attested: None, + user_presence_completed: false, + }, + ) + .unwrap(); + + assert!(matches!( + &result.responses[0], + ResponseItem::V4 { + identifier, + signal_hash, + .. + } if identifier == "selfie" && signal_hash.as_deref() == Some("signal-hash") + )); + } + #[test] fn test_legacy_face_response_uses_public_selfie_identifier() { let response = BridgeResponseV1 {