From 45cf37d142480f6d3d1a8a7e24b80587067ecd37 Mon Sep 17 00:00:00 2001 From: Takis Kakalis <80459599+Takaros999@users.noreply.github.com> Date: Wed, 6 May 2026 13:53:02 -0700 Subject: [PATCH] feat: expose integrity bundle in responses --- .../nextjs/app/api/verify-proof/route.ts | 3 +- js/packages/core/src/index.ts | 2 + js/packages/core/src/lib/wasm.ts | 2 + .../core/src/transports/native.test.ts | 70 ++++++++ js/packages/core/src/transports/native.ts | 21 ++- js/packages/core/src/types/result.ts | 2 + js/packages/react/src/index.ts | 2 + rust/core/src/bridge.rs | 156 +++++++++++++++--- rust/core/src/lib.rs | 2 +- rust/core/src/types.rs | 69 ++++++++ rust/core/src/wasm_bindings.rs | 23 +++ swift/Tests/IDKitTests/IDKitTests.swift | 3 +- 12 files changed, 331 insertions(+), 24 deletions(-) diff --git a/js/examples/nextjs/app/api/verify-proof/route.ts b/js/examples/nextjs/app/api/verify-proof/route.ts index a8c06af6..a80a6078 100644 --- a/js/examples/nextjs/app/api/verify-proof/route.ts +++ b/js/examples/nextjs/app/api/verify-proof/route.ts @@ -1,4 +1,5 @@ import { NextResponse } from "next/server"; +import type { IDKitResult } from "@worldcoin/idkit"; // export const runtime = "nodejs"; @@ -6,7 +7,7 @@ export async function POST(request: Request): Promise { try { const body = (await request.json()) as { rp_id?: string; - devPortalPayload?: unknown; + devPortalPayload?: IDKitResult; }; const baseUrl = diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 9d797137..7f884594 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -43,6 +43,8 @@ export type { export type { // Uniqueness proof response types IDKitResult, + IntegrityBundle, + IntegritySignatureFormat, ResponseItemV4, ResponseItemV3, // Session proof response types diff --git a/js/packages/core/src/lib/wasm.ts b/js/packages/core/src/lib/wasm.ts index b3fde565..ab5fe22f 100644 --- a/js/packages/core/src/lib/wasm.ts +++ b/js/packages/core/src/lib/wasm.ts @@ -51,6 +51,8 @@ export type { IDKitResult, IDKitResultV3, IDKitResultV4, + IntegrityBundle, + IntegritySignatureFormat, // Session proof response types IDKitResultSession, ResponseItemSession, diff --git a/js/packages/core/src/transports/native.test.ts b/js/packages/core/src/transports/native.test.ts index 619ae6a7..f3fb98d2 100644 --- a/js/packages/core/src/transports/native.test.ts +++ b/js/packages/core/src/transports/native.test.ts @@ -244,6 +244,76 @@ describe("native transport request lifecycle", () => { }); }); + it("preserves integrity_bundle from v2.1 native responses", async () => { + const integrityBundle = { + version: 1, + signature_format: "android_keystore", + timestamp: 1709901234, + signature: "304502210", + jwt: "eyJhbGciOiJFUzI1NiIs", + } as const; + + const req = createNativeRequest({}, baseConfig, {}, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + proof_response: { + id: "req_abc123", + version: 1, + responses: [ + { + identifier: "face", + proof: proofResponseProof, + nullifier: proofResponseNullifier("a"), + issuer_schema_id: 11, + expires_at_min: 0, + }, + ], + }, + integrity_bundle: integrityBundle, + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.integrity_bundle).toEqual(integrityBundle); + } + }); + + it("preserves integrity_bundle from legacy native responses", async () => { + const integrityBundle = { + version: 1, + signature_format: "apple_app_attest", + timestamp: 1709901234, + signature: "304502210", + jwt: "eyJhbGciOiJFUzI1NiIs", + } as const; + + const req = createNativeRequest({}, baseConfig, {}, ""); + activeRequest = req; + + const completionPromise = req.pollUntilCompletion({ timeout: 1000 }); + + miniKitHandlers["miniapp-verify-action"]?.({ + status: "success", + protocol_version: "3.0", + verification_level: "orb", + proof: "0x01", + merkle_root: "0x02", + nullifier_hash: "0x03", + integrity_bundle: integrityBundle, + }); + + const completion = await completionPromise; + expect(completion.success).toBe(true); + if (completion.success) { + expect(completion.result.integrity_bundle).toEqual(integrityBundle); + } + }); + it("prefers response signal_hash over signal hashes map", async () => { const signalHashes = { proof_of_human: hashSignal("from-constraints") }; diff --git a/js/packages/core/src/transports/native.ts b/js/packages/core/src/transports/native.ts index b69c842b..b1fdb2e9 100644 --- a/js/packages/core/src/transports/native.ts +++ b/js/packages/core/src/transports/native.ts @@ -23,7 +23,7 @@ import type { } from "../request"; import type { IDKitResult } from "../types/result"; import { IDKitErrorCodes } from "../types/result"; -import type { IDKitResultV3 } from "../lib/wasm"; +import type { IDKitResultV3, IntegrityBundle } from "../lib/wasm"; import { WasmModule } from "../lib/wasm"; import { isDebug } from "../lib/debug"; @@ -374,6 +374,7 @@ function nativeResultToIDKitResult( ): IDKitResult { const p = payload as Record; const rpNonce = config.rp_context?.nonce ?? ""; + const integrity_bundle = normalizeIntegrityBundle(p); // V4 response wrapped in `proof_response` envelope. if ("proof_response" in p && p.proof_response != null) { @@ -386,7 +387,7 @@ function nativeResultToIDKitResult( ), }); - return WasmModule.proofResponseToIDKitResult(proof_response, { + const result = WasmModule.proofResponseToIDKitResult(proof_response, { nonce: rpNonce, action: config.action, action_description: config.action_description, @@ -394,6 +395,9 @@ function nativeResultToIDKitResult( signal_hashes: signalHashes, identity_attested: p.identity_attested, }) as IDKitResult; + + result.integrity_bundle = integrity_bundle; + return result; } // Protocol ProofResponse must be nested under `proof_response`. @@ -426,6 +430,7 @@ function nativeResultToIDKitResult( nullifier: v.nullifier_hash, })), environment: config.environment ?? "production", + integrity_bundle, } satisfies IDKitResultV3; } @@ -447,5 +452,17 @@ function nativeResultToIDKitResult( }, ], environment: config.environment ?? "production", + integrity_bundle, } satisfies IDKitResultV3; } + +function normalizeIntegrityBundle( + payload: Record, +): IntegrityBundle | undefined { + const integrityBundle = payload.integrity_bundle; + if (integrityBundle == null || typeof integrityBundle !== "object") { + return undefined; + } + + return integrityBundle as IntegrityBundle; +} diff --git a/js/packages/core/src/types/result.ts b/js/packages/core/src/types/result.ts index 7181a9b0..077be393 100644 --- a/js/packages/core/src/types/result.ts +++ b/js/packages/core/src/types/result.ts @@ -10,6 +10,8 @@ export type { IDKitResult, ResponseItemV4, ResponseItemV3, + IntegrityBundle, + IntegritySignatureFormat, // Session proof response types ResponseItemSession, IDKitResultSession, diff --git a/js/packages/react/src/index.ts b/js/packages/react/src/index.ts index 39a100d3..beba477c 100644 --- a/js/packages/react/src/index.ts +++ b/js/packages/react/src/index.ts @@ -50,6 +50,8 @@ export type { Preset, ConstraintNode, IDKitResult, + IntegrityBundle, + IntegritySignatureFormat, IDKitResultSession, IDKitRequestConfig, IDKitSessionConfig, diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index add776bf..7294c7cf 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, ResponseItem, - RpContext, VerificationLevel, + AppId, BridgeResponseV1, BridgeUrl, IDKitResult, IdentityAttribute, IntegrityBundle, + ResponseItem, RpContext, VerificationLevel, }, ConstraintNode, Signal, }; @@ -315,18 +315,25 @@ enum BridgeResponse { ResponseV2_1 { proof_response: world_id_primitives::ProofResponse, identity_attested: Option, + integrity_bundle: Option, }, /// Multi-credential legacy: bridge sends v3 proofs via `legacy_responses` MultiLegacyResponse { legacy_responses: Vec, + integrity_bundle: Option, }, /// V1 legacy (old World App 3.0, single credential) - ResponseV1(BridgeResponseV1), + ResponseV1 { + #[serde(flatten)] + response: BridgeResponseV1, + integrity_bundle: Option, + }, } /// Status of a verification request +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, PartialEq, Eq)] pub enum Status { /// Waiting for World App to retrieve the request @@ -846,13 +853,21 @@ impl BridgeConnection { match bridge_response { BridgeResponse::Error { error_code } => Ok(Status::Failed(error_code)), BridgeResponse::ResponseV2(proof_response) => { - self.handle_bridge_v2_response(proof_response, None) + self.handle_bridge_v2_response(proof_response, None, None) } BridgeResponse::ResponseV2_1 { proof_response, identity_attested, - } => self.handle_bridge_v2_response(proof_response, identity_attested), - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + integrity_bundle, + } => self.handle_bridge_v2_response( + proof_response, + identity_attested, + integrity_bundle, + ), + BridgeResponse::MultiLegacyResponse { + legacy_responses, + integrity_bundle, + } => { let responses: Vec = legacy_responses .into_iter() .map(|item| { @@ -865,28 +880,37 @@ impl BridgeConnection { }) .collect(); - Ok(Status::Confirmed(IDKitResult::new( + let mut result = IDKitResult::new( "3.0", self.nonce.clone(), self.action.clone(), self.action_description.clone(), responses, self.environment.as_ref(), - ))) + ); + result.integrity_bundle = integrity_bundle; + + Ok(Status::Confirmed(result)) } - BridgeResponse::ResponseV1(response) => { + BridgeResponse::ResponseV1 { + response, + integrity_bundle, + } => { // V1 responses are always protocol 3.0 // For V1 we don't have identifier, use verification_level as key let signal_hash = self.cached_signal_hashes.legacy(); let item = response.into_response_item(signal_hash); - Ok(Status::Confirmed(IDKitResult::new( + let mut result = IDKitResult::new( "3.0", self.nonce.clone(), self.action.clone(), self.action_description.clone(), vec![item], self.environment.as_ref(), - ))) + ); + result.integrity_bundle = integrity_bundle; + + Ok(Status::Confirmed(result)) } } } @@ -898,13 +922,14 @@ impl BridgeConnection { &self, proof_response: world_id_primitives::ProofResponse, identity_attested: Option, + integrity_bundle: 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))); } - Ok(Status::Confirmed(proof_response_to_idkit_result( + let mut result = proof_response_to_idkit_result( proof_response, self.nonce.clone(), self.action.clone(), @@ -912,7 +937,10 @@ impl BridgeConnection { Some(self.environment), &self.cached_signal_hashes.signal_hashes, identity_attested, - )?)) + )?; + result.integrity_bundle = integrity_bundle; + + Ok(Status::Confirmed(result)) } /// Returns the request ID for this request. @@ -1547,6 +1575,7 @@ pub struct IDKitRequestWrapper { } #[cfg(feature = "ffi")] +#[allow(clippy::large_enum_variant)] #[derive(Debug, Clone, uniffi::Enum)] pub enum StatusWrapper { /// Waiting for World App to retrieve the request @@ -1741,7 +1770,7 @@ mod tests { use std::str::FromStr; use super::*; - use crate::types::{CredentialRequest, CredentialType, Signal}; + use crate::types::{CredentialRequest, CredentialType, IntegritySignatureFormat, Signal}; #[test] fn test_bridge_request_payload_serialization() { @@ -1953,7 +1982,7 @@ mod tests { }"#; let response: BridgeResponse = serde_json::from_str(json).unwrap(); - assert!(matches!(response, BridgeResponse::ResponseV1(_))); + assert!(matches!(response, BridgeResponse::ResponseV1 { .. })); } /// Android World App sends `credential_type` instead of `verification_level` @@ -1969,7 +1998,7 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { - BridgeResponse::ResponseV1(v1) => { + BridgeResponse::ResponseV1 { response: v1, .. } => { assert_eq!(v1.verification_level, VerificationLevel::Device); assert_eq!(v1.proof, "0xproof"); assert_eq!(v1.merkle_root, "0xroot"); @@ -1992,13 +2021,50 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { - BridgeResponse::ResponseV1(v1) => { + BridgeResponse::ResponseV1 { response: v1, .. } => { assert_eq!(v1.verification_level, VerificationLevel::Orb); } other => panic!("Expected ResponseV1, got: {other:?}"), } } + #[test] + fn test_bridge_response_v1_integrity_bundle() { + let json = r#"{ + "proof": "0xproof", + "merkle_root": "0xroot", + "nullifier_hash": "0xnull", + "verification_level": "orb", + "integrity_bundle": { + "version": 1, + "signature_format": "apple_app_attest", + "timestamp": 1709901234, + "signature": "304502210", + "jwt": "eyJhbGciOiJFUzI1NiIs" + } + }"#; + + let response: BridgeResponse = serde_json::from_str(json).unwrap(); + match response { + BridgeResponse::ResponseV1 { + response: v1, + integrity_bundle, + } => { + assert_eq!(v1.verification_level, VerificationLevel::Orb); + let bundle = integrity_bundle.expect("integrity bundle"); + assert_eq!(bundle.version, 1); + assert_eq!( + bundle.signature_format, + IntegritySignatureFormat::AppleAppAttest + ); + assert_eq!(bundle.timestamp, 1_709_901_234); + assert_eq!(bundle.signature, "304502210"); + assert_eq!(bundle.jwt, "eyJhbGciOiJFUzI1NiIs"); + } + other => panic!("Expected ResponseV1, got: {other:?}"), + } + } + #[test] fn test_bridge_response_error_deserialization() { let json = r#"{"error_code": "user_rejected"}"#; @@ -2106,6 +2172,54 @@ mod tests { } } + #[test] + fn test_bridge_response_v2_1_integrity_bundle() { + let json = format!( + r#"{{ + "proof_response": {{ + "id": "req_integrity", + "version": 1, + "responses": [{{ + "identifier": "face", + "issuer_schema_id": 11, + "proof": "{ZERO_PROOF}", + "nullifier": "{ZERO_NULLIFIER}", + "expires_at_min": 1735689600 + }}] + }}, + "identity_attested": true, + "integrity_bundle": {{ + "version": 1, + "signature_format": "android_keystore", + "timestamp": 1709901234, + "signature": "304502210", + "jwt": "eyJhbGciOiJFUzI1NiIs" + }} + }}"# + ); + + let response: BridgeResponse = serde_json::from_str(&json).unwrap(); + match response { + BridgeResponse::ResponseV2_1 { + proof_response, + identity_attested, + integrity_bundle, + } => { + assert_eq!(proof_response.id, "req_integrity"); + assert_eq!(proof_response.responses.len(), 1); + assert_eq!(proof_response.responses[0].identifier, "face"); + assert_eq!(identity_attested, Some(true)); + let bundle = integrity_bundle.expect("integrity bundle"); + assert_eq!( + bundle.signature_format, + IntegritySignatureFormat::AndroidKeystore + ); + assert_eq!(bundle.timestamp, 1_709_901_234); + } + other => panic!("Expected ResponseV2_1, got: {other:?}"), + } + } + // ───────────────────────────────────────────────────────────────────────── // BridgeResponse::MultiLegacyResponse Parsing Tests // ───────────────────────────────────────────────────────────────────────── @@ -2125,7 +2239,9 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + BridgeResponse::MultiLegacyResponse { + legacy_responses, .. + } => { assert_eq!(legacy_responses.len(), 1); assert_eq!( legacy_responses[0].verification_level, @@ -2158,7 +2274,9 @@ mod tests { let response: BridgeResponse = serde_json::from_str(json).unwrap(); match response { - BridgeResponse::MultiLegacyResponse { legacy_responses } => { + BridgeResponse::MultiLegacyResponse { + legacy_responses, .. + } => { assert_eq!(legacy_responses.len(), 2); assert_eq!( legacy_responses[0].verification_level, diff --git a/rust/core/src/lib.rs b/rust/core/src/lib.rs index d6884f4d..b4ba946b 100644 --- a/rust/core/src/lib.rs +++ b/rust/core/src/lib.rs @@ -34,7 +34,7 @@ pub use error::{Error, Result}; pub use preset::Preset; pub use types::{ AppId, BridgeResponseV1, BridgeUrl, CredentialRequest, CredentialType, IDKitResult, - ResponseItem, RpContext, Signal, VerificationLevel, + IntegrityBundle, IntegritySignatureFormat, ResponseItem, RpContext, Signal, VerificationLevel, }; // UniFFI scaffolding for core types diff --git a/rust/core/src/types.rs b/rust/core/src/types.rs index cfbe2da7..267bf340 100644 --- a/rust/core/src/types.rs +++ b/rust/core/src/types.rs @@ -614,6 +614,37 @@ impl<'de> Deserialize<'de> for BridgeResponseV1 { // Unified Response Types (World ID 4.0) // ───────────────────────────────────────────────────────────────────────────── +/// Integrity signature format used by the device. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ffi", derive(uniffi::Enum))] +#[serde(rename_all = "snake_case")] +pub enum IntegritySignatureFormat { + /// iOS App Attest signature format. + AppleAppAttest, + /// Android Keystore signature format. + AndroidKeystore, +} + +/// World App integrity bundle for proving request-time app integrity. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "ffi", derive(uniffi::Record))] +pub struct IntegrityBundle { + /// Version of the integrity bundle. + pub version: u8, + + /// Signature format used by the device. + pub signature_format: IntegritySignatureFormat, + + /// Unix timestamp of this request, in seconds. + pub timestamp: u64, + + /// Hex-encoded device signature. + pub signature: String, + + /// Attestation Gateway JWT proving integrity of the public key used to verify the signature. + pub jwt: String, +} + /// A single credential response item for uniqueness proofs /// /// V4 is detected by presence of `issuer_schema_id`. @@ -718,6 +749,10 @@ pub struct IDKitResult { /// Only present on responses from an `IdentityCheck` request. #[serde(skip_serializing_if = "Option::is_none")] pub identity_attested: Option, + + /// Optional World App integrity bundle for this proof request. + #[serde(skip_serializing_if = "Option::is_none")] + pub integrity_bundle: Option, } impl IDKitResult { @@ -740,6 +775,7 @@ impl IDKitResult { responses, environment: environment.into(), identity_attested: None, + integrity_bundle: None, } } @@ -762,6 +798,7 @@ impl IDKitResult { responses, environment: environment.into(), identity_attested: None, + integrity_bundle: None, } } @@ -1385,6 +1422,38 @@ mod tests { assert_eq!(result.responses.len(), 1); } + #[test] + fn test_idkit_result_integrity_bundle_serialization() { + let mut result = IDKitResult::new( + "3.0", + "0x0000000000000000000000000000000000000000000000000000000000000001", + None, + None, + Vec::new(), + "production", + ); + result.integrity_bundle = Some(IntegrityBundle { + version: 1, + signature_format: IntegritySignatureFormat::AppleAppAttest, + timestamp: 1_709_901_234, + signature: "304502210".to_string(), + jwt: "eyJhbGciOiJFUzI1NiIs".to_string(), + }); + + let json = serde_json::to_string(&result).unwrap(); + assert!(json.contains(r#""integrity_bundle""#)); + assert!(json.contains(r#""signature_format":"apple_app_attest""#)); + + let deserialized: IDKitResult = serde_json::from_str(&json).unwrap(); + let bundle = deserialized.integrity_bundle.expect("integrity bundle"); + assert_eq!(bundle.version, 1); + assert_eq!( + bundle.signature_format, + IntegritySignatureFormat::AppleAppAttest + ); + assert_eq!(bundle.timestamp, 1_709_901_234); + } + #[test] fn test_credential_type_issuer_schema_id() { assert_eq!(CredentialType::ProofOfHuman.issuer_schema_id(), 1); diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index c55c56b7..fc94f339 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -1361,6 +1361,23 @@ export function computeRpSignatureMessage(nonce: string, createdAt: bigint, expi // Export ResponseItem/IDKitResult types for unified response #[wasm_bindgen(typescript_custom_section)] const TS_IDKIT_RESULT: &str = r#" +/** Device signature format used by the integrity bundle */ +export type IntegritySignatureFormat = "apple_app_attest" | "android_keystore"; + +/** World App integrity bundle for proving request-time app integrity */ +export interface IntegrityBundle { + /** Version of the integrity bundle */ + version: number; + /** Signature format used by the device */ + signature_format: IntegritySignatureFormat; + /** Unix timestamp of this request, in seconds */ + timestamp: number; + /** Hex-encoded device signature */ + signature: string; + /** Attestation Gateway JWT proving integrity of the public key used to verify the signature */ + jwt: string; +} + /** V4 response item for World ID v4 uniqueness proofs */ export interface ResponseItemV4 { /** Credential identifier (e.g., "proof_of_human", "face", "passport", "mnc") */ @@ -1421,6 +1438,8 @@ export interface IDKitResultV3 { responses: ResponseItemV3[]; /** The environment used for this request ("production" or "staging") */ environment: string; + /** Optional World App integrity bundle for this proof request */ + integrity_bundle?: IntegrityBundle; } /** V4 result for uniqueness proofs */ @@ -1437,6 +1456,8 @@ export interface IDKitResultV4 { responses: ResponseItemV4[]; /** The environment used for this request ("production" or "staging") */ environment: string; + /** Optional World App integrity bundle for this proof request */ + integrity_bundle?: IntegrityBundle; } /** V4 result for session proofs */ @@ -1453,6 +1474,8 @@ export interface IDKitResultSession { responses: ResponseItemSession[]; /** The environment used for this request ("production" or "staging") */ environment: string; + /** Optional World App integrity bundle for this proof request */ + integrity_bundle?: IntegrityBundle; } /** diff --git a/swift/Tests/IDKitTests/IDKitTests.swift b/swift/Tests/IDKitTests/IDKitTests.swift index e1628e14..80dc3cec 100644 --- a/swift/Tests/IDKitTests/IDKitTests.swift +++ b/swift/Tests/IDKitTests/IDKitTests.swift @@ -26,7 +26,8 @@ private func sampleResult(sessionId: String? = nil) -> IDKitResult { sessionId: sessionId, responses: [], environment: "production", - identityAttested: nil + identityAttested: nil, + integrityBundle: nil ) }