Skip to content

Commit e3fd24c

Browse files
feat: invite-code mode core API (WDP-73 / APP-9428)
Adds a code-based discovery channel to the existing Bridge proof flow. The RP shows the user a 6-character code; the user types it into World App on their phone and completes the existing Selfie Check / proof flow. Same proof flow, same poll loop, same Status enum — only the discovery channel changes. Wire shape (matches wallet-bridge APP-9425 and world-app-ios APP-9424): - Code is canonical 6-char Crockford Base32 (5 random data chars + 1 mod-32 weighted check digit, weights 1/3/5/7/9 — all coprime to 32, so 100% of single-char substitutions are caught). UI may format as "ABC-DEF" but the canonical form has no separator. - HKDF-SHA256, no salt, 32-byte output, IKM = canonical code's UTF-8 bytes. info="dx" → lookup index (lowercase hex on the wire); info="key" → AES-256-GCM key. The encryption key never reaches the bridge — only the index and ciphertext do. - POST /request body has the new `request_code_enabled: true, iv, payload, index` shape with `iv`/`payload` as standard base64 (same as the URL/QR path). - Response carries `session_nonce` and `code_expires_at`. Polling attaches `Authorization: Bearer <session_nonce>` so we're forward- compatible with the bridge's session_nonce gate (release-blocking follow-up on Bridge). URL-mode connections keep `session_nonce: None` and the header is omitted, so existing bridge behavior is unchanged. Cross-device implications: - Original WDP-73 mermaid minted a `delivery_token` ferried back via universal link. Universal links route to the device that opened them, so a desktop browser ↔ phone flow never receives the token. We drop delivery_token from idkit's surface entirely. Anti-collusion in the code path now degrades to "10-min TTL + one-shot redeem + per-IP rate limit." API surface (mirrors existing URL-mode): - Rust: `BridgeConnection::create_for_invite_code` (retry-once-on-409), `.invite_code()` / `.code_expires_at()` accessors. New crypto.rs primitives: `generate_invite_code`, `parse_invite_code`, `hkdf_invite_index_hex`, `hkdf_invite_key`, `generate_nonce`. - FFI: `IDKitInviteCodeRequest` sibling to `IDKitRequestWrapper`. New builder methods `constraints_with_invite_code` / `preset_with_invite_code` on `IDKitBuilder`. - WASM: `IDKitInviteCodeRequest` sibling to `IDKitRequest`, `constraintsWithInviteCode` / `presetWithInviteCode` on the WASM builder. - TypeScript: `IDKitInviteCodeRequest` interface + impl, `IDKitInviteCodeBuilder`, `IDKit.requestWithInviteCode(...)` entry point. Code mode is bridge-only by definition (user is on a different device than World App), so the builder skips the `isInWorldApp()` branch. - React: `IDKitInviteCodeRequestWidget`, `useIDKitInviteCodeRequest`, `useIDKitInviteCodeFlow` hooks. New `InviteCodeState` UI component. - Swift: `presetWithInviteCode(_:)` / `constraintsWithInviteCode(_:)` on the builder; `IDKitInviteCodeRequest` wrapper exposing `code` and `expiresAt: Date`. Adopter diff is two lines per integration: // before const req = await IDKit.request(config).constraints(...); showQR(req.connectorURI); // after const req = await IDKit.requestWithInviteCode(config).constraints(...); showCode(req.code); Tests: - 9 new Rust unit tests covering code generation, parser (round-trip, separator stripping, lowercase normalization, Crockford ambiguity collapse, U-rejection, length validation, check-digit validation, exhaustive single-char substitution detection), HKDF determinism, hex output shape, index/key differentiation across info strings. - 86 existing rust tests pass; 54 core JS tests pass; 23 React tests pass. Clippy clean on native + ffi feature combos. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent da524d8 commit e3fd24c

21 files changed

Lines changed: 1901 additions & 89 deletions

