Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions js/packages/core/src/transports/native.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
39 changes: 27 additions & 12 deletions js/packages/core/src/transports/native.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -514,33 +518,44 @@ 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,
} satisfies IDKitResultV3;
}

// 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,
Expand Down
159 changes: 152 additions & 7 deletions rust/core/src/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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(),
};
Comment on lines +224 to +227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Takaros999 ResponseItem::V3 has no verification_level field (types.rs#L702-L714), and the request still sends verification_level: "face". Does an RP verifying a legacy proof now get selfie where it used to read the 3.0 level? Is selfie accepted there, or should the mapping cover only v4 and Session?


ResponseItem::V3 {
identifier: self.verification_level.to_string(),
identifier,
signal_hash,
proof: self.proof,
merkle_root: self.merkle_root,
Expand All @@ -231,6 +236,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`.
///
Expand All @@ -239,9 +255,11 @@ impl ResponseItem {
item: world_id_primitives::ResponseItem,
signal_hash: Option<String>,
) -> Result<Self> {
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
Expand All @@ -257,7 +275,7 @@ impl ResponseItem {
})
} else if let Some(nullifier) = item.nullifier {
Ok(Self::V4 {
identifier: item.identifier,
identifier,
signal_hash,
proof: item
.proof
Expand Down Expand Up @@ -306,7 +324,14 @@ pub fn proof_response_to_idkit_result<S: std::hash::BuildHasher>(
.responses
.into_iter()
.map(|item| {
let signal_hash = context.signal_hashes.get(&item.identifier).cloned();
let incoming_identifier = item.identifier.clone();
let normalized_identifier =
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::<Result<Vec<_>>>()?;
Expand Down Expand Up @@ -2445,7 +2470,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(
Expand Down Expand Up @@ -2499,6 +2524,57 @@ mod tests {
serde_json::json!(11)
);
assert_eq!(payload["verification_level"], serde_json::json!("device"));

let cached_signal_hashes = CachedSignalHashes::compute(&params);
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]
Expand Down Expand Up @@ -2999,6 +3075,75 @@ 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_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 {
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!(
Expand Down
Loading