diff --git a/js/examples/nextjs/app/api/verify-integrity-bundle/route.ts b/js/examples/nextjs/app/api/verify-integrity-bundle/route.ts new file mode 100644 index 0000000..97e8843 --- /dev/null +++ b/js/examples/nextjs/app/api/verify-integrity-bundle/route.ts @@ -0,0 +1,311 @@ +import { createHash, createPublicKey } from "crypto"; +import { NextResponse } from "next/server"; +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { decode as cborDecode } from "cbor-x"; +import { p256 } from "@noble/curves/nist.js"; + +export const runtime = "nodejs"; + +interface IntegrityBundle { + version: number; + signature_format: string; + timestamp: number; + /** Hex-encoded CBOR assertion containing DER signature + authenticatorData */ + signature: string; + /** Attestation Gateway JWT with cnf.jwk key binding */ + jwt: string; +} + +interface JWKPublicKey { + kty: string; + crv: string; + x: string; + y: string; + kid: string; +} + +export async function POST(request: Request): Promise { + try { + const body = (await request.json()) as { + bundle?: IntegrityBundle; + proofs?: string[]; + nonce?: string; + protocol_version?: string; + environment?: string; + }; + + if ( + !body.bundle?.signature || + !body.bundle?.jwt || + !Array.isArray(body.proofs) + ) { + return NextResponse.json( + { error: "Missing required fields: bundle, proofs" }, + { status: 400 }, + ); + } + + const rpId = process.env.NEXT_PUBLIC_RP_ID?.trim(); + + const result = await verifyIntegrityBundle( + body.bundle, + body.proofs, + body.nonce, + body.protocol_version, + body.environment ?? "production", + rpId, + ); + + return NextResponse.json(result); + } catch (error) { + console.error("Integrity bundle verification error:", error); + return NextResponse.json( + { + error: error instanceof Error ? error.message : "Unknown server error", + }, + { status: 500 }, + ); + } +} + +async function verifyIntegrityBundle( + bundle: IntegrityBundle, + proofs: string[], + nonce: string | undefined, + protocol_version: string | undefined, + environment: string, + rp_id: string | undefined, +) { + const base = + environment === "production" + ? "https://attestation.worldcoin.org" + : "https://attestation.worldcoin.dev"; + + // Decode JWT payload without signature verification for display purposes. + const rawJwtClaims = decodeJwtPayload(bundle.jwt); + const expectedIss = + environment === "production" + ? "attestation.worldcoin.org" + : "attestation.worldcoin.dev"; + + // Step 1: Fetch JWKS and verify JWT; extract cnf.jwk + const JWKS = createRemoteJWKSet(new URL(`${base}/.well-known/jwks.json`)); + + let cnfJWK: JWKPublicKey; + let jwtClaims: { issuer: string; audience: string[]; expiresAt: number }; + + try { + const { payload } = await jwtVerify(bundle.jwt, JWKS); + + if (payload.iss !== expectedIss) { + throw new Error( + `Invalid JWT issuer: got "${payload.iss}", expected "${expectedIss}"`, + ); + } + + const audList = Array.isArray(payload.aud) + ? payload.aud + : [payload.aud ?? ""]; + if (!rp_id) { + throw new Error("rp_id is required for audience validation"); + } + if (!audList.includes(rp_id)) { + throw new Error( + `Invalid JWT audience: got [${audList.join(", ")}], expected "${rp_id}"`, + ); + } + + const cnf = payload.cnf as { jwk?: JWKPublicKey } | undefined; + if (!cnf?.jwk) { + throw new Error("JWT missing cnf key binding"); + } + + cnfJWK = cnf.jwk; + jwtClaims = { + issuer: payload.iss, + audience: audList, + expiresAt: payload.exp ?? 0, + }; + } catch (error) { + return { + valid: false, + jwtValid: false, + jwtError: + error instanceof Error ? error.message : "JWT verification failed", + expectedIss, + rawJwtClaims, + }; + } + + // Step 2: Decode signature bytes — format depends on platform: + // apple_app_attest: hex-encoded CBOR {signature, authenticatorData} + // android_keystore: hex-encoded DER ECDSA signature (no authenticatorData) + let signature: Buffer; + let authenticatorData: Buffer | null = null; + + try { + if (bundle.signature_format === "android_keystore") { + signature = Buffer.from(bundle.signature, "hex"); + } else { + const assertionData = Buffer.from(bundle.signature, "hex"); + const decoded = cborDecode(assertionData) as Record; + + if (!decoded.signature || !decoded.authenticatorData) { + throw new Error("CBOR assertion missing required fields"); + } + + signature = Buffer.from(decoded.signature); + authenticatorData = Buffer.from(decoded.authenticatorData); + } + } catch (error) { + return { + valid: false, + jwtValid: true, + jwtClaims, + assertionValid: false, + assertionError: + error instanceof Error ? error.message : "Signature decode failed", + }; + } + + const step2 = { + signatureBytes: signature.length, + signatureHex: signature.toString("hex"), + ...(authenticatorData + ? { + authenticatorDataBytes: authenticatorData.length, + authenticatorDataHex: authenticatorData.toString("hex"), + } + : {}), + }; + + // Step 3: + const xBytes = base64URLToBuffer(cnfJWK.x); + const yBytes = base64URLToBuffer(cnfJWK.y); + const x963 = Buffer.concat([Buffer.from([0x04]), xBytes, yBytes]); + + // Step 4: clientDataHash depends on protocol version: + // v4 = SHA256("worldcoin/proof-integrity/v4" || nonce_32_bytes_BE) + // v3 = proofV3Digest(proofs) + const clientDataHash = + protocol_version === "4.0" && nonce + ? proofV4Digest(nonce) + : proofV3Digest(proofs); + + // Bind the bundle timestamp: SHA256(timestamp_8_bytes_BE || clientDataHash) + const tsBuf = Buffer.alloc(8); + tsBuf.writeBigInt64BE(BigInt(bundle.timestamp)); + const integrityDigest = createHash("sha256") + .update(Buffer.concat([tsBuf, clientDataHash])) + .digest(); + + const step4 = { + clientDataHash: clientDataHash.toString("hex"), + integrityDigest: integrityDigest.toString("hex"), + }; + + // For apple_app_attest: sigNonce = SHA256(authenticatorData || integrityDigest). + // CryptoKit isValidSignature SHA256s its input, so the signed hash = SHA256(sigNonce). + // For android_keystore: NONEwithECDSA signs integrityDigest raw, so messageToVerify IS the hash. + const messageToVerify = authenticatorData + ? createHash("sha256") + .update(Buffer.concat([authenticatorData, integrityDigest])) + .digest() + : integrityDigest; + + const step5 = authenticatorData + ? { sigNonce: messageToVerify.toString("hex") } + : {}; + + // Step 5: Verify ECDSA-P256 signature. + // Android (NONEwithECDSA): device signed integrityDigest raw — pass it directly as the hash. + // iOS (CryptoKit isValidSignature): internally SHA256s the input — pass SHA256(sigNonce). + // Both platforms emit DER-encoded signatures. + try { + const signatureValid = p256.verify(signature, messageToVerify, x963, { + format: "der", + lowS: false, + prehash: bundle.signature_format !== "android_keystore", + }); + + return { + valid: signatureValid, + jwtValid: true, + jwtClaims, + expectedIss, + rawJwtClaims, + assertionValid: signatureValid, + ...(signatureValid + ? {} + : { assertionError: "Assertion signature invalid" }), + signatureFormat: bundle.signature_format, + timestamp: bundle.timestamp, + version: bundle.version, + step2, + step4, + step5, + }; + } catch (error) { + return { + valid: false, + jwtValid: true, + jwtClaims, + assertionValid: false, + assertionError: + error instanceof Error ? error.message : "Signature verification error", + }; + } +} + +// Domain-separated SHA256 over the RP-supplied nonce field element (32-byte BE). +// Matches compute_proof_v4_digest in the Rust IDKit SDK. +function proofV4Digest(nonce: string): Buffer { + const hash = createHash("sha256"); + hash.update("worldcoin/proof-integrity/v4"); + const nonceHex = nonce.startsWith("0x") ? nonce.slice(2) : nonce; + const nonceBytes = Buffer.from(nonceHex.padStart(64, "0"), "hex"); + hash.update(nonceBytes); + return hash.digest(); +} + +// Domain-separated SHA256 over count and length-prefixed proof strings. +// Matches proofV3Digest in IntegrityBundleVerifier.swift and +// compute_proof_v3_digest in the Rust IDKit SDK. +function proofV3Digest(proofs: string[]): Buffer { + const hash = createHash("sha256"); + hash.update("worldcoin/proof-integrity/v3"); + + const countBuf = Buffer.alloc(4); + countBuf.writeUInt32BE(proofs.length, 0); + hash.update(countBuf); + + for (const proof of proofs) { + const proofBuf = Buffer.from(proof, "utf8"); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32BE(proofBuf.length, 0); + hash.update(lenBuf); + hash.update(proofBuf); + } + + return hash.digest(); +} + +function base64URLToBuffer(str: string): Buffer { + const base64 = str.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + return Buffer.from(padded, "base64"); +} + +function decodeJwtPayload(jwt: string): Record | null { + try { + const part = jwt.split(".")[1]; + if (!part) return null; + const json = Buffer.from( + part.replace(/-/g, "+").replace(/_/g, "/"), + "base64", + ).toString("utf8"); + return JSON.parse(json) as Record; + } catch { + return null; + } +} diff --git a/js/examples/nextjs/app/api/verify-proof/route.ts b/js/examples/nextjs/app/api/verify-proof/route.ts index a80a607..c48e315 100644 --- a/js/examples/nextjs/app/api/verify-proof/route.ts +++ b/js/examples/nextjs/app/api/verify-proof/route.ts @@ -8,10 +8,13 @@ export async function POST(request: Request): Promise { const body = (await request.json()) as { rp_id?: string; devPortalPayload?: IDKitResult; + devPortalBaseUrl?: string; }; const baseUrl = - process.env.DEV_PORTAL_BASE_URL?.trim() || "https://developer.world.org"; + body.devPortalBaseUrl?.trim() || + process.env.DEV_PORTAL_BASE_URL?.trim() || + "https://developer.world.org"; const response = await fetch(`${baseUrl}/api/v4/verify/${body.rp_id}`, { method: "POST", diff --git a/js/examples/nextjs/app/ui.tsx b/js/examples/nextjs/app/ui.tsx index 21dc940..7da0a07 100644 --- a/js/examples/nextjs/app/ui.tsx +++ b/js/examples/nextjs/app/ui.tsx @@ -13,6 +13,7 @@ import { setDebug, type ConstraintNode, type IDKitResult, + type IntegrityBundle, type RpContext, Preset, } from "@worldcoin/idkit"; @@ -22,8 +23,11 @@ setDebug(true); const APP_ID = process.env.NEXT_PUBLIC_APP_ID as `app_${string}` | undefined; const RP_ID = process.env.NEXT_PUBLIC_RP_ID; const STAGING_CONNECT_BASE_URL = "https://staging.world.org/verify"; +const STAGING_DEVPORTAL_BASE_URL = "https://staging-developer.worldcoin.org"; const CONNECT_URL_OVERRIDE_TOOLTIP = "Enable this to change the deeplink base URL to the staging verify endpoint. Useful when testing with a Staging iOS World App build that supports this override."; +const DEVPORTAL_URL_OVERRIDE_TOOLTIP = + "Enable this to send proof verification requests to the staging Developer Portal instead of production."; const GENESIS_ISSUED_AT_MIN_TOOLTIP = "Minimum genesis_issued_at timestamp that the used Credential must meet. " + "If present, the proof will include a constraint that the credential's genesis issued at timestamp " + @@ -107,7 +111,10 @@ async function fetchRpContext(action: string): Promise { }; } -async function verifyProof(payload: IDKitResult): Promise { +async function verifyProof( + payload: IDKitResult, + devPortalBaseUrl?: string, +): Promise { if (!RP_ID) { throw new Error("Missing NEXT_PUBLIC_RP_ID"); } @@ -115,7 +122,11 @@ async function verifyProof(payload: IDKitResult): Promise { const response = await fetch("/api/verify-proof", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ rp_id: RP_ID, devPortalPayload: payload }), + body: JSON.stringify({ + rp_id: RP_ID, + devPortalPayload: payload, + devPortalBaseUrl, + }), }); const json = await response.json(); @@ -132,6 +143,61 @@ async function verifyProof(payload: IDKitResult): Promise { return json; } +// Extract flat proof strings from an IDKit result for the integrity bundle digest. +// V3 responses have proof: string; V4 responses have proof: string[]. +function extractProofs(result: IDKitResult): string[] { + return (result.responses as Array<{ proof: string | string[] }>).flatMap( + (r) => (Array.isArray(r.proof) ? r.proof : [r.proof]), + ); +} + +interface IntegrityBundleVerifyResult { + valid: boolean; + jwtValid: boolean; + jwtError?: string; + jwtClaims?: { issuer: string; audience: string[]; expiresAt: number }; + expectedIss?: string; + rawJwtClaims?: Record | null; + assertionValid?: boolean; + assertionError?: string; + signatureFormat?: string; + timestamp?: number; + version?: number; + step2?: { + signatureBytes: number; + signatureHex: string; + authenticatorDataBytes?: number; + authenticatorDataHex?: string; + }; + step3?: { computedKid: string; jwtKid: string }; + step4?: { clientDataHash: string; integrityDigest: string }; + step5?: { messageToVerify: string; sigNonce?: string }; +} + +async function verifyIntegrityBundle( + result: IDKitResult, + environment: string, +): Promise { + const response = await fetch("/api/verify-integrity-bundle", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + bundle: result.integrity_bundle, + proofs: extractProofs(result), + nonce: result.nonce, + protocol_version: result.protocol_version, + environment, + }), + }); + + const json = await response.json(); + if (!response.ok) { + throw new Error(json.error ?? "Integrity bundle verification failed"); + } + + return json as IntegrityBundleVerifyResult; +} + export function DemoClient(): ReactElement { const [isLightTheme, setIsLightTheme] = useState(false); const [widgetOpen, setWidgetOpen] = useState(false); @@ -142,6 +208,11 @@ export function DemoClient(): ReactElement { const [widgetVerifyResult, setWidgetVerifyResult] = useState(null); const [widgetIdkitResult, setWidgetIdkitResult] = useState(null); + const [integrityBundleResult, setIntegrityBundleResult] = + useState(null); + const [integrityBundleError, setIntegrityBundleError] = useState< + string | null + >(null); const [widgetSignal, setWidgetSignal] = useState("demo-signal-initial"); const [action, setAction] = useState("test-action"); const [environment, setEnvironment] = useState<"production" | "staging">( @@ -150,6 +221,9 @@ export function DemoClient(): ReactElement { const [useStagingConnectBaseUrl, setUseStagingConnectBaseUrl] = useState(false); const [isConnectUrlTooltipOpen, setIsConnectUrlTooltipOpen] = useState(false); + const [useStagingDevPortalUrl, setUseStagingDevPortalUrl] = useState(false); + const [isDevPortalUrlTooltipOpen, setIsDevPortalUrlTooltipOpen] = + useState(false); const [worldIdVersion, setWorldIdVersion] = useState<"3.0" | "4.0">("3.0"); const [v4CredentialType, setV4CredentialType] = useState("proof_of_human"); @@ -195,6 +269,10 @@ export function DemoClient(): ReactElement { environment === "staging" && useStagingConnectBaseUrl ? STAGING_CONNECT_BASE_URL : undefined; + const overrideDevPortalBaseUrl = + environment === "staging" && useStagingDevPortalUrl + ? STAGING_DEVPORTAL_BASE_URL + : undefined; const effectiveReturnTo = useReturnTo ? returnTo.trim() || undefined : undefined; @@ -210,6 +288,8 @@ export function DemoClient(): ReactElement { if (environment !== "staging") { setUseStagingConnectBaseUrl(false); setIsConnectUrlTooltipOpen(false); + setUseStagingDevPortalUrl(false); + setIsDevPortalUrlTooltipOpen(false); } }, [environment]); @@ -232,10 +312,28 @@ export function DemoClient(): ReactElement { ); }, []); + // Auto-verify integrity bundle when idkit result arrives and bundle is present + useEffect(() => { + if (!widgetIdkitResult?.integrity_bundle) return; + + setIntegrityBundleResult(null); + setIntegrityBundleError(null); + + verifyIntegrityBundle(widgetIdkitResult, environment) + .then(setIntegrityBundleResult) + .catch((err) => + setIntegrityBundleError( + err instanceof Error ? err.message : "Unknown error", + ), + ); + }, [widgetIdkitResult, environment]); + const startWidgetFlow = async () => { setWidgetError(null); setWidgetVerifyResult(null); setWidgetIdkitResult(null); + setIntegrityBundleResult(null); + setIntegrityBundleError(null); try { const rpContext = await fetchRpContext(action || "test-action"); @@ -361,6 +459,52 @@ export function DemoClient(): ReactElement { )} + {environment === "staging" && ( +
+ +
setIsDevPortalUrlTooltipOpen(true)} + onMouseLeave={() => setIsDevPortalUrlTooltipOpen(false)} + > + + {isDevPortalUrlTooltipOpen && ( + + {DEVPORTAL_URL_OVERRIDE_TOOLTIP} + + )} +
+ setUseStagingDevPortalUrl(e.target.checked)} + /> + {STAGING_DEVPORTAL_BASE_URL} +
+ )} +