Cargo.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ strum = { version = "0.27", features = ["derive"] }
2828
# Cryptography
2929
aes-gcm = "0.10"
3030
getrandom = { version = "0.2", features = ["js"] }
31+
hkdf = "0.12"
32+
sha2 = "0.10"
3133
tiny-keccak = { version = "2.0", features = ["keccak"] }
3234

3335
# Encoding

js/packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export {
2020
selfieCheckLegacy,
2121
// Types
2222
type IDKitRequest,
23+
type IDKitInviteCodeRequest,
2324
type IDKitCompletionResult,
2425
type WaitOptions,
2526
type RpContext,

js/packages/core/src/request.ts

Lines changed: 233 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,46 @@ export interface IDKitRequest {
7272
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult>;
7373
}
7474

75+
/**
76+
* Shared poll loop. Used by both URL-mode and invite-code-mode request impls;
77+
* the loop body is identical between the two paths because the bridge
78+
* `Status` shape is mode-agnostic.
79+
*/
80+
async function pollUntilCompletionLoop(
81+
pollOnce: () => Promise<Status>,
82+
options?: WaitOptions,
83+
): Promise<IDKitCompletionResult> {
84+
const pollInterval = options?.pollInterval ?? 1000;
85+
const timeout = options?.timeout ?? 900_000; // 15 minutes default
86+
const startTime = Date.now();
87+
88+
while (true) {
89+
if (options?.signal?.aborted) {
90+
return { success: false, error: IDKitErrorCodes.Cancelled };
91+
}
92+
93+
if (Date.now() - startTime > timeout) {
94+
return { success: false, error: IDKitErrorCodes.Timeout };
95+
}
96+
97+
const status = await pollOnce();
98+
99+
if (status.type === "confirmed" && status.result) {
100+
return { success: true, result: status.result };
101+
}
102+
103+
if (status.type === "failed") {
104+
return {
105+
success: false,
106+
error:
107+
(status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError,
108+
};
109+
}
110+
111+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
112+
}
113+
}
114+
75115
/**
76116
* Internal request implementation (bridge/WASM path)
77117
*/
@@ -98,38 +138,70 @@ class IDKitRequestImpl implements IDKitRequest {
98138
return (await this.wasmRequest.pollForStatus()) as Status;
99139
}
100140

101-
async pollUntilCompletion(
102-
options?: WaitOptions,
103-
): Promise<IDKitCompletionResult> {
104-
const pollInterval = options?.pollInterval ?? 1000;
105-
const timeout = options?.timeout ?? 900_000; // 15 minutes default
106-
const startTime = Date.now();
141+
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult> {
142+
return pollUntilCompletionLoop(() => this.pollOnce(), options);
143+
}
144+
}
107145

108-
while (true) {
109-
if (options?.signal?.aborted) {
110-
return { success: false, error: IDKitErrorCodes.Cancelled };
111-
}
146+
/**
147+
* An invite-code mode World ID verification request (WDP-73).
148+
*
149+
* Sibling shape to {@link IDKitRequest}, but discovery happens through a
150+
* 6-character code the user types into World App instead of a QR scan. The
151+
* polling lifecycle is byte-identical to URL mode — same `Status`, same
152+
* `IDKitCompletionResult` — so adopters write the same poll loop.
153+
*
154+
* Only the constructor and the displayable `code` differ between modes.
155+
*/
156+
export interface IDKitInviteCodeRequest {
157+
/** Canonical 6-char Crockford Base32 code (no separator). UI may format as "ABC-DEF" for display. */
158+
readonly code: string;
159+
/** Unix-seconds expiry of the unredeemed code. After this point bridge will reject the redeem. */
160+
readonly expiresAt: number;
161+
/** Unique request ID for this verification */
162+
readonly requestId: string;
163+
/** Poll once for current status (for manual polling) */
164+
pollOnce(): Promise<Status>;
165+
/** Poll continuously until completion or timeout */
166+
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult>;
167+
}
112168

113-
if (Date.now() - startTime > timeout) {
114-
return { success: false, error: IDKitErrorCodes.Timeout };
115-
}
169+
/**
170+
* Internal invite-code request implementation (bridge/WASM only — code mode
171+
* has no in-app native postMessage path by design; the user is on a different
172+
* device than World App).
173+
*/
174+
class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest {
175+
private wasmRequest: WasmModule.IDKitInviteCodeRequest;
176+
private _code: string;
177+
private _expiresAt: number;
178+
private _requestId: string;
179+
180+
constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) {
181+
this.wasmRequest = wasmRequest;
182+
this._code = wasmRequest.code();
183+
this._expiresAt = wasmRequest.expiresAt();
184+
this._requestId = wasmRequest.requestId();
185+
}
116186

117-
const status = await this.pollOnce();
187+
get code(): string {
188+
return this._code;
189+
}
118190

119-
if (status.type === "confirmed" && status.result) {
120-
return { success: true, result: status.result };
121-
}
191+
get expiresAt(): number {
192+
return this._expiresAt;
193+
}
122194

123-
if (status.type === "failed") {
124-
return {
125-
success: false,
126-
error:
127-
(status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError,
128-
};
129-
}
195+
get requestId(): string {
196+
return this._requestId;
197+
}
130198

131-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
132-
}
199+
async pollOnce(): Promise<Status> {
200+
return (await this.wasmRequest.pollForStatus()) as Status;
201+
}
202+
203+
pollUntilCompletion(options?: WaitOptions): Promise<IDKitCompletionResult> {
204+
return pollUntilCompletionLoop(() => this.pollOnce(), options);
133205
}
134206
}
135207

@@ -547,6 +619,82 @@ class IDKitBuilder {
547619
}
548620
}
549621

622+
/**
623+
* Builder for invite-code mode requests (WDP-73).
624+
*
625+
* Code mode is bridge-only by definition: the user is on a different device
626+
* than World App (e.g. desktop browser ↔ phone), so there's no in-app native
627+
* postMessage path to branch on. This builder skips the `isInWorldApp()`
628+
* check that {@link IDKitBuilder} performs.
629+
*/
630+
class IDKitInviteCodeBuilder {
631+
private config: BuilderConfig;
632+
633+
constructor(config: BuilderConfig) {
634+
this.config = config;
635+
}
636+
637+
/**
638+
* Creates an invite-code mode IDKit request with the given constraints.
639+
*
640+
* @param constraints - Constraint tree (CredentialRequest or any/all/enumerate combinators)
641+
* @returns A new IDKitInviteCodeRequest instance
642+
*
643+
* @example
644+
* ```typescript
645+
* const request = await IDKit.requestWithInviteCode({ app_id, action, rp_context, allow_legacy_proofs: false })
646+
* .constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face')));
647+
* showCode(request.code);
648+
* ```
649+
*/
650+
async constraints(
651+
constraints: ConstraintNode,
652+
): Promise<IDKitInviteCodeRequest> {
653+
await initIDKit();
654+
655+
const wasmBuilder = createWasmBuilderFromConfig(this.config);
656+
const wasmRequest = (await (
657+
wasmBuilder as unknown as {
658+
constraintsWithInviteCode: (
659+
c: ConstraintNode,
660+
) => Promise<unknown>;
661+
}
662+
).constraintsWithInviteCode(
663+
constraints,
664+
)) as unknown as WasmModule.IDKitInviteCodeRequest;
665+
return new IDKitInviteCodeRequestImpl(wasmRequest);
666+
}
667+
668+
/**
669+
* Creates an invite-code mode IDKit request from a preset.
670+
*
671+
* @param preset - A preset object from orbLegacy(), secureDocumentLegacy(), documentLegacy(), selfieCheckLegacy(), or deviceLegacy()
672+
* @returns A new IDKitInviteCodeRequest instance
673+
*/
674+
async preset(preset: Preset): Promise<IDKitInviteCodeRequest> {
675+
if (
676+
this.config.type === "createSession" ||
677+
this.config.type === "proveSession"
678+
) {
679+
throw new Error(
680+
"Presets are not supported for session flows. Use .constraints() instead.",
681+
);
682+
}
683+
684+
await initIDKit();
685+
686+
const wasmBuilder = createWasmBuilderFromConfig(this.config);
687+
const wasmRequest = (await (
688+
wasmBuilder as unknown as {
689+
presetWithInviteCode: (p: Preset) => Promise<unknown>;
690+
}
691+
).presetWithInviteCode(
692+
preset,
693+
)) as unknown as WasmModule.IDKitInviteCodeRequest;
694+
return new IDKitInviteCodeRequestImpl(wasmRequest);
695+
}
696+
}
697+
550698
// ─────────────────────────────────────────────────────────────────────────────
551699
// Entry points
552700
// ─────────────────────────────────────────────────────────────────────────────
@@ -623,6 +771,63 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder {
623771
});
624772
}
625773

774+
/**
775+
* Creates an invite-code mode IDKit request builder (WDP-73).
776+
*
777+
* Sibling entry point to {@link createRequest}. Validates the same required
778+
* fields, returns a {@link IDKitInviteCodeBuilder} whose `.constraints()` /
779+
* `.preset()` methods produce {@link IDKitInviteCodeRequest} handles.
780+
*
781+
* @example
782+
* ```typescript
783+
* const request = await IDKit.requestWithInviteCode({
784+
* app_id: 'app_staging_xxxxx',
785+
* action: 'my-action',
786+
* rp_context: { ... },
787+
* allow_legacy_proofs: false,
788+
* }).constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face')));
789+
*
790+
* showCode(request.code); // user types this into World App
791+
* const proof = await request.pollUntilCompletion();
792+
* ```
793+
*/
794+
function createRequestWithInviteCode(
795+
config: IDKitRequestConfig,
796+
): IDKitInviteCodeBuilder {
797+
// Validate required fields — mirror createRequest exactly so integrators
798+
// don't get different validation between the two paths.
799+
if (!config.app_id) {
800+
throw new Error("app_id is required");
801+
}
802+
if (!config.action) {
803+
throw new Error("action is required");
804+
}
805+
if (!config.rp_context) {
806+
throw new Error(
807+
"rp_context is required. Generate it on your backend using signRequest().",
808+
);
809+
}
810+
if (typeof config.allow_legacy_proofs !== "boolean") {
811+
throw new Error(
812+
"allow_legacy_proofs is required. Set to true to accept v3 proofs during migration, " +
813+
"or false to only accept v4 proofs.",
814+
);
815+
}
816+
817+
return new IDKitInviteCodeBuilder({
818+
type: "request",
819+
app_id: config.app_id,
820+
action: String(config.action),
821+
rp_context: config.rp_context,
822+
action_description: config.action_description,
823+
bridge_url: config.bridge_url,
824+
return_to: config.return_to,
825+
allow_legacy_proofs: config.allow_legacy_proofs,
826+
override_connect_base_url: config.override_connect_base_url,
827+
environment: config.environment,
828+
});
829+
}
830+
626831
/**
627832
* Creates a new session builder (no action, no existing session_id)
628833
*
@@ -757,6 +962,8 @@ function proveSession(
757962
export const IDKit = {
758963
/** Create a new verification request */
759964
request: createRequest,
965+
/** Create a new invite-code mode verification request (WDP-73) */
966+
requestWithInviteCode: createRequestWithInviteCode,
760967
/** Create a new session (no action, no existing session_id) */
761968
createSession,
762969
/** Prove an existing session (no action, has session_id) */

0 commit comments

Comments
 (0)