diff --git a/Cargo.lock b/Cargo.lock index 4917f822..f69843df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2447,6 +2447,15 @@ dependencies = [ "arrayvec", ] +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + [[package]] name = "hmac" version = "0.12.1" @@ -2685,6 +2694,7 @@ dependencies = [ "console_error_panic_hook", "getrandom 0.2.17", "hex", + "hkdf", "js-sys", "k256", "reqwest", @@ -2692,6 +2702,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "sha2", "strum", "taceo-oprf", "thiserror 1.0.69", diff --git a/Cargo.toml b/Cargo.toml index ce2f0727..87ff9445 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ strum = { version = "0.27", features = ["derive"] } # Cryptography aes-gcm = "0.10" getrandom = { version = "0.2", features = ["js"] } +hkdf = "0.12" +sha2 = "0.10" tiny-keccak = { version = "2.0", features = ["keccak"] } # Encoding diff --git a/js/packages/core/src/index.ts b/js/packages/core/src/index.ts index 8cd12fc6..9d797137 100644 --- a/js/packages/core/src/index.ts +++ b/js/packages/core/src/index.ts @@ -20,6 +20,7 @@ export { selfieCheckLegacy, // Types type IDKitRequest, + type IDKitInviteCodeRequest, type IDKitCompletionResult, type WaitOptions, type RpContext, diff --git a/js/packages/core/src/request.ts b/js/packages/core/src/request.ts index 303eacc5..bb48a7ec 100644 --- a/js/packages/core/src/request.ts +++ b/js/packages/core/src/request.ts @@ -72,6 +72,46 @@ export interface IDKitRequest { pollUntilCompletion(options?: WaitOptions): Promise; } +/** + * Shared poll loop. Used by both URL-mode and invite-code-mode request impls; + * the loop body is identical between the two paths because the bridge + * `Status` shape is mode-agnostic. + */ +async function pollUntilCompletionLoop( + pollOnce: () => Promise, + options?: WaitOptions, +): Promise { + const pollInterval = options?.pollInterval ?? 1000; + const timeout = options?.timeout ?? 900_000; // 15 minutes default + const startTime = Date.now(); + + while (true) { + if (options?.signal?.aborted) { + return { success: false, error: IDKitErrorCodes.Cancelled }; + } + + if (Date.now() - startTime > timeout) { + return { success: false, error: IDKitErrorCodes.Timeout }; + } + + const status = await pollOnce(); + + if (status.type === "confirmed" && status.result) { + return { success: true, result: status.result }; + } + + if (status.type === "failed") { + return { + success: false, + error: + (status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError, + }; + } + + await new Promise((resolve) => setTimeout(resolve, pollInterval)); + } +} + /** * Internal request implementation (bridge/WASM path) */ @@ -98,38 +138,69 @@ class IDKitRequestImpl implements IDKitRequest { return (await this.wasmRequest.pollForStatus()) as Status; } - async pollUntilCompletion( - options?: WaitOptions, - ): Promise { - const pollInterval = options?.pollInterval ?? 1000; - const timeout = options?.timeout ?? 900_000; // 15 minutes default - const startTime = Date.now(); + pollUntilCompletion(options?: WaitOptions): Promise { + return pollUntilCompletionLoop(() => this.pollOnce(), options); + } +} - while (true) { - if (options?.signal?.aborted) { - return { success: false, error: IDKitErrorCodes.Cancelled }; - } +/** + * An invite-code mode World ID verification request (WDP-73). + * + * Sibling shape to {@link IDKitRequest}, but discovery happens through a + * URL pointing at the `world.org/verify` landing page (which displays the + * code for the user to type into World App). The polling lifecycle is + * byte-identical to URL mode — same `Status`, same `IDKitCompletionResult` — + * so adopters write the same poll loop. + */ +export interface IDKitInviteCodeRequest { + /** URL to display to the user. Same shape as URL/QR mode's `connectorURI` with `&c=&a=` appended. */ + readonly connectorURI: string; + /** Unix-seconds expiry of the unredeemed code. After this point bridge will reject the redeem. */ + readonly expiresAt: number; + /** Unique request ID for this verification */ + readonly requestId: string; + /** Poll once for current status (for manual polling) */ + pollOnce(): Promise; + /** Poll continuously until completion or timeout */ + pollUntilCompletion(options?: WaitOptions): Promise; +} - if (Date.now() - startTime > timeout) { - return { success: false, error: IDKitErrorCodes.Timeout }; - } +/** + * Internal invite-code request implementation (bridge/WASM only — code mode + * has no in-app native postMessage path by design; the user is on a different + * device than World App). + */ +class IDKitInviteCodeRequestImpl implements IDKitInviteCodeRequest { + private wasmRequest: WasmModule.IDKitInviteCodeRequest; + private _connectorURI: string; + private _expiresAt: number; + private _requestId: string; - const status = await this.pollOnce(); + constructor(wasmRequest: WasmModule.IDKitInviteCodeRequest) { + this.wasmRequest = wasmRequest; + this._connectorURI = wasmRequest.connectUrl(); + this._expiresAt = wasmRequest.expiresAt(); + this._requestId = wasmRequest.requestId(); + } - if (status.type === "confirmed" && status.result) { - return { success: true, result: status.result }; - } + get connectorURI(): string { + return this._connectorURI; + } - if (status.type === "failed") { - return { - success: false, - error: - (status.error as IDKitErrorCodes) ?? IDKitErrorCodes.GenericError, - }; - } + get expiresAt(): number { + return this._expiresAt; + } - await new Promise((resolve) => setTimeout(resolve, pollInterval)); - } + get requestId(): string { + return this._requestId; + } + + async pollOnce(): Promise { + return (await this.wasmRequest.pollForStatus()) as Status; + } + + pollUntilCompletion(options?: WaitOptions): Promise { + return pollUntilCompletionLoop(() => this.pollOnce(), options); } } @@ -547,6 +618,72 @@ class IDKitBuilder { } } +/** + * Builder for invite-code mode requests (WDP-73). + * + * Code mode is bridge-only by definition: the user is on a different device + * than World App (e.g. desktop browser ↔ phone), so there's no in-app native + * postMessage path to branch on. This builder skips the `isInWorldApp()` + * check that {@link IDKitBuilder} performs. + */ +class IDKitInviteCodeBuilder { + private config: BuilderConfig; + + constructor(config: BuilderConfig) { + this.config = config; + } + + /** + * Creates an invite-code mode IDKit request with the given constraints. + * + * @param constraints - Constraint tree (CredentialRequest or any/all/enumerate combinators) + * @returns A new IDKitInviteCodeRequest instance + * + * @example + * ```typescript + * const request = await IDKit.requestWithInviteCode({ app_id, action, rp_context, allow_legacy_proofs: false }) + * .constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face'))); + * displayLink(request.connectorURI); + * ``` + */ + async constraints( + constraints: ConstraintNode, + ): Promise { + await initIDKit(); + + const wasmBuilder = createWasmBuilderFromConfig(this.config); + const wasmRequest = (await wasmBuilder.constraintsWithInviteCode( + constraints, + )) as unknown as WasmModule.IDKitInviteCodeRequest; + return new IDKitInviteCodeRequestImpl(wasmRequest); + } + + /** + * Creates an invite-code mode IDKit request from a preset. + * + * @param preset - A preset object from orbLegacy(), secureDocumentLegacy(), documentLegacy(), selfieCheckLegacy(), or deviceLegacy() + * @returns A new IDKitInviteCodeRequest instance + */ + async preset(preset: Preset): Promise { + if ( + this.config.type === "createSession" || + this.config.type === "proveSession" + ) { + throw new Error( + "Presets are not supported for session flows. Use .constraints() instead.", + ); + } + + await initIDKit(); + + const wasmBuilder = createWasmBuilderFromConfig(this.config); + const wasmRequest = (await wasmBuilder.presetWithInviteCode( + preset, + )) as unknown as WasmModule.IDKitInviteCodeRequest; + return new IDKitInviteCodeRequestImpl(wasmRequest); + } +} + // ───────────────────────────────────────────────────────────────────────────── // Entry points // ───────────────────────────────────────────────────────────────────────────── @@ -623,6 +760,63 @@ function createRequest(config: IDKitRequestConfig): IDKitBuilder { }); } +/** + * Creates an invite-code mode IDKit request builder (WDP-73). + * + * Sibling entry point to {@link createRequest}. Validates the same required + * fields, returns a {@link IDKitInviteCodeBuilder} whose `.constraints()` / + * `.preset()` methods produce {@link IDKitInviteCodeRequest} handles. + * + * @example + * ```typescript + * const request = await IDKit.requestWithInviteCode({ + * app_id: 'app_staging_xxxxx', + * action: 'my-action', + * rp_context: { ... }, + * allow_legacy_proofs: false, + * }).constraints(any(CredentialRequest('proof_of_human'), CredentialRequest('face'))); + * + * displayLink(request.connectorURI); // user opens this URL on their phone + * const proof = await request.pollUntilCompletion(); + * ``` + */ +function createRequestWithInviteCode( + config: IDKitRequestConfig, +): IDKitInviteCodeBuilder { + // Validate required fields — mirror createRequest exactly so integrators + // don't get different validation between the two paths. + if (!config.app_id) { + throw new Error("app_id is required"); + } + if (!config.action) { + throw new Error("action is required"); + } + if (!config.rp_context) { + throw new Error( + "rp_context is required. Generate it on your backend using signRequest().", + ); + } + if (typeof config.allow_legacy_proofs !== "boolean") { + throw new Error( + "allow_legacy_proofs is required. Set to true to accept v3 proofs during migration, " + + "or false to only accept v4 proofs.", + ); + } + + return new IDKitInviteCodeBuilder({ + type: "request", + app_id: config.app_id, + action: String(config.action), + rp_context: config.rp_context, + action_description: config.action_description, + bridge_url: config.bridge_url, + return_to: config.return_to, + allow_legacy_proofs: config.allow_legacy_proofs, + override_connect_base_url: config.override_connect_base_url, + environment: config.environment, + }); +} + /** * Creates a new session builder (no action, no existing session_id) * @@ -757,6 +951,8 @@ function proveSession( export const IDKit = { /** Create a new verification request */ request: createRequest, + /** Create a new invite-code mode verification request (WDP-73) */ + requestWithInviteCode: createRequestWithInviteCode, /** Create a new session (no action, no existing session_id) */ createSession, /** Prove an existing session (no action, has session_id) */ diff --git a/js/packages/react/src/components/States/InviteCodeState.tsx b/js/packages/react/src/components/States/InviteCodeState.tsx new file mode 100644 index 00000000..43433068 --- /dev/null +++ b/js/packages/react/src/components/States/InviteCodeState.tsx @@ -0,0 +1,137 @@ +import { useEffect, useState, type ReactElement } from "react"; +import { __ } from "../../lang"; +import { WorldcoinIcon } from "../Icons/WorldIcon"; +import { LoadingIcon } from "../Icons/LoadingIcon"; +import { QRCode } from "../../widget/QRCode"; + +type InviteCodeStateProps = { + connectorURI: string | null; + codeExpiresAt: number | null; + isAwaitingUserConfirmation: boolean; +}; + +function useNowInSeconds(): number { + const [now, setNow] = useState(() => Math.floor(Date.now() / 1000)); + useEffect(() => { + const id = setInterval(() => { + setNow(Math.floor(Date.now() / 1000)); + }, 1000); + return () => clearInterval(id); + }, []); + return now; +} + +export function InviteCodeState({ + connectorURI, + codeExpiresAt, + isAwaitingUserConfirmation, +}: InviteCodeStateProps): ReactElement { + const now = useNowInSeconds(); + const secondsRemaining = + codeExpiresAt !== null ? Math.max(0, codeExpiresAt - now) : null; + + return ( +
+
+ +
+ +

{__("Connect your World ID")}

+ +

+ {__("Scan with your phone to continue verifying")} +

+ +
+ {isAwaitingUserConfirmation && ( +
+
+ +
+
+

{__("Connecting...")}

+

{__("Please continue in app")}

+
+
+ )} + +
+ {connectorURI ? : null} +
+
+ + {connectorURI && ( +
+ + {connectorURI} + + +
+ )} + + {secondsRemaining !== null && ( +
+ {__("Expires in")} {secondsRemaining}s +
+ )} +
+ ); +} diff --git a/js/packages/react/src/hooks/inviteCodeCommon.ts b/js/packages/react/src/hooks/inviteCodeCommon.ts new file mode 100644 index 00000000..fc1e7a6d --- /dev/null +++ b/js/packages/react/src/hooks/inviteCodeCommon.ts @@ -0,0 +1,30 @@ +import type { IDKitErrorCodes } from "@worldcoin/idkit-core"; + +type IDKitHookStatus = + | "idle" + | "waiting_for_connection" + | "awaiting_confirmation" + | "confirmed" + | "failed"; + +export type InviteCodeHookState = { + isOpen: boolean; + status: IDKitHookStatus; + connectorURI: string | null; + codeExpiresAt: number | null; + result: TResult | null; + errorCode: IDKitErrorCodes | null; +}; + +export function createInitialInviteCodeHookState< + TResult, +>(): InviteCodeHookState { + return { + isOpen: false, + status: "idle", + connectorURI: null, + codeExpiresAt: null, + result: null, + errorCode: null, + }; +} diff --git a/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts b/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts new file mode 100644 index 00000000..3d9eab12 --- /dev/null +++ b/js/packages/react/src/hooks/useIDKitInviteCodeFlow.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + IDKitErrorCodes, + isInWorldApp as isInWorldAppCheck, + isDebug, + type IDKitInviteCodeRequest, +} from "@worldcoin/idkit-core"; +import type { FlowConfig, IDKitInviteCodeHookResult } from "../types"; +import { delay, ensureNotAborted, toErrorCode } from "./common"; +import { + createInitialInviteCodeHookState, + type InviteCodeHookState, +} from "./inviteCodeCommon"; + +export function useIDKitInviteCodeFlow( + createFlowHandle: () => Promise, + config: FlowConfig, +): IDKitInviteCodeHookResult { + const isInWorldApp = useMemo(() => isInWorldAppCheck(), []); + + const [state, setState] = useState>( + createInitialInviteCodeHookState, + ); + const [runId, setRunId] = useState(0); + // Mutable handle so event handlers (reset) can cancel the active polling loop. + const abortRef = useRef(null); + // Refs keep the effect stable (deps: [state.isOpen]) while always reading the latest values. + const createFlowHandleRef = useRef(createFlowHandle); + const configRef = useRef(config); + + // Updated every render so the effect reads fresh closures/config without re-triggering. + createFlowHandleRef.current = createFlowHandle; + configRef.current = config; + + const reset = useCallback(() => { + abortRef.current?.abort(); + abortRef.current = null; + setState(createInitialInviteCodeHookState); + setRunId((id) => id + 1); + }, []); + + const open = useCallback(() => { + setState((prev) => { + if (prev.isOpen) { + return prev; + } + + return { + isOpen: true, + status: "waiting_for_connection", + connectorURI: null, + codeExpiresAt: null, + result: null, + errorCode: null, + }; + }); + }, []); + + useEffect(() => { + if (!state.isOpen) { + return; + } + + const controller = new AbortController(); + abortRef.current = controller; + + const setFailed = (errorCode: IDKitErrorCodes) => { + setState((prev) => { + if (prev.status === "failed" && prev.errorCode === errorCode) { + return prev; + } + + return { + ...prev, + status: "failed", + errorCode, + }; + }); + }; + + void (async () => { + try { + if (isDebug()) + console.debug("[IDKit] Creating invite-code flow handle…"); + const request = await createFlowHandleRef.current(); + ensureNotAborted(controller.signal); + if (isDebug()) + console.debug("[IDKit] Invite-code flow created", { + connectorURI: request.connectorURI, + expiresAt: request.expiresAt, + requestId: request.requestId, + }); + + const connectorURI = request.connectorURI; + const codeExpiresAt = request.expiresAt; + setState((prev) => { + if ( + prev.connectorURI === connectorURI && + prev.codeExpiresAt === codeExpiresAt + ) { + return prev; + } + return { ...prev, connectorURI, codeExpiresAt }; + }); + + const pollInterval = configRef.current.polling?.interval ?? 1000; + const timeout = configRef.current.polling?.timeout ?? 900_000; + const startedAt = Date.now(); + + while (true) { + ensureNotAborted(controller.signal); + + if (Date.now() - startedAt > timeout) { + setFailed(IDKitErrorCodes.Timeout); + return; + } + + const nextStatus = await request.pollOnce(); + ensureNotAborted(controller.signal); + + if (nextStatus.type === "confirmed") { + const confirmedResult = nextStatus.result; + if (!confirmedResult) { + setFailed(IDKitErrorCodes.UnexpectedResponse); + return; + } + + setState((prev) => ({ + ...prev, + status: "confirmed", + result: confirmedResult as TResult, + errorCode: null, + })); + return; + } + + if (nextStatus.type === "failed") { + if (isDebug()) + console.warn( + "[IDKit] Invite-code poll returned failed", + nextStatus, + ); + setFailed(nextStatus.error ?? IDKitErrorCodes.GenericError); + return; + } + + setState((prev) => { + if (prev.status === nextStatus.type) { + return prev; + } + return { ...prev, status: nextStatus.type }; + }); + + await delay(pollInterval, controller.signal); + } + } catch (error) { + if (controller.signal.aborted) { + if (isDebug()) console.debug("[IDKit] Invite-code flow aborted"); + return; + } + + if (isDebug()) console.error("[IDKit] Invite-code flow error:", error); + setFailed(toErrorCode(error)); + } + })(); + + return () => { + controller.abort(); + if (abortRef.current === controller) { + abortRef.current = null; + } + }; + }, [state.isOpen, runId, isInWorldApp]); + + return { + open, + reset, + isAwaitingUserConnection: state.status === "waiting_for_connection", + isAwaitingUserConfirmation: state.status === "awaiting_confirmation", + isSuccess: state.status === "confirmed", + isError: state.status === "failed", + connectorURI: state.connectorURI, + codeExpiresAt: state.codeExpiresAt, + result: state.result, + errorCode: state.errorCode, + isOpen: state.isOpen, + isInWorldApp, + }; +} diff --git a/js/packages/react/src/hooks/useIDKitInviteCodeRequest.ts b/js/packages/react/src/hooks/useIDKitInviteCodeRequest.ts new file mode 100644 index 00000000..bc3302cb --- /dev/null +++ b/js/packages/react/src/hooks/useIDKitInviteCodeRequest.ts @@ -0,0 +1,28 @@ +import { IDKit, type IDKitResult } from "@worldcoin/idkit-core"; +import type { + IDKitInviteCodeRequestHookConfig, + UseIDKitInviteCodeRequestHookResult, +} from "../types"; +import { useIDKitInviteCodeFlow } from "./useIDKitInviteCodeFlow"; + +export function useIDKitInviteCodeRequest( + config: IDKitInviteCodeRequestHookConfig, +): UseIDKitInviteCodeRequestHookResult { + return useIDKitInviteCodeFlow(() => { + const builder = IDKit.requestWithInviteCode({ + app_id: config.app_id, + action: config.action, + rp_context: config.rp_context, + action_description: config.action_description, + bridge_url: config.bridge_url, + return_to: config.return_to, + allow_legacy_proofs: config.allow_legacy_proofs, + override_connect_base_url: config.override_connect_base_url, + environment: config.environment, + }); + if ("constraints" in config && config.constraints) { + return builder.constraints(config.constraints); + } + return builder.preset(config.preset!); + }, config); +} diff --git a/js/packages/react/src/index.ts b/js/packages/react/src/index.ts index 5d889a71..39a100d3 100644 --- a/js/packages/react/src/index.ts +++ b/js/packages/react/src/index.ts @@ -1,12 +1,20 @@ export { useIDKitRequest } from "./hooks/useIDKitRequest"; +export { useIDKitInviteCodeRequest } from "./hooks/useIDKitInviteCodeRequest"; export { useIDKitSession } from "./hooks/useIDKitSession"; export { IDKitRequestWidget } from "./widget/IDKitRequestWidget"; +export { IDKitInviteCodeRequestWidget } from "./widget/IDKitInviteCodeRequestWidget"; export { IDKitSessionWidget } from "./widget/IDKitSessionWidget"; -export type { IDKitHookResult, PollingConfig } from "./types/common"; +export type { + IDKitHookResult, + IDKitInviteCodeHookResult, + PollingConfig, +} from "./types/common"; export type { IDKitRequestHookConfig, UseIDKitRequestHookResult, + IDKitInviteCodeRequestHookConfig, + UseIDKitInviteCodeRequestHookResult, } from "./types/request"; export type { IDKitSessionHookConfig, @@ -14,6 +22,7 @@ export type { } from "./types/session"; export type { IDKitRequestWidgetProps, + IDKitInviteCodeRequestWidgetProps, IDKitSessionWidgetProps, } from "./types/widget"; diff --git a/js/packages/react/src/types/common.ts b/js/packages/react/src/types/common.ts index b0e09e0a..292d1912 100644 --- a/js/packages/react/src/types/common.ts +++ b/js/packages/react/src/types/common.ts @@ -31,3 +31,21 @@ export type IDKitHookResult = { /** Use `isInWorldApp` to determine if the widget is running inside the World App (mini app context). */ isInWorldApp: boolean; }; + +export type IDKitInviteCodeHookResult = { + open: () => void; + reset: () => void; + isAwaitingUserConnection: boolean; + isAwaitingUserConfirmation: boolean; + isSuccess: boolean; + isError: boolean; + /** URL to display to the user (same shape as URL/QR mode's `connectorURI`, with `&c=&a=` appended for the `world.org/verify` landing page). */ + connectorURI: string | null; + /** Unix-seconds expiry of the unredeemed code. */ + codeExpiresAt: number | null; + result: TResult | null; + errorCode: IDKitErrorCodes | null; + isOpen: boolean; + /** Use `isInWorldApp` to determine if the widget is running inside the World App (mini app context). */ + isInWorldApp: boolean; +}; diff --git a/js/packages/react/src/types/index.ts b/js/packages/react/src/types/index.ts index 48be4f1b..66d50936 100644 --- a/js/packages/react/src/types/index.ts +++ b/js/packages/react/src/types/index.ts @@ -1,7 +1,14 @@ -export type { PollingConfig, FlowConfig, IDKitHookResult } from "./common"; +export type { + PollingConfig, + FlowConfig, + IDKitHookResult, + IDKitInviteCodeHookResult, +} from "./common"; export type { IDKitRequestHookConfig, UseIDKitRequestHookResult, + IDKitInviteCodeRequestHookConfig, + UseIDKitInviteCodeRequestHookResult, } from "./request"; export type { IDKitSessionHookConfig, @@ -9,5 +16,6 @@ export type { } from "./session"; export type { IDKitRequestWidgetProps, + IDKitInviteCodeRequestWidgetProps, IDKitSessionWidgetProps, } from "./widget"; diff --git a/js/packages/react/src/types/request.ts b/js/packages/react/src/types/request.ts index c2ceccda..f50eb53d 100644 --- a/js/packages/react/src/types/request.ts +++ b/js/packages/react/src/types/request.ts @@ -4,7 +4,11 @@ import type { Preset, ConstraintNode, } from "@worldcoin/idkit-core"; -import type { IDKitHookResult, PollingConfig } from "./common"; +import type { + IDKitHookResult, + IDKitInviteCodeHookResult, + PollingConfig, +} from "./common"; export type IDKitRequestHookConfig = IDKitRequestConfig & PollingConfig & @@ -14,3 +18,13 @@ export type IDKitRequestHookConfig = IDKitRequestConfig & ); export type UseIDKitRequestHookResult = IDKitHookResult; + +export type IDKitInviteCodeRequestHookConfig = IDKitRequestConfig & + PollingConfig & + ( + | { preset: Preset; constraints?: never } + | { constraints: ConstraintNode; preset?: never } + ); + +export type UseIDKitInviteCodeRequestHookResult = + IDKitInviteCodeHookResult; diff --git a/js/packages/react/src/types/widget.ts b/js/packages/react/src/types/widget.ts index 56394008..428b5ac6 100644 --- a/js/packages/react/src/types/widget.ts +++ b/js/packages/react/src/types/widget.ts @@ -1,4 +1,7 @@ -import type { IDKitRequestHookConfig } from "./request"; +import type { + IDKitInviteCodeRequestHookConfig, + IDKitRequestHookConfig, +} from "./request"; import type { IDKitSessionHookConfig } from "./session"; import type { IDKitErrorCodes, @@ -22,5 +25,8 @@ type WidgetSharedProps = { export type IDKitRequestWidgetProps = IDKitRequestHookConfig & WidgetSharedProps; +export type IDKitInviteCodeRequestWidgetProps = + IDKitInviteCodeRequestHookConfig & WidgetSharedProps; + export type IDKitSessionWidgetProps = IDKitSessionHookConfig & WidgetSharedProps; diff --git a/js/packages/react/src/widget/IDKitInviteCodeRequestWidget.tsx b/js/packages/react/src/widget/IDKitInviteCodeRequestWidget.tsx new file mode 100644 index 00000000..a4106e5b --- /dev/null +++ b/js/packages/react/src/widget/IDKitInviteCodeRequestWidget.tsx @@ -0,0 +1,36 @@ +import type { ReactElement } from "react"; +import type { IDKitInviteCodeRequestWidgetProps } from "../types"; +import { useIDKitInviteCodeRequest } from "../hooks/useIDKitInviteCodeRequest"; +import { IDKitInviteCodeWidgetBase } from "./IDKitInviteCodeWidgetBase"; + +export function IDKitInviteCodeRequestWidget({ + open, + onOpenChange, + handleVerify, + onSuccess, + onError, + autoClose, + language, + ...config +}: IDKitInviteCodeRequestWidgetProps): ReactElement | null { + if (typeof onSuccess !== "function") { + throw new Error( + "IDKitInviteCodeRequestWidget requires an onSuccess callback.", + ); + } + + const flow = useIDKitInviteCodeRequest(config); + + return ( + + ); +} diff --git a/js/packages/react/src/widget/IDKitInviteCodeWidgetBase.tsx b/js/packages/react/src/widget/IDKitInviteCodeWidgetBase.tsx new file mode 100644 index 00000000..83cb515c --- /dev/null +++ b/js/packages/react/src/widget/IDKitInviteCodeWidgetBase.tsx @@ -0,0 +1,196 @@ +import { useEffect, useRef, useState, type ReactElement } from "react"; +import { IDKitErrorCodes } from "@worldcoin/idkit-core"; +import type { IDKitInviteCodeHookResult } from "../types"; +import { IDKitModal } from "./IDKitModal"; +import { InviteCodeState } from "../components/States/InviteCodeState"; +import { SuccessState } from "../components/States/SuccessState"; +import { ErrorState } from "../components/States/ErrorState"; +import { HostAppVerificationState } from "../components/States/HostAppVerificationState"; +import { setLocalizationConfig } from "../lang"; +import type { SupportedLanguage } from "../lang/types"; + +type VisualStage = "invite_code" | "host_verification" | "success" | "error"; + +function getVisualStage( + isSuccess: boolean, + isError: boolean, + isHostVerifying: boolean, +): VisualStage { + if (isError) return "error"; + if (isHostVerifying) return "host_verification"; + if (isSuccess) return "success"; + return "invite_code"; +} + +type MaybePromise = Promise | T; + +export type IDKitInviteCodeWidgetBaseProps = { + flow: IDKitInviteCodeHookResult; + open: boolean; + onOpenChange: (open: boolean) => void; + handleVerify?: (result: TResult) => MaybePromise; + onSuccess: (result: TResult) => MaybePromise; + onError?: (errorCode: IDKitErrorCodes) => MaybePromise; + autoClose?: boolean; + language?: SupportedLanguage; +}; + +export function IDKitInviteCodeWidgetBase({ + flow, + open, + onOpenChange, + handleVerify, + onSuccess, + onError, + autoClose = true, + language, +}: IDKitInviteCodeWidgetBaseProps): ReactElement | null { + const { open: openFlow, reset: resetFlow } = flow; + + const [hostVerifyResult, setHostVerifyResult] = useState< + "passed" | "failed" | null + >(null); + const lastResultRef = useRef(null); + const lastErrorCodeRef = useRef(null); + // Generation counter: incremented on close/retry to invalidate stale + // handleVerify resolutions and prevent cross-run state leaks. + const verifyGenRef = useRef(0); + + // Set language config + useEffect(() => { + if (language) { + setLocalizationConfig({ language }); + } + }, [language]); + + useEffect(() => { + if (open) { + setHostVerifyResult(null); + openFlow(); + return; + } + + setHostVerifyResult(null); + lastResultRef.current = null; + lastErrorCodeRef.current = null; + verifyGenRef.current++; + resetFlow(); + }, [open, openFlow, resetFlow]); + + const isSuccess = + flow.isSuccess && (!handleVerify || hostVerifyResult === "passed"); + const isError = flow.isError || hostVerifyResult === "failed"; + const isHostVerifying = + flow.isSuccess && Boolean(handleVerify) && hostVerifyResult === null; + const effectiveErrorCode = + flow.errorCode ?? + (hostVerifyResult === "failed" ? IDKitErrorCodes.FailedByHostApp : null); + + useEffect(() => { + if (!isSuccess || !flow.result || flow.result === lastResultRef.current) { + return; + } + + lastResultRef.current = flow.result; + void Promise.resolve(onSuccess(flow.result)).catch(() => { + // Swallow host callback errors to keep widget flow stable. + }); + }, [flow.result, isSuccess, onSuccess]); + + useEffect(() => { + if ( + !effectiveErrorCode || + effectiveErrorCode === lastErrorCodeRef.current + ) { + return; + } + + lastErrorCodeRef.current = effectiveErrorCode; + void Promise.resolve(onError?.(effectiveErrorCode)).catch(() => { + // Swallow host callback errors to keep widget flow stable. + }); + }, [effectiveErrorCode, onError]); + + // In World App context there's no UI to render HostAppVerificationState, + // so invoke handleVerify programmatically when the proof arrives. + useEffect(() => { + if ( + !flow.isInWorldApp || + !isHostVerifying || + !flow.result || + !handleVerify + ) { + return; + } + + const gen = ++verifyGenRef.current; + + void Promise.resolve(handleVerify(flow.result)) + .then(() => { + if (verifyGenRef.current === gen) setHostVerifyResult("passed"); + }) + .catch(() => { + if (verifyGenRef.current === gen) setHostVerifyResult("failed"); + }); + }, [flow.isInWorldApp, isHostVerifying, flow.result, handleVerify]); + + // In World App there's no visible UI, so auto-close immediately on success or error. + // In bridge flow, only auto-close on success after the 2.5s delay (errors show retry UI). + useEffect(() => { + if (flow.isInWorldApp && (isSuccess || isError)) { + onOpenChange(false); + } else if (isSuccess && autoClose) { + const timer = setTimeout(() => onOpenChange(false), 2500); + return () => clearTimeout(timer); + } + }, [isSuccess, isError, autoClose, onOpenChange, flow.isInWorldApp]); + + // In World App context, the host app handles all UI — render nothing. + if (flow.isInWorldApp) { + return null; + } + + const stage = getVisualStage(isSuccess, isError, isHostVerifying); + + return ( + + {stage === "invite_code" && ( + + )} + {stage === "host_verification" && ( + { + const gen = ++verifyGenRef.current; + return Promise.resolve(handleVerify!(flow.result!)).then( + () => { + if (verifyGenRef.current === gen) setHostVerifyResult("passed"); + }, + () => { + if (verifyGenRef.current === gen) setHostVerifyResult("failed"); + }, + ); + }} + /> + )} + {stage === "success" && } + {stage === "error" && ( + onOpenChange(false)} + onRetry={() => { + setHostVerifyResult(null); + lastResultRef.current = null; + lastErrorCodeRef.current = null; + verifyGenRef.current++; + resetFlow(); + openFlow(); + }} + /> + )} + + ); +} diff --git a/rust/core/Cargo.toml b/rust/core/Cargo.toml index 2a21d1f4..16417cf0 100644 --- a/rust/core/Cargo.toml +++ b/rust/core/Cargo.toml @@ -23,6 +23,8 @@ serde_json = { workspace = true } strum = { workspace = true } aes-gcm = { workspace = true, optional = true } getrandom = { workspace = true } +hkdf = { workspace = true, optional = true } +sha2 = { workspace = true, optional = true } tiny-keccak = { workspace = true } base64 = { workspace = true } hex = { workspace = true } @@ -57,8 +59,8 @@ reqwest = { workspace = true, features = ["json"], optional = true } default = ["native-crypto", "bridge"] # Cryptography implementations -native-crypto = ["aes-gcm"] # AES-256-GCM encryption for native platforms -wasm-crypto = ["aes-gcm"] # AES-256-GCM encryption for WebAssembly +native-crypto = ["aes-gcm", "hkdf", "sha2"] # AES-256-GCM + HKDF-SHA256 for native platforms +wasm-crypto = ["aes-gcm", "hkdf", "sha2"] # AES-256-GCM + HKDF-SHA256 for WebAssembly rp-signature = ["k256"] # RP signature generation (ECDSA secp256k1) # Language bindings diff --git a/rust/core/src/bridge.rs b/rust/core/src/bridge.rs index 5d4363a7..eaeeaa1f 100644 --- a/rust/core/src/bridge.rs +++ b/rust/core/src/bridge.rs @@ -12,7 +12,6 @@ use crate::{ ConstraintNode, Signal, }; use serde::{Deserialize, Serialize}; -use uuid::Uuid; use world_id_primitives::{FieldElement, ProofRequest, SessionId}; #[cfg(feature = "native-crypto")] @@ -136,14 +135,32 @@ pub struct EncryptedPayload { pub payload: String, } -/// Response from bridge when creating a request +/// Body sent on `POST /request`. `request_id` is optional: when present, the +/// bridge stores under that key with NX semantics (409 on collision); when +/// absent, the bridge generates a UUID v4. Invite-code mode supplies +/// `Some(HKDF(C, "dx"))`; the URL/QR path leaves it `None` and accepts the +/// bridge's UUID. +#[derive(Debug, Serialize)] +struct CreateRequestBody { + iv: String, + payload: String, + #[serde(skip_serializing_if = "Option::is_none")] + request_id: Option, +} + +/// Response from bridge when creating a request. `request_id` is now an +/// opaque string — UUID v4 in URL/QR mode, hex-encoded HKDF output in code +/// mode. #[derive(Debug, Deserialize)] -#[allow(dead_code)] struct BridgeCreateResponse { - /// Unique request ID - request_id: Uuid, + request_id: String, } +/// TTL applied to an invite-code request by the bridge (`EXPIRE_AFTER_SECONDS` +/// in wallet-bridge). Used to compute `code_expires_at` locally since the +/// bridge no longer returns it on `POST /request`. +const INVITE_CODE_TTL_SECONDS: u64 = 900; + /// Response from bridge when polling for status #[derive(Debug, Deserialize)] struct BridgePollResponse { @@ -343,7 +360,11 @@ pub struct BridgeConnection { #[cfg(feature = "native-crypto")] key: CryptoKey, key_bytes: Vec, - request_id: Uuid, + request_id: String, + /// Application ID, kept on the struct so `connect_url()` can stamp it + /// onto the connector URL as the `a` query param (consumed by the + /// `world.org/verify` landing page in invite-code mode). + app_id: String, client: reqwest::Client, /// Cached signal hashes of the request /// Used to add the `signal_hash` back to the idkit response for convenience @@ -360,6 +381,11 @@ pub struct BridgeConnection { return_to: Option, /// Resolved environment for this connection environment: Environment, + // ─── Invite-code mode (WDP-73) — None for the legacy URL/QR path ──────── + /// Canonical 6-char Crockford Base32 invite code shown to the user. + pub(crate) invite_code: Option, + /// Unix-seconds expiry of the unredeemed code. + pub(crate) code_expires_at: Option, } /// Builds a `BridgeRequestPayload` from params without connecting to the bridge. @@ -551,9 +577,11 @@ impl BridgeConnection { #[cfg(not(feature = "native-crypto"))] let encrypted = encrypt(&key_bytes, &nonce_bytes, &payload_json)?; - let encrypted_payload = EncryptedPayload { + let body = CreateRequestBody { iv: base64_encode(&nonce_bytes), payload: base64_encode(&encrypted), + // URL/QR mode lets the bridge mint the request_id (UUID v4). + request_id: None, }; // Send to bridge @@ -563,7 +591,7 @@ impl BridgeConnection { let response = client .post(bridge_url.join("/request")?) - .json(&encrypted_payload) + .json(&body) .send() .await?; @@ -589,12 +617,15 @@ impl BridgeConnection { _ => None, }; + let app_id = params.app_id.as_str().to_string(); + Ok(Self { bridge_url, #[cfg(feature = "native-crypto")] key, key_bytes: key_bytes.to_vec(), request_id: create_response.request_id, + app_id, client, cached_signal_hashes, action, @@ -603,10 +634,59 @@ impl BridgeConnection { override_connect_base_url: params.override_connect_base_url, return_to: params.return_to, environment: params.environment.unwrap_or_default(), + invite_code: None, + code_expires_at: None, }) } - /// Returns the connect URL for World App + /// Creates a new bridge connection in invite-code mode (WDP-73). + /// + /// Generates a fresh 6-char Crockford Base32 code, derives `index` and + /// `K` from it via HKDF-SHA256, encrypts the request payload with `K`, + /// and posts the new shape to `POST /request`. The encryption key never + /// reaches the bridge — only the index and ciphertext do. + /// + /// Retries once on a 409 conflict (vanishingly rare collision on the + /// `code:idx:` lookup; ~10⁻¹⁰ across two attempts at realistic + /// active-code rates). Beyond that, surfaces the error — sustained + /// collisions indicate a bridge or entropy-budget misconfiguration. + /// + /// # Errors + /// + /// Returns an error if the request cannot be created or the bridge call + /// fails after retries. + #[allow(dead_code)] + pub(crate) async fn create_for_invite_code(params: BridgeConnectionParams) -> Result { + const MAX_ATTEMPTS: u8 = 2; + + // Silent retry: a single collision is statistically expected zero + // times across the lifetime of a healthy deployment. If we ever burn + // both attempts, the BridgeError surfaces with enough detail for the + // caller's log infrastructure to flag it. + for attempt in 1..=MAX_ATTEMPTS { + match try_create_invite_code_request(¶ms).await { + Ok(connection) => return Ok(connection), + Err(CreateCodeError::Conflict) if attempt < MAX_ATTEMPTS => {} + Err(CreateCodeError::Conflict) => { + return Err(Error::BridgeError( + "invite-code index collision after retries — bridge or entropy budget misconfigured" + .to_string(), + )); + } + Err(CreateCodeError::Other(e)) => return Err(e), + } + } + unreachable!("loop returns or errors on the final attempt") + } + + /// Returns the connect URL for World App. + /// + /// In URL/QR mode this is the deeplink that opens World App. In + /// invite-code mode it is the same URL with `&c=&a=` + /// appended; the `world.org/verify` server inspects those params and + /// renders a landing page that displays the code (rather than redirecting + /// straight into World App). Backwards-compatible: clients that ignore + /// `c` / `a` see the original URL/QR-mode shape. #[must_use] pub fn connect_url(&self) -> String { let key_b64 = base64_encode(&self.key_bytes); @@ -622,6 +702,15 @@ impl BridgeConnection { .filter(|value| !value.is_empty()) .map(|value| format!("&return_to={}", urlencoding::encode(value))) .unwrap_or_default(); + // Invite-code mode adds `c` (canonical code) and `a` (app id). The + // canonical code is Crockford Base32, so it's URL-safe by construction + // and doesn't need percent-encoding; the app id is too, but we encode + // it defensively in case the format ever loosens. + let invite_code_params = self + .invite_code + .as_deref() + .map(|code| format!("&c={}&a={}", code, urlencoding::encode(&self.app_id))) + .unwrap_or_default(); let base_url = self .override_connect_base_url @@ -629,17 +718,24 @@ impl BridgeConnection { .unwrap_or("https://world.org/verify"); format!( - "{}?t=wld&i={}&k={}{}{}", + "{}?t=wld&i={}&k={}{}{}{}", base_url, self.request_id, urlencoding::encode(&key_b64), return_to_param, - bridge_param + bridge_param, + invite_code_params ) } /// Polls the bridge for the current status (non-blocking) /// + /// `GET /response/:id` is unauthenticated for both URL/QR and invite-code + /// modes. The endpoint returns AES-GCM ciphertext keyed under a secret + /// (the URL fragment key in QR mode, or HKDF(code, "key") in code mode) + /// that never reaches the bridge — encryption is the security boundary, + /// not endpoint auth. + /// /// # Errors /// /// Returns an error if the request fails or the response is invalid @@ -780,11 +876,173 @@ impl BridgeConnection { )) } - /// Returns the request ID for this request + /// Returns the request ID for this request. + /// + /// In URL/QR mode this is a UUID v4 generated by the bridge; in + /// invite-code mode it is the lowercase-hex `HKDF(C, "dx")` the RP sent + /// on `POST /request`. #[must_use] - pub const fn request_id(&self) -> Uuid { - self.request_id + pub fn request_id(&self) -> &str { + &self.request_id + } + + /// Unix-seconds expiry of the unredeemed code, if this connection was + /// created in invite-code mode. + #[must_use] + pub const fn code_expires_at(&self) -> Option { + self.code_expires_at + } +} + +/// Internal error type for the invite-code create path. Lets the retry loop +/// distinguish 409-on-collision (retryable) from anything else (not). +enum CreateCodeError { + Conflict, + Other(Error), +} + +impl From for CreateCodeError { + fn from(e: Error) -> Self { + Self::Other(e) + } +} + +/// Current Unix-seconds, branching on target. `std::time::SystemTime::now()` +/// panics on `wasm32-unknown-unknown` (no system clock); the WASM build uses +/// `js_sys::Date::now()` against the host's clock instead. +fn current_unix_seconds() -> Result { + #[cfg(target_arch = "wasm32")] + { + let ms = js_sys::Date::now(); + if !ms.is_finite() || ms < 0.0 { + return Err(Error::BridgeError( + "host clock returned a non-finite or negative timestamp".into(), + )); + } + Ok((ms / 1000.0) as u64) + } + #[cfg(not(target_arch = "wasm32"))] + { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(|_| Error::BridgeError("system time before UNIX epoch".into())) + } +} + +#[allow(dead_code)] +async fn try_create_invite_code_request( + params: &BridgeConnectionParams, +) -> std::result::Result { + use crate::crypto::{ + generate_invite_code, generate_nonce, hkdf_invite_index_hex, hkdf_invite_key, + }; + + let code = generate_invite_code()?; + let key_bytes = hkdf_invite_key(&code); + // HKDF(C, "dx") becomes the request_id we hand to the bridge directly — + // no separate `index` field. The bridge has been simplified to a generic + // content-addressable single-use store keyed by any opaque string. + let request_id = hkdf_invite_index_hex(&code); + + // The AES-GCM nonce is fresh-random per request. Deriving it from the + // code would reuse the same (K, IV) pair across any retry for that code, + // breaking AES-GCM's contract — and the code is single-use anyway, so + // there's nothing to gain from determinism. + let nonce_bytes = generate_nonce()?; + + let payload_value = build_request_payload(params, false)?; + let payload_json = serde_json::to_vec(&payload_value).map_err(Error::from)?; + let encrypted = encrypt(&key_bytes, &nonce_bytes, &payload_json)?; + + let body = CreateRequestBody { + iv: base64_encode(&nonce_bytes), + payload: base64_encode(&encrypted), + request_id: Some(request_id.clone()), + }; + + let cached_signal_hashes = CachedSignalHashes::compute(params); + let bridge_url = params.bridge_url.clone().unwrap_or_default(); + let client = reqwest::Client::builder() + .user_agent(format!("idkit-core/{}", env!("CARGO_PKG_VERSION"))) + .build() + .map_err(Error::from)?; + + let response = client + .post(bridge_url.join("/request")?) + .json(&body) + .send() + .await + .map_err(|e| Error::BridgeError(format!("Bridge request failed: {e}")))?; + + if response.status() == reqwest::StatusCode::CONFLICT { + return Err(CreateCodeError::Conflict); + } + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(Error::BridgeError(format!( + "Bridge /request (code) failed with status {status}: {}", + if body.is_empty() { + "no error details" + } else { + &body + } + )) + .into()); + } + + // Validate that the bridge stored the request under the id we sent. + // World App will derive the same id from the user-typed code and read via + // `GET /request/:request_id`; if a misbehaving bridge or proxy silently + // assigned a different id, the request would never be retrievable from + // the World App side and we'd fail in a confusing way much later in the + // poll loop. Catching the mismatch here surfaces the contract violation + // at creation time. + let echoed: BridgeCreateResponse = response + .json() + .await + .map_err(|e| Error::BridgeError(format!("Failed to parse bridge response: {e}")))?; + if echoed.request_id != request_id { + return Err(Error::BridgeError(format!( + "Bridge echoed mismatched request_id (sent {request_id}, got {})", + echoed.request_id + )) + .into()); } + + // Bridge no longer reports the unredeemed-code expiry; it ships + // `EXPIRE_AFTER_SECONDS` (900s) on every row, including code-mode rows, + // so we synthesize the deadline here so adopters can still drive + // countdowns off `code_expires_at()`. + let code_expires_at = current_unix_seconds()? + INVITE_CODE_TTL_SECONDS; + + let action = match ¶ms.kind { + RequestKind::Uniqueness { action } => Some(action.clone()), + _ => None, + }; + + #[cfg(feature = "native-crypto")] + let key = CryptoKey::new(key_bytes, nonce_bytes); + + Ok(BridgeConnection { + bridge_url, + #[cfg(feature = "native-crypto")] + key, + key_bytes: key_bytes.to_vec(), + request_id, + app_id: params.app_id.as_str().to_string(), + client, + cached_signal_hashes, + action, + action_description: params.action_description.clone(), + nonce: params.rp_context.nonce.clone(), + override_connect_base_url: params.override_connect_base_url.clone(), + return_to: params.return_to.clone(), + environment: params.environment.unwrap_or_default(), + invite_code: Some(code), + code_expires_at: Some(code_expires_at), + }) } // ───────────────────────────────────────────────────────────────────────────── @@ -1161,6 +1419,59 @@ impl IDKitBuilder { connect_url_mode: self.config.connect_url_mode(), })) } + + /// Creates an invite-code mode `BridgeConnection` with the given constraints (WDP-73). + /// + /// Same proof-request shape as `constraints()`; the only difference is the + /// transport layer — the user types a 6-char code into World App instead + /// of scanning a QR. Returns the displayable code via `IDKitInviteCodeRequest::code()`. + /// + /// # Errors + /// + /// Returns an error if the request cannot be created. + #[allow(clippy::needless_pass_by_value)] + pub fn constraints_with_invite_code( + &self, + constraints: Arc, + ) -> std::result::Result, crate::error::IdkitError> { + let runtime = + tokio::runtime::Runtime::new().map_err(|e| crate::error::IdkitError::BridgeError { + details: format!("Failed to create runtime: {e}"), + })?; + + let params = self.config.to_params((*constraints).clone())?; + + let inner = runtime + .block_on(BridgeConnection::create_for_invite_code(params)) + .map_err(crate::error::IdkitError::from)?; + + Ok(Arc::new(IDKitInviteCodeRequest { runtime, inner })) + } + + /// Creates an invite-code mode `BridgeConnection` from a preset (WDP-73). + /// + /// # Errors + /// + /// Returns an error if the request cannot be created or the request type + /// doesn't support presets (sessions only support `constraints()`). + #[allow(clippy::needless_pass_by_value)] + pub fn preset_with_invite_code( + &self, + preset: Preset, + ) -> std::result::Result, crate::error::IdkitError> { + let runtime = + tokio::runtime::Runtime::new().map_err(|e| crate::error::IdkitError::BridgeError { + details: format!("Failed to create runtime: {e}"), + })?; + + let params = self.config.to_params_from_preset(preset)?; + + let inner = runtime + .block_on(BridgeConnection::create_for_invite_code(params)) + .map_err(crate::error::IdkitError::from)?; + + Ok(Arc::new(IDKitInviteCodeRequest { runtime, inner })) + } } /// Entry point for creating `IDKit` requests @@ -1308,6 +1619,84 @@ impl IDKitRequestWrapper { } } +// ───────────────────────────────────────────────────────────────────────────── +// Invite-code mode UniFFI wrapper (WDP-73) +// ───────────────────────────────────────────────────────────────────────────── + +/// FFI handle for an invite-code mode bridge session. +/// +/// Sibling to `IDKitRequestWrapper` for the URL/QR path. Method names mirror +/// the URL wrapper exactly so adopters writing a code-mode integration write +/// the same poll loop they wrote in URL mode — only the constructor and the +/// displayable `code()` differ. +#[cfg(feature = "ffi")] +#[derive(uniffi::Object)] +pub struct IDKitInviteCodeRequest { + runtime: tokio::runtime::Runtime, + inner: BridgeConnection, +} + +#[cfg(feature = "ffi")] +#[uniffi::export] +#[allow(clippy::needless_pass_by_value)] +impl IDKitInviteCodeRequest { + /// Returns the connector URL the RP should display to the user. + /// + /// Same URL shape as URL/QR mode (`https://world.org/verify?t=wld&i=…&k=…`), + /// with two extra query params (`c=`, `a=`) the + /// `world.org/verify` landing page uses to render an invite-code-aware view. + #[must_use] + pub fn connect_url(&self) -> String { + self.inner.connect_url() + } + + /// Unix-seconds expiry of the unredeemed code. + /// + /// # Panics + /// + /// Never panics in correct use — see `connect_url()`. + #[must_use] + pub fn expires_at(&self) -> u64 { + self.inner + .code_expires_at() + .expect("invite-code wrapper always has code_expires_at populated") + } + + /// Returns the request ID for this request. + #[must_use] + pub fn request_id(&self) -> String { + self.inner.request_id().to_string() + } + + /// Polls the request once for the current status. + /// + /// `poll_interval_ms` and `timeout_ms` are accepted for signature parity + /// with `IDKitRequestWrapper::poll_status` and ignored. + pub fn poll_status( + &self, + poll_interval_ms: Option, + timeout_ms: Option, + ) -> StatusWrapper { + let _ = (poll_interval_ms, timeout_ms); + self.poll_status_once() + } + + /// Polls the request exactly once for updates. + pub fn poll_status_once(&self) -> StatusWrapper { + match self.runtime.block_on(self.inner.poll_for_status()) { + Ok(status) => status.into(), + Err(err) => { + let app_error = to_app_error(&err); + if is_networking_error(&err) { + StatusWrapper::NetworkingError { error: app_error } + } else { + StatusWrapper::Failed { error: app_error } + } + } + } + } +} + #[cfg(test)] mod tests { use std::str::FromStr; @@ -2041,7 +2430,8 @@ mod tests { #[cfg(feature = "native-crypto")] key: crate::crypto::CryptoKey::new([0; 32], [0; 12]), key_bytes: vec![1, 2, 3, 4], - request_id: Uuid::parse_str("64e0ec6b-b4ca-47cc-8f70-504a95189e26").unwrap(), + request_id: "64e0ec6b-b4ca-47cc-8f70-504a95189e26".to_string(), + app_id: "app_test".to_string(), client: reqwest::Client::new(), cached_signal_hashes: CachedSignalHashes { signal_hashes: std::collections::HashMap::new(), @@ -2053,6 +2443,8 @@ mod tests { override_connect_base_url: None, return_to, environment: Environment::Production, + invite_code: None, + code_expires_at: None, } } diff --git a/rust/core/src/crypto.rs b/rust/core/src/crypto.rs index 1631cdc4..40d31808 100644 --- a/rust/core/src/crypto.rs +++ b/rust/core/src/crypto.rs @@ -14,6 +14,8 @@ use { Aes256Gcm, Nonce, }, getrandom::getrandom, + hkdf::Hkdf, + sha2::Sha256, }; /// Generates a random encryption key and nonce for AES-256-GCM @@ -67,6 +69,25 @@ pub fn encrypt(key: &[u8], nonce: &[u8], plaintext: &[u8]) -> Result> { .map_err(|_| Error::Crypto("Encryption failed".to_string())) } +/// Generates a random AES-GCM nonce (12 bytes). +/// +/// Used by the invite-code path, which derives the AES key from the user-typed +/// code via HKDF and only needs a fresh nonce. The IV must NOT be derived from +/// the code — that would reuse the same (K, IV) pair across requests for the +/// same code, breaking AES-GCM's contract. +/// +/// # Errors +/// +/// Returns an error if the random number generator fails. +#[cfg(any(feature = "native-crypto", feature = "wasm-crypto"))] +pub fn generate_nonce() -> Result<[u8; 12]> { + use crate::Error; + + let mut nonce = [0u8; 12]; + getrandom(&mut nonce).map_err(|e| Error::Crypto(format!("Failed to generate nonce: {e}")))?; + Ok(nonce) +} + /// Decrypts ciphertext using AES-256-GCM /// /// # Errors @@ -97,6 +118,174 @@ pub fn decrypt(key: &[u8], nonce: &[u8], ciphertext: &[u8]) -> Result> { .map_err(|_| Error::Crypto("Decryption failed".to_string())) } +// ============================================================================ +// Invite-code primitives (WDP-73) +// ============================================================================ +// +// The invite-code flow lets the RP show a short user-typeable code instead of +// a QR. Both idkit (RP) and World App must agree byte-for-byte on: +// - The canonical code form (so HKDF inputs match). +// - The HKDF parameters (so `index` and `key` derivations match). +// - The lookup-key encoding (lowercase hex of `HKDF(C, "dx")`). +// +// Drift on any of these means the same code produces different bridge keys on +// each side and the redeem fails. Tests round-trip these helpers; the Swift +// port in world-app-ios must be kept in lockstep. + +#[cfg(any(feature = "native-crypto", feature = "wasm-crypto"))] +mod invite_code { + use super::{Hkdf, Sha256}; + + /// Crockford Base32 alphabet — same set used for data digits and the check + /// digit. Excludes I, L, O, U. + const CROCKFORD: &[u8; 32] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + /// 5 random data chars + 1 check digit = 6 chars total. + const DATA_LEN: usize = 5; + const TOTAL_LEN: usize = 6; + + /// Position weights for the mod-32 check. + /// + /// All weights are odd (coprime to 32) so single-char substitutions always + /// shift the check by a non-zero residue mod 32 — i.e. 100% of single-char + /// substitutions are detected. Adjacent transpositions are caught when the + /// transposed values differ by anything other than ±16 mod 32 (an + /// unavoidable blind spot for any single mod-32 check digit). + const WEIGHTS: [u32; DATA_LEN] = [1, 3, 5, 7, 9]; + + /// Errors produced when parsing a user-typed invite code. + #[derive(Debug, PartialEq, Eq)] + #[allow(dead_code)] // pub(crate) helper; only consumed by tests today. + pub enum InviteCodeError { + WrongLength, + InvalidChar, + BadCheckDigit, + } + + /// Generates a fresh canonical 6-char Crockford Base32 invite code. + /// + /// The check digit is the last character; the first 5 are uniformly random + /// over the 32-char alphabet. UI may insert a separator between the two + /// 3-char halves for display, but the canonical form has no separator. + /// + /// # Errors + /// + /// Returns an error if the random number generator fails. Mirrors the + /// shape of `generate_nonce` so callers can propagate uniformly via `?`. + pub fn generate_invite_code() -> crate::Result { + let mut rng_bytes = [0u8; DATA_LEN]; + getrandom::getrandom(&mut rng_bytes).map_err(|e| { + crate::Error::Crypto(format!("Failed to generate invite code entropy: {e}")) + })?; + + let mut values = [0u32; DATA_LEN]; + let mut code = String::with_capacity(TOTAL_LEN); + for (i, byte) in rng_bytes.iter().enumerate() { + // u8 % 32 is uniform over the alphabet (256 / 32 = 8, no bias). + let v = u32::from(*byte) % 32; + values[i] = v; + code.push(CROCKFORD[v as usize] as char); + } + code.push(CROCKFORD[checksum(&values) as usize] as char); + Ok(code) + } + + /// Parses user input back to canonical form, validating the check digit. + /// + /// Strips ASCII whitespace and `-` / `_` separators, uppercases, and + /// normalizes Crockford ambiguities (`I`/`L` → `1`, `O` → `0`). `U` is + /// rejected as `InvalidChar` rather than mapped to `V` — `U`-as-`V` is a + /// rare confusion and explicit rejection is clearer than silent rewrite. + /// + /// Returns the canonical 6-char string on success. + #[allow(dead_code)] // pub(crate) helper; only consumed by tests today. + pub fn parse_invite_code(input: &str) -> Result { + let mut canonical = String::with_capacity(TOTAL_LEN); + for ch in input.chars() { + if ch.is_ascii_whitespace() || ch == '-' || ch == '_' { + continue; + } + let upper = ch.to_ascii_uppercase(); + let normalized = match upper { + 'I' | 'L' => '1', + 'O' => '0', + _ => upper, + }; + canonical.push(normalized); + } + if canonical.len() != TOTAL_LEN { + return Err(InviteCodeError::WrongLength); + } + let bytes = canonical.as_bytes(); + let mut values = [0u32; DATA_LEN]; + for (i, value) in values.iter_mut().enumerate() { + *value = decode_crockford(bytes[i]).ok_or(InviteCodeError::InvalidChar)?; + } + let expected = checksum(&values); + let actual = decode_crockford(bytes[DATA_LEN]).ok_or(InviteCodeError::InvalidChar)?; + if expected != actual { + return Err(InviteCodeError::BadCheckDigit); + } + Ok(canonical) + } + + fn checksum(values: &[u32; DATA_LEN]) -> u32 { + let mut acc: u32 = 0; + for (v, w) in values.iter().zip(WEIGHTS.iter()) { + acc = acc.wrapping_add(v.wrapping_mul(*w)); + } + acc % 32 + } + + fn decode_crockford(b: u8) -> Option { + match b { + b'0'..=b'9' => Some(u32::from(b - b'0')), + b'A'..=b'H' => Some(u32::from(b - b'A') + 10), + b'J' | b'K' => Some(u32::from(b - b'J') + 18), + b'M' | b'N' => Some(u32::from(b - b'M') + 20), + b'P'..=b'T' => Some(u32::from(b - b'P') + 22), + b'V'..=b'Z' => Some(u32::from(b - b'V') + 27), + _ => None, + } + } + + /// HKDF-SHA256, no salt, 32-byte output. RFC 5869. + /// + /// Both idkit and world-app-ios derive from the canonical code's UTF-8 + /// bytes; both implementations MUST match exactly or the same code yields + /// different bridge keys per side. + fn hkdf_invite(canonical_code: &str, info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, canonical_code.as_bytes()); + let mut out = [0u8; 32]; + hk.expand(info, &mut out) + .expect("HKDF-SHA256 expand never fails for 32-byte output"); + out + } + + /// `index` for the `code:idx:` Redis key and the `POST /code/redeem` + /// body — lowercase hex of `HKDF(C, "dx")`. world-app-ios sends hex on the + /// wire and wallet-bridge stores the literal string as the key suffix; we + /// match that exact format. + pub fn hkdf_invite_index_hex(canonical_code: &str) -> String { + use std::fmt::Write; + let bytes = hkdf_invite(canonical_code, b"dx"); + let mut s = String::with_capacity(64); + for b in bytes { + let _ = write!(&mut s, "{b:02x}"); + } + s + } + + /// AES-256-GCM key for encrypting the request payload (and decrypting the + /// World App response, which is encrypted with the same K). + pub fn hkdf_invite_key(canonical_code: &str) -> [u8; 32] { + hkdf_invite(canonical_code, b"key") + } +} + +#[cfg(any(feature = "native-crypto", feature = "wasm-crypto"))] +pub(crate) use invite_code::{generate_invite_code, hkdf_invite_index_hex, hkdf_invite_key}; + // ============================================================================ // Common implementations (work on both native and WASM) // ============================================================================ @@ -301,6 +490,182 @@ mod tests { assert_eq!(decoded.as_slice(), input); } + #[cfg(any(feature = "native-crypto", feature = "wasm-crypto"))] + mod invite_code_tests { + use super::super::invite_code::{ + generate_invite_code, hkdf_invite_index_hex, hkdf_invite_key, parse_invite_code, + InviteCodeError, + }; + + #[test] + fn generate_produces_canonical_six_char_codes() { + for _ in 0..200 { + let code = generate_invite_code().unwrap(); + assert_eq!(code.len(), 6, "code must be exactly 6 chars"); + assert!( + code.chars() + .all(|c| "0123456789ABCDEFGHJKMNPQRSTVWXYZ".contains(c)), + "code must use only Crockford32 alphabet, got {code}" + ); + } + } + + #[test] + fn generated_codes_round_trip_through_parser() { + for _ in 0..200 { + let code = generate_invite_code().unwrap(); + let parsed = parse_invite_code(&code).expect("freshly generated codes must parse"); + assert_eq!(parsed, code); + } + } + + #[test] + fn parser_strips_separators_and_whitespace() { + // First generate a real code so the check digit is correct. + let code = generate_invite_code().unwrap(); + let formatted = format!("{}-{}", &code[..3], &code[3..]); + assert_eq!(parse_invite_code(&formatted).unwrap(), code); + + let with_space = format!("{} {}", &code[..3], &code[3..]); + assert_eq!(parse_invite_code(&with_space).unwrap(), code); + + let underscored = format!("{}_{}", &code[..3], &code[3..]); + assert_eq!(parse_invite_code(&underscored).unwrap(), code); + } + + #[test] + fn parser_normalizes_lowercase_input() { + let code = generate_invite_code().unwrap(); + let lower = code.to_lowercase(); + assert_eq!(parse_invite_code(&lower).unwrap(), code); + } + + #[test] + fn parser_normalizes_crockford_ambiguous_chars() { + // Find a real code that contains a `1` so we can confirm the + // I/L → 1 normalization round-trips. Generate until we find one. + let code_with_one = (0..1000) + .map(|_| generate_invite_code().unwrap()) + .find(|c| c.contains('1')) + .expect("statistically certain to find a 1 in 1000 attempts"); + let with_i = code_with_one.replace('1', "I"); + assert_eq!(parse_invite_code(&with_i).unwrap(), code_with_one); + let with_l = code_with_one.replace('1', "L"); + assert_eq!(parse_invite_code(&with_l).unwrap(), code_with_one); + + let code_with_zero = (0..1000) + .map(|_| generate_invite_code().unwrap()) + .find(|c| c.contains('0')) + .expect("statistically certain to find a 0 in 1000 attempts"); + let with_o = code_with_zero.replace('0', "O"); + assert_eq!(parse_invite_code(&with_o).unwrap(), code_with_zero); + } + + #[test] + fn parser_rejects_u_outright() { + // Pick a code, replace one data char with U. U is not in the data + // alphabet and we explicitly do NOT normalize U → V. + let code = generate_invite_code().unwrap(); + let mut bytes = code.into_bytes(); + bytes[0] = b'U'; + let mangled = String::from_utf8(bytes).unwrap(); + assert_eq!( + parse_invite_code(&mangled), + Err(InviteCodeError::InvalidChar) + ); + } + + #[test] + fn parser_rejects_wrong_length() { + assert_eq!(parse_invite_code("ABC"), Err(InviteCodeError::WrongLength)); + assert_eq!( + parse_invite_code("ABCDEFG"), + Err(InviteCodeError::WrongLength) + ); + assert_eq!(parse_invite_code(""), Err(InviteCodeError::WrongLength)); + } + + #[test] + fn parser_rejects_bad_check_digit() { + let code = generate_invite_code().unwrap(); + // Flip the check digit to something else in the alphabet. + let mut bytes = code.into_bytes(); + let last = bytes[5]; + bytes[5] = if last == b'0' { b'1' } else { b'0' }; + let mangled = String::from_utf8(bytes).unwrap(); + assert_eq!( + parse_invite_code(&mangled), + Err(InviteCodeError::BadCheckDigit) + ); + } + + #[test] + fn parser_catches_single_char_substitutions() { + // The check digit's primary job. Try every possible single-char + // substitution at every data position; all should reject. + let code = generate_invite_code().unwrap(); + let alphabet = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + for pos in 0..5 { + let original = code.as_bytes()[pos]; + for &candidate in alphabet { + if candidate == original { + continue; + } + let mut bytes = code.as_bytes().to_vec(); + bytes[pos] = candidate; + let mangled = String::from_utf8(bytes).unwrap(); + assert_eq!( + parse_invite_code(&mangled), + Err(InviteCodeError::BadCheckDigit), + "single substitution at pos {pos} ({} -> {}) was not detected", + original as char, + candidate as char + ); + } + } + } + + #[test] + fn hkdf_helpers_are_deterministic() { + let code = "ABCDEF"; + assert_eq!(hkdf_invite_index_hex(code), hkdf_invite_index_hex(code)); + assert_eq!(hkdf_invite_key(code), hkdf_invite_key(code)); + } + + #[test] + fn hkdf_index_is_64_lowercase_hex_chars() { + // Matches world-app-ios's wire format. Bridge accepts the literal + // string as the Redis key suffix, so the encoding must agree. + let code = "ABCDEF"; + let index = hkdf_invite_index_hex(code); + assert_eq!(index.len(), 64); + assert!( + index + .chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)), + "index must be lowercase hex, got {index}" + ); + } + + #[test] + fn hkdf_index_and_key_differ_for_same_code() { + // Different `info` strings → different outputs. + let code = "ABCDEF"; + let index_bytes = hex::decode(hkdf_invite_index_hex(code)).unwrap(); + let key = hkdf_invite_key(code); + assert_ne!(index_bytes.as_slice(), &key[..]); + } + + #[test] + fn hkdf_outputs_differ_across_codes() { + assert_ne!( + hkdf_invite_index_hex("ABCDEF"), + hkdf_invite_index_hex("GHJKMN") + ); + assert_ne!(hkdf_invite_key("ABCDEF"), hkdf_invite_key("GHJKMN")); + } + } + // Known value that was used in previous idkit versions to verify consistency of the hash_to_field implementation #[test] fn test_hash_to_field_empty_string() { diff --git a/rust/core/src/types.rs b/rust/core/src/types.rs index faa9f589..cfbe2da7 100644 --- a/rust/core/src/types.rs +++ b/rust/core/src/types.rs @@ -879,10 +879,12 @@ impl BridgeUrl { crate::Error::InvalidConfiguration(format!("Failed to parse Bridge URL: {e}")) })?; - let is_localhost = matches!(parsed.host_str(), Some("localhost" | "127.0.0.1")); - - // Staging localhost: skip all validation - if is_staging && is_localhost { + // Staging dev hosts (loopback, RFC1918 private IPv4, `*.local` mDNS): + // skip all validation. The relaxation exists so devs can run a local + // wallet-bridge during testing — including the phone-on-LAN ↔ + // desktop-bridge case where the URL the phone needs to reach is the + // dev machine's LAN IP, not `localhost`. + if is_staging && is_dev_host(parsed.host().as_ref()) { return Ok(Self(url)); } @@ -931,6 +933,26 @@ impl BridgeUrl { } } +/// Classifies a host as a developer-machine address so the staging +/// `BridgeUrl` validator can relax HTTPS / port / path checks. Covers: +/// +/// - `localhost` +/// - IPv4 loopback (127.0.0.0/8) and IPv6 loopback (`::1`) +/// - RFC1918 private IPv4: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 +/// - `*.local` mDNS hostnames +/// +/// Public IPs and non-`.local` hostnames still get the production validator. +fn is_dev_host(host: Option<&url::Host<&str>>) -> bool { + match host { + Some(url::Host::Domain(name)) => { + name.eq_ignore_ascii_case("localhost") || name.to_ascii_lowercase().ends_with(".local") + } + Some(url::Host::Ipv4(ip)) => ip.is_loopback() || ip.is_private(), + Some(url::Host::Ipv6(ip)) => ip.is_loopback(), + None => false, + } +} + impl Default for BridgeUrl { fn default() -> Self { Self(Self::DEFAULT.to_string()) diff --git a/rust/core/src/wasm_bindings.rs b/rust/core/src/wasm_bindings.rs index ac52f001..aaa78d5c 100644 --- a/rust/core/src/wasm_bindings.rs +++ b/rust/core/src/wasm_bindings.rs @@ -939,6 +939,44 @@ impl IDKitBuilderWasm { })) }) } + + /// Creates an invite-code mode `BridgeConnection` with the given constraints (WDP-73). + #[wasm_bindgen(js_name = constraintsWithInviteCode)] + pub fn constraints_with_invite_code(self, constraints_json: JsValue) -> js_sys::Promise { + let config = self.config; + future_to_promise(async move { + let constraints: ConstraintNode = serde_wasm_bindgen::from_value(constraints_json) + .map_err(|e| JsValue::from_str(&format!("Invalid constraints: {e}")))?; + + let params = config.to_params(Some(constraints))?; + let connection = crate::bridge::BridgeConnection::create_for_invite_code(params) + .await + .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; + + Ok(JsValue::from(IDKitInviteCodeRequest { + inner: Rc::new(RefCell::new(Some(connection))), + })) + }) + } + + /// Creates an invite-code mode `BridgeConnection` from a preset (WDP-73). + #[wasm_bindgen(js_name = presetWithInviteCode)] + pub fn preset_with_invite_code(self, preset_json: JsValue) -> js_sys::Promise { + let config = self.config; + future_to_promise(async move { + let preset: Preset = serde_wasm_bindgen::from_value(preset_json) + .map_err(|e| JsValue::from_str(&format!("Invalid preset: {e}")))?; + + let params = config.to_params_from_preset(preset)?; + let connection = crate::bridge::BridgeConnection::create_for_invite_code(params) + .await + .map_err(|e| JsValue::from_str(&format!("Failed: {e}")))?; + + Ok(JsValue::from(IDKitInviteCodeRequest { + inner: Rc::new(RefCell::new(Some(connection))), + })) + }) + } } /// Entry point for creating `IDKit` requests (WASM) @@ -1081,47 +1119,149 @@ impl IDKitRequest { let inner = self.inner.clone(); future_to_promise(async move { - // Take request temporarily for async operation - let request = inner - .borrow_mut() - .take() - .ok_or_else(|| JsValue::from_str("Request closed"))?; - - let status = request - .poll_for_status() - .await - .map_err(|e| JsValue::from_str(&format!("Poll failed: {e}")))?; + let status = poll_taking_inner(&inner).await?; + status_to_js_value(&status) + }) + } +} - // Put request back - *inner.borrow_mut() = Some(request); +// ───────────────────────────────────────────────────────────────────────────── +// Invite-code mode WASM wrapper (WDP-73) +// ───────────────────────────────────────────────────────────────────────────── - // Convert Rust Status enum to plain JS object - // Use serialize_maps_as_objects(true) to return plain objects instead of Maps - let serializer = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); +/// Invite-code mode bridge session. +/// +/// Sibling to `IDKitRequest` for the URL/QR path. Method names mirror the URL +/// wrapper exactly so adopters writing a code-mode integration write the same +/// poll loop they wrote in URL mode — only the constructor and the +/// displayable `code()` differ. +#[wasm_bindgen] +pub struct IDKitInviteCodeRequest { + #[wasm_bindgen(skip)] + inner: Rc>>, +} - let js_status = match status { - crate::Status::WaitingForConnection => { - serde_json::json!({"type": "waiting_for_connection"}).serialize(&serializer) - } - crate::Status::AwaitingConfirmation => { - serde_json::json!({"type": "awaiting_confirmation"}).serialize(&serializer) - } - crate::Status::Confirmed(result) => { - serde_json::json!({"type": "confirmed", "result": result}) - .serialize(&serializer) - } - crate::Status::Failed(error) => { - serde_json::json!({"type": "failed", "error": serde_json::to_value(error).unwrap_or_else(|_| serde_json::Value::String(format!("{error:?}")))}) - .serialize(&serializer) +#[wasm_bindgen] +impl IDKitInviteCodeRequest { + /// Returns the connector URL the RP should display to the user. + /// + /// This is the same URL shape the URL/QR mode produces, with two extra + /// query params (`c=`, `a=`) the `world.org/verify` + /// landing page uses to render an invite-code-aware view. + /// + /// # Errors + /// + /// Returns an error if the request has been closed. + #[wasm_bindgen(js_name = connectUrl)] + pub fn connect_url(&self) -> Result { + self.inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed")) + .map(crate::BridgeConnection::connect_url) + } + + /// Unix-seconds expiry of the unredeemed code. + /// + /// # Errors + /// + /// Returns an error if the request has been closed. + #[wasm_bindgen(js_name = expiresAt)] + pub fn expires_at(&self) -> Result { + self.inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed")) + .map(|s| { + #[allow(clippy::cast_precision_loss)] + { + s.code_expires_at() + .expect("invite-code wrapper always has code_expires_at populated") + as f64 } - } - .map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}")))?; + }) + } + + /// Returns the request ID for this request. + /// + /// # Errors + /// + /// Returns an error if the request has been closed. + #[wasm_bindgen(js_name = requestId)] + pub fn request_id(&self) -> Result { + self.inner + .borrow() + .as_ref() + .ok_or_else(|| JsValue::from_str("Request closed")) + .map(|s| s.request_id().to_string()) + } + + /// Polls the bridge for the current status (non-blocking). + /// + /// Mirrors `IDKitRequest::pollForStatus` exactly — same status shape, + /// same close semantics. Adopters use the same poll loop they wrote for + /// URL mode. + /// + /// # Errors + /// + /// Returns an error if the request has been closed or the poll fails. + #[wasm_bindgen(js_name = pollForStatus)] + pub fn poll_for_status(&self) -> js_sys::Promise { + let inner = self.inner.clone(); - Ok(js_status) + future_to_promise(async move { + let status = poll_taking_inner(&inner).await?; + status_to_js_value(&status) }) } } +/// Polls a `BridgeConnection` while taking it out of the `Rc>>` +/// for the await, then **always** puts it back — including on poll error. +/// +/// Without this, a transient network blip would cause the `?` on the poll +/// result to drop out of scope before the request is reinserted, leaving +/// `inner` as `None` and turning every subsequent poll into a hard "Request +/// closed" failure (which has the same shape as a deliberately-closed +/// request, making it indistinguishable from a real teardown). +async fn poll_taking_inner( + inner: &Rc>>, +) -> Result { + let request = inner + .borrow_mut() + .take() + .ok_or_else(|| JsValue::from_str("Request closed"))?; + + let result = request.poll_for_status().await; + *inner.borrow_mut() = Some(request); + + result.map_err(|e| JsValue::from_str(&format!("Poll failed: {e}"))) +} + +/// Converts a Rust `Status` to a plain JS object via +/// `serialize_maps_as_objects(true)` so JS sees `{ type: "..." }` instead of a +/// `Map`. Pulled out so both URL/QR and invite-code wrappers can reuse it. +fn status_to_js_value(status: &crate::Status) -> Result { + let ser = serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true); + + let result = match status { + crate::Status::WaitingForConnection => { + serde_json::json!({"type": "waiting_for_connection"}).serialize(&ser) + } + crate::Status::AwaitingConfirmation => { + serde_json::json!({"type": "awaiting_confirmation"}).serialize(&ser) + } + crate::Status::Confirmed(result) => { + serde_json::json!({"type": "confirmed", "result": result}).serialize(&ser) + } + crate::Status::Failed(error) => { + serde_json::json!({"type": "failed", "error": serde_json::to_value(error).unwrap_or_else(|_| serde_json::Value::String(format!("{error:?}")))}).serialize(&ser) + } + }; + + result.map_err(|e| JsValue::from_str(&format!("Serialization failed: {e}"))) +} + // TypeScript type definitions #[wasm_bindgen(typescript_custom_section)] const TS_TYPES: &str = r#" diff --git a/swift/Sources/IDKit/IDKit.swift b/swift/Sources/IDKit/IDKit.swift index 79d09a19..8a011e46 100644 --- a/swift/Sources/IDKit/IDKit.swift +++ b/swift/Sources/IDKit/IDKit.swift @@ -49,10 +49,26 @@ public final class IDKitBuilder { // return try IDKitRequest(inner: request) // } + // TODO: Re-enable when World ID 4.0 is live + // public func constraintsWithInviteCode(_ constraints: ConstraintNode) throws -> IDKitInviteCodeRequest { + // let request = try inner.constraintsWithInviteCode(constraints: constraints) + // return try IDKitInviteCodeRequest(inner: request) + // } + public func preset(_ preset: Preset) throws -> IDKitRequest { let request = try inner.preset(preset: preset) return try IDKitRequest(inner: request) } + + /// Builds the request in invite-code mode. + /// + /// Returns an `IDKitInviteCodeRequest` exposing the canonical 6-character + /// invite code (no separator), expiry, and the same poll-status surface + /// as `IDKitRequest`. + public func presetWithInviteCode(_ preset: Preset) throws -> IDKitInviteCodeRequest { + let request = try inner.presetWithInviteCode(preset: preset) + return try IDKitInviteCodeRequest(inner: request) + } } /// One-shot polling status returned by `IDKitRequest.pollStatusOnce()`. @@ -182,6 +198,12 @@ public enum IDKitClientError: Error, LocalizedError { /// Canonical request wrapper. public final class IDKitRequest { public let connectorURL: URL + /// Bridge-assigned UUID v4 for the URL/QR flow. + /// + /// Invite-code mode uses opaque hex identifiers that are not UUIDs and is + /// exposed via `IDKitInviteCodeRequest.requestID: String` — the typed + /// `UUID` here is preserved on this wrapper so existing URL/QR adopters + /// keep their source contract. public let requestID: UUID private let pollOnceImpl: @Sendable () async -> IDKitStatus @@ -218,35 +240,7 @@ public final class IDKitRequest { /// Polls repeatedly until a terminal result, timeout, or cancellation. public func pollUntilCompletion(options: IDKitPollOptions = IDKitPollOptions()) async -> IDKitCompletionResult { - let pollIntervalMs = max(options.pollIntervalMs, 1) - let startTime = Date() - - while true { - if Task.isCancelled { - return .failure(.cancelled) - } - - let elapsedMs = Date().timeIntervalSince(startTime) * 1_000 - if elapsedMs >= Double(options.timeoutMs) { - return .failure(.timeout) - } - - let status = await pollStatusOnce() - switch status { - case .confirmed(let result): - return .success(result) - case .failed(let error): - return .failure(error) - case .waitingForConnection, .awaitingConfirmation, .networkingError: - break - } - - do { - try await Task.sleep(nanoseconds: pollIntervalMs * 1_000_000) - } catch { - return .failure(.cancelled) - } - } + await idkitPollUntilCompletion(options: options, pollOnce: pollOnceImpl) } static func mapStatus(_ status: StatusWrapper) -> IDKitStatus { @@ -265,6 +259,91 @@ public final class IDKitRequest { } } +/// Shared poll loop used by both `IDKitRequest` and `IDKitInviteCodeRequest`. +@Sendable +private func idkitPollUntilCompletion( + options: IDKitPollOptions, + pollOnce: @Sendable () async -> IDKitStatus +) async -> IDKitCompletionResult { + let pollIntervalMs = max(options.pollIntervalMs, 1) + let startTime = Date() + + while true { + if Task.isCancelled { + return .failure(.cancelled) + } + + let elapsedMs = Date().timeIntervalSince(startTime) * 1_000 + if elapsedMs >= Double(options.timeoutMs) { + return .failure(.timeout) + } + + let status = await pollOnce() + switch status { + case .confirmed(let result): + return .success(result) + case .failed(let error): + return .failure(error) + case .waitingForConnection, .awaitingConfirmation, .networkingError: + break + } + + do { + try await Task.sleep(nanoseconds: pollIntervalMs * 1_000_000) + } catch { + return .failure(.cancelled) + } + } +} + +/// Canonical invite-code request wrapper. +/// +/// Sibling to `IDKitRequest` for the invite-code flow. The connector URL has +/// the same shape as URL/QR mode plus `&c=&a=`; the +/// `world.org/verify` landing page reads those params and renders an +/// invite-code-aware view. The poll surface mirrors `IDKitRequest`. +public final class IDKitInviteCodeRequest { + public let connectorURL: URL + public let expiresAt: Date + /// Bridge-assigned request identifier. In invite-code mode this is the + /// lowercase-hex `HKDF(C, "dx")` the SDK derived from the code, not a + /// UUID — exposed as `String`. + public let requestID: String + + private let pollOnceImpl: @Sendable () async -> IDKitStatus + + fileprivate init(inner: IdKitInviteCodeRequest) throws { + let rawURL = inner.connectUrl() + guard let connectorURL = URL(string: rawURL) else { + throw IDKitClientError.invalidConnectorURL(rawURL) + } + self.connectorURL = connectorURL + self.expiresAt = Date(timeIntervalSince1970: TimeInterval(inner.expiresAt())) + self.requestID = inner.requestId() + self.pollOnceImpl = { + IDKitRequest.mapStatus(inner.pollStatusOnce()) + } + } + + // Internal initializer for deterministic polling tests. + init(connectorURL: URL, expiresAt: Date, requestID: String, pollOnce: @escaping @Sendable () async -> IDKitStatus) { + self.connectorURL = connectorURL + self.expiresAt = expiresAt + self.requestID = requestID + self.pollOnceImpl = pollOnce + } + + /// Polls the request exactly once. + public func pollStatusOnce() async -> IDKitStatus { + await pollOnceImpl() + } + + /// Polls repeatedly until a terminal result, timeout, or cancellation. + public func pollUntilCompletion(options: IDKitPollOptions = IDKitPollOptions()) async -> IDKitCompletionResult { + await idkitPollUntilCompletion(options: options, pollOnce: pollOnceImpl) + } +} + // TODO: Re-enable when World ID 4.0 is live // public struct CredentialRequestOptions: Equatable { // public var signal: String?