Skip to content

Commit 63edc90

Browse files
authored
feat: add bridge error codes (#231)
* feat(rust): add bridge error codes * feat(js): map bridge error codes * feat(native): expose bridge error codes * test(example): add bridge error cases * fix(example): keep developer portal domain
1 parent da524d8 commit 63edc90

26 files changed

Lines changed: 1560 additions & 5 deletions

File tree

Lines changed: 323 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,323 @@
1+
import { NextResponse } from "next/server";
2+
import {
3+
computeRpSignatureMessage,
4+
signRequest,
5+
} from "@worldcoin/idkit-server";
6+
import { bytesToHex, hexToBytes, keccak256, type Hex } from "viem";
7+
import { privateKeyToAccount } from "viem/accounts";
8+
9+
type TestCaseId =
10+
| "valid_success"
11+
| "invalid_rp_signature"
12+
| "duplicate_nonce"
13+
| "nullifier_replayed"
14+
| "invalid_rp_id_format"
15+
| "unknown_rp"
16+
| "inactive_rp"
17+
| "timestamp_too_old"
18+
| "timestamp_too_far_in_future"
19+
| "invalid_timestamp"
20+
| "rp_signature_expired";
21+
22+
type RpContextPayload = {
23+
rp_id: string;
24+
nonce: string;
25+
created_at: number;
26+
expires_at: number;
27+
signature: string;
28+
};
29+
30+
type RequestBody = {
31+
caseId: TestCaseId;
32+
action?: string;
33+
nonce?: string;
34+
};
35+
36+
const KNOWN_CASES = new Set<TestCaseId>([
37+
"valid_success",
38+
"invalid_rp_signature",
39+
"duplicate_nonce",
40+
"nullifier_replayed",
41+
"invalid_rp_id_format",
42+
"unknown_rp",
43+
"inactive_rp",
44+
"timestamp_too_old",
45+
"timestamp_too_far_in_future",
46+
"invalid_timestamp",
47+
"rp_signature_expired",
48+
]);
49+
50+
const DEFAULT_ACTION = "idkit-test-case";
51+
const DEFAULT_TTL_SEC = 300;
52+
const RP_ID_FORMAT = /^rp_[0-9a-fA-F]{1,16}$/;
53+
54+
function normalizePrivateKey(key: string): Hex {
55+
const normalized = key.startsWith("0x") ? key : `0x${key}`;
56+
if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) {
57+
throw new Error("Expected a 32-byte hex RP signing key");
58+
}
59+
return normalized as Hex;
60+
}
61+
62+
function randomFieldElement(): Hex {
63+
const randomBytes = crypto.getRandomValues(new Uint8Array(32));
64+
const field = BigInt(keccak256(randomBytes)) >> 8n;
65+
return `0x${field.toString(16).padStart(64, "0")}` as Hex;
66+
}
67+
68+
function randomUnknownRpId(): string {
69+
const bytes = crypto.getRandomValues(new Uint8Array(8));
70+
return `rp_${bytesToHex(bytes).slice(2)}`;
71+
}
72+
73+
function normalizeNonceOverride(nonce?: string): Hex | undefined {
74+
if (!nonce) {
75+
return undefined;
76+
}
77+
78+
const normalized = nonce.startsWith("0x") ? nonce : `0x${nonce}`;
79+
if (!/^0x[0-9a-fA-F]{64}$/.test(normalized)) {
80+
throw new Error("Expected nonce to be a 32-byte hex string");
81+
}
82+
83+
return normalized as Hex;
84+
}
85+
86+
function getUnknownRpId(): { rpId: string; note?: string } {
87+
const configuredRpId = process.env.TEST_UNKNOWN_RP_ID?.trim();
88+
if (!configuredRpId) {
89+
return { rpId: randomUnknownRpId() };
90+
}
91+
92+
if (RP_ID_FORMAT.test(configuredRpId)) {
93+
return { rpId: configuredRpId };
94+
}
95+
96+
return {
97+
rpId: randomUnknownRpId(),
98+
note: "Ignored TEST_UNKNOWN_RP_ID because it must be rp_ followed by up to 16 hex characters.",
99+
};
100+
}
101+
102+
async function signControlledRequest({
103+
action,
104+
rpId,
105+
signingKeyHex,
106+
nonce = randomFieldElement(),
107+
createdAt,
108+
expiresAt,
109+
}: {
110+
action: string;
111+
rpId: string;
112+
signingKeyHex: string;
113+
nonce?: Hex;
114+
createdAt: number;
115+
expiresAt: number;
116+
}): Promise<RpContextPayload> {
117+
const account = privateKeyToAccount(normalizePrivateKey(signingKeyHex));
118+
const message = computeRpSignatureMessage(
119+
hexToBytes(nonce),
120+
createdAt,
121+
expiresAt,
122+
action,
123+
);
124+
const signature = await account.signMessage({ message: { raw: message } });
125+
126+
return {
127+
rp_id: rpId,
128+
nonce,
129+
created_at: createdAt,
130+
expires_at: expiresAt,
131+
signature,
132+
};
133+
}
134+
135+
function signDefaultRequest({
136+
action,
137+
rpId,
138+
signingKeyHex,
139+
ttl = DEFAULT_TTL_SEC,
140+
}: {
141+
action: string;
142+
rpId: string;
143+
signingKeyHex: string;
144+
ttl?: number;
145+
}): RpContextPayload {
146+
const signature = signRequest({ action, signingKeyHex, ttl });
147+
return {
148+
rp_id: rpId,
149+
nonce: signature.nonce,
150+
created_at: signature.createdAt,
151+
expires_at: signature.expiresAt,
152+
signature: signature.sig,
153+
};
154+
}
155+
156+
function mutateSignature(signature: string): string {
157+
const bytes = hexToBytes(signature as Hex);
158+
bytes[0] = bytes[0] ^ 0x01;
159+
return bytesToHex(bytes);
160+
}
161+
162+
function jsonError(message: string, status: number): Response {
163+
return NextResponse.json({ error: message }, { status });
164+
}
165+
166+
export async function POST(request: Request): Promise<Response> {
167+
try {
168+
const body = (await request.json()) as RequestBody;
169+
if (!KNOWN_CASES.has(body.caseId)) {
170+
return jsonError(`Unknown test case: ${String(body.caseId)}`, 400);
171+
}
172+
173+
const signingKey = process.env.RP_SIGNING_KEY;
174+
const rpId = process.env.NEXT_PUBLIC_RP_ID;
175+
if (!signingKey || !rpId) {
176+
return jsonError("Missing RP_SIGNING_KEY or NEXT_PUBLIC_RP_ID", 500);
177+
}
178+
179+
const action = body.action?.trim() || DEFAULT_ACTION;
180+
const now = Math.floor(Date.now() / 1000);
181+
let rpContext: RpContextPayload;
182+
let returnedAction = action;
183+
let note: string | undefined;
184+
185+
switch (body.caseId) {
186+
case "valid_success":
187+
case "nullifier_replayed":
188+
rpContext = signDefaultRequest({
189+
action,
190+
rpId,
191+
signingKeyHex: signingKey,
192+
});
193+
break;
194+
case "duplicate_nonce": {
195+
const nonceOverride = normalizeNonceOverride(body.nonce);
196+
if (nonceOverride) {
197+
rpContext = await signControlledRequest({
198+
action,
199+
rpId,
200+
signingKeyHex: signingKey,
201+
nonce: nonceOverride,
202+
createdAt: now,
203+
expiresAt: now + DEFAULT_TTL_SEC,
204+
});
205+
} else {
206+
rpContext = signDefaultRequest({
207+
action,
208+
rpId,
209+
signingKeyHex: signingKey,
210+
});
211+
}
212+
break;
213+
}
214+
case "invalid_rp_signature":
215+
rpContext = signDefaultRequest({
216+
action,
217+
rpId,
218+
signingKeyHex: signingKey,
219+
});
220+
rpContext.signature = mutateSignature(rpContext.signature);
221+
break;
222+
case "invalid_rp_id_format":
223+
rpContext = signDefaultRequest({
224+
action,
225+
rpId: "invalid_rp_id",
226+
signingKeyHex: signingKey,
227+
});
228+
note =
229+
"Fails local RP ID validation before the request is opened in World App.";
230+
break;
231+
case "unknown_rp":
232+
{
233+
const unknownRp = getUnknownRpId();
234+
note = unknownRp.note;
235+
rpContext = signDefaultRequest({
236+
action,
237+
rpId: unknownRp.rpId,
238+
signingKeyHex: signingKey,
239+
});
240+
}
241+
break;
242+
case "inactive_rp": {
243+
const inactiveRpId = process.env.TEST_INACTIVE_RP_ID;
244+
if (!inactiveRpId) {
245+
return NextResponse.json(
246+
{
247+
configured: false,
248+
error:
249+
"inactive_rp requires TEST_INACTIVE_RP_ID for an RP that exists in the registry but is inactive; random valid RP IDs return unknown_rp",
250+
},
251+
{ status: 422 },
252+
);
253+
}
254+
rpContext = signDefaultRequest({
255+
action,
256+
rpId: inactiveRpId,
257+
signingKeyHex: process.env.TEST_INACTIVE_RP_SIGNING_KEY || signingKey,
258+
});
259+
break;
260+
}
261+
case "timestamp_too_old":
262+
rpContext = await signControlledRequest({
263+
action,
264+
rpId,
265+
signingKeyHex: signingKey,
266+
createdAt: now - 20 * 60,
267+
expiresAt: now + 5 * 60,
268+
});
269+
break;
270+
case "timestamp_too_far_in_future":
271+
rpContext = await signControlledRequest({
272+
action,
273+
rpId,
274+
signingKeyHex: signingKey,
275+
createdAt: now + 20 * 60,
276+
expiresAt: now + 25 * 60,
277+
});
278+
break;
279+
case "invalid_timestamp":
280+
rpContext = await signControlledRequest({
281+
action,
282+
rpId,
283+
signingKeyHex: signingKey,
284+
createdAt: now,
285+
expiresAt: now - 1,
286+
});
287+
note =
288+
"Fails local timestamp validation before the request is opened in World App.";
289+
break;
290+
case "rp_signature_expired":
291+
rpContext = await signControlledRequest({
292+
action,
293+
rpId,
294+
signingKeyHex: signingKey,
295+
createdAt: now - 120,
296+
expiresAt: now - 30,
297+
});
298+
break;
299+
default: {
300+
const exhaustive: never = body.caseId;
301+
throw new Error(`Unhandled test case: ${String(exhaustive)}`);
302+
}
303+
}
304+
305+
if (body.caseId === "invalid_rp_signature") {
306+
returnedAction = action;
307+
}
308+
309+
return NextResponse.json({
310+
configured: true,
311+
caseId: body.caseId,
312+
action: returnedAction,
313+
rpContext,
314+
note,
315+
});
316+
} catch (error) {
317+
console.error("Error generating test-case RP context:", error);
318+
return jsonError(
319+
error instanceof Error ? error.message : "Unknown server error",
320+
500,
321+
);
322+
}
323+
}

js/examples/nextjs/app/api/verify-proof/route.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,11 @@ export async function POST(request: Request): Promise<Response> {
2222

2323
const payload = await response.json();
2424

25+
console.log("Received response from Dev Portal:", {
26+
payload,
27+
status: response.status,
28+
});
29+
2530
return NextResponse.json(payload, {
2631
status: response.status,
2732
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import type { ReactElement } from "react";
2+
import { TestCasesClient } from "./ui";
3+
4+
export default function ErrorCasesPage(): ReactElement {
5+
return (
6+
<main>
7+
<a className="nav-link" href="/">
8+
Back to demo
9+
</a>
10+
<h1>IDKit E2E Error Cases</h1>
11+
<p>
12+
Run malformed World ID 4.0 requests against a local World App build and
13+
compare the returned IDKit error code with the expected result.
14+
</p>
15+
<TestCasesClient />
16+
</main>
17+
);
18+
}

0 commit comments

Comments
 (0)