diff --git a/README.md b/README.md index be1fff89b..1e2fc943b 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ dependencies { Replace `VERSION` with the desired WalletKit version. -2. Sync Gradle. +1. Sync Gradle. ## Local development (iOS/Swift) @@ -62,7 +62,7 @@ nix develop .#wasm --command bun install --cwd web/walletkit --frozen-lockfile nix develop .#wasm --command bun run --cwd web/walletkit build ``` -The Next.js integration probe under `examples/uniffi-web-authenticator-poc` +The Next.js integration probe under `examples/web` installs the published package and consumes its public `initializeWalletKit()` interface. @@ -87,6 +87,7 @@ nix develop .#android --command cargo xtask kotlin local 0.3.1 ``` Example with custom Rust locations: + ```bash RUSTUP_HOME=~/.rustup CARGO_HOME=~/.cargo cargo xtask kotlin local 0.1.0-SNAPSHOT ``` @@ -94,6 +95,7 @@ RUSTUP_HOME=~/.rustup CARGO_HOME=~/.cargo cargo xtask kotlin local 0.1.0-SNAPSHO > **Note**: The xtask runs from the workspace root, but does not provision or enter the build environment. Run it from the Nix `android` devshell or with the required dependencies configured manually. This will: + 1. Build the Rust library for all Android architectures (arm64-v8a, armeabi-v7a, x86_64, x86) 2. Generate Kotlin UniFFI bindings 3. Publish to `~/.m2/repository/org/world/walletkit/` @@ -292,6 +294,7 @@ sequenceDiagram ``` How it works in code: + - **On-chain registration** uses `InitializingAuthenticator::register_with_defaults` / `register` and then `poll_status` until `Finalized`. - **Authenticator creation** happens with `Authenticator::init_with_defaults` / `init` after the account exists on-chain; then `init_storage(now)` binds local storage to the authenticator leaf. - **Blinding factor generation** is remote (`generate_credential_blinding_factor_remote`) and calls OPRF nodes. diff --git a/examples/uniffi-web-authenticator-poc/.gitignore b/examples/web/.gitignore similarity index 100% rename from examples/uniffi-web-authenticator-poc/.gitignore rename to examples/web/.gitignore diff --git a/examples/uniffi-web-authenticator-poc/AGENTS.md b/examples/web/AGENTS.md similarity index 100% rename from examples/uniffi-web-authenticator-poc/AGENTS.md rename to examples/web/AGENTS.md diff --git a/examples/uniffi-web-authenticator-poc/CLAUDE.md b/examples/web/CLAUDE.md similarity index 100% rename from examples/uniffi-web-authenticator-poc/CLAUDE.md rename to examples/web/CLAUDE.md diff --git a/examples/uniffi-web-authenticator-poc/README.md b/examples/web/README.md similarity index 71% rename from examples/uniffi-web-authenticator-poc/README.md rename to examples/web/README.md index 01e6a33d8..2d521bbd7 100644 --- a/examples/uniffi-web-authenticator-poc/README.md +++ b/examples/web/README.md @@ -9,8 +9,8 @@ generation, WASM optimization, or asset staging. Run it from the repository root: ```sh -bun install --cwd examples/uniffi-web-authenticator-poc --frozen-lockfile -bun run --cwd examples/uniffi-web-authenticator-poc dev +bun install --cwd examples/web --frozen-lockfile +bun run --cwd examples/web dev ``` The example installs `walletkit-web` from the npm registry and does not build the @@ -25,13 +25,12 @@ local package. Run `bun run walletkit:published` to restore the registry package - `walletkit-web` hides generation and WASM loading behind `initializeWalletKit()`. -- The package exposes WalletKit records, errors, objects, callbacks, and async - authenticator methods. +- The package exposes an async facade and owns the worker running WalletKit. - The generated WASM loads in a browser and calls WalletKit synchronously to derive authenticator recovery material from secure browser randomness. - Next.js can bundle the package's generated JavaScript glue and emit its WASM asset from a Client Component. -- The UI drives a real opt-in staging flow: account registration, ephemeral +- The UI drives a real opt-in staging flow: account registration, persistent credential-store initialization, faux credential issuance, and uniqueness proof generation. - A staging RP proof request is signed in the browser with the intentionally @@ -60,6 +59,17 @@ WASM player and browser-only APIs out of Next.js server rendering. The package's WASM is optimized with Binaryen's `wasm-opt -Oz --converge` and resolved from the package with `new URL(..., import.meta.url)`. Proof generation currently embeds the proving artifacts, making the optimized WASM roughly 40 MB. -The example uses a WASM-only ephemeral store whose data and key envelope are -discarded on refresh. No attempt has been made to productionize persistent -storage, worker placement, artifact delivery, or bundle splitting. +The example reuses a saved storage ID and database key to reopen its encrypted +OPFS SQLite databases. Its seed and registration/issuance progress are also saved, +so after reload you can initialize the existing authenticator and use its stored +credentials without registering again. Initialization remains an explicit action +because it contacts staging services. + +The versioned demo profile is stored in localStorage, including the seed and raw +database key. This is staging-only key retention, not passkey protection: scripts +on the origin can read both keys. Production hosts should supply a protected key +source, such as passkey PRF. Corrupt profiles fail rather than silently replacing +keys. Use one tab per origin; clearing site data removes both the profile and OPFS +databases. A browser can also evict site data, so this is not a backup. + +Use the local package workflow above for this unreleased worker API. diff --git a/examples/uniffi-web-authenticator-poc/bun.lock b/examples/web/bun.lock similarity index 99% rename from examples/uniffi-web-authenticator-poc/bun.lock rename to examples/web/bun.lock index d7969f932..47cfbf1a6 100644 --- a/examples/uniffi-web-authenticator-poc/bun.lock +++ b/examples/web/bun.lock @@ -3,7 +3,7 @@ "configVersion": 0, "workspaces": { "": { - "name": "walletkit-uniffi-web-authenticator-poc", + "name": "walletkit-web-example", "dependencies": { "lossless-json": "^4.3.1", "next": "16.3.2", diff --git a/examples/uniffi-web-authenticator-poc/next-env.d.ts b/examples/web/next-env.d.ts similarity index 100% rename from examples/uniffi-web-authenticator-poc/next-env.d.ts rename to examples/web/next-env.d.ts diff --git a/examples/uniffi-web-authenticator-poc/next.config.ts b/examples/web/next.config.ts similarity index 100% rename from examples/uniffi-web-authenticator-poc/next.config.ts rename to examples/web/next.config.ts diff --git a/examples/uniffi-web-authenticator-poc/package.json b/examples/web/package.json similarity index 93% rename from examples/uniffi-web-authenticator-poc/package.json rename to examples/web/package.json index 636a0af2d..fcd5e4173 100644 --- a/examples/uniffi-web-authenticator-poc/package.json +++ b/examples/web/package.json @@ -1,5 +1,5 @@ { - "name": "walletkit-uniffi-web-authenticator-poc", + "name": "walletkit-web-example", "private": true, "version": "0.0.0", "type": "module", @@ -10,6 +10,7 @@ }, "scripts": { "dev": "next dev", + "test": "bun test", "build": "next build", "start": "next start", "walletkit:local": "bun run --cwd ../../web/walletkit build && bun link --cwd ../../web/walletkit && bun link --no-save walletkit-web", diff --git a/examples/uniffi-web-authenticator-poc/src/app/api/faux-credential/route.ts b/examples/web/src/app/api/faux-credential/route.ts similarity index 100% rename from examples/uniffi-web-authenticator-poc/src/app/api/faux-credential/route.ts rename to examples/web/src/app/api/faux-credential/route.ts diff --git a/examples/web/src/app/demo-profile.test.ts b/examples/web/src/app/demo-profile.test.ts new file mode 100644 index 000000000..2cc789e05 --- /dev/null +++ b/examples/web/src/app/demo-profile.test.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { loadDemoProfile, saveDemoProfile } from "./demo-profile"; + +const original = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +let values: Map; +beforeEach(() => { + values = new Map(); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + }, + }); +}); +afterEach(() => { + if (original) Object.defineProperty(globalThis, "localStorage", original); + else Reflect.deleteProperty(globalThis, "localStorage"); +}); + +test("reopening retains independent keys, namespace, and completed progress", () => { + const profile = loadDemoProfile(); + expect(profile.seed).not.toEqual(profile.databaseKey); + profile.registered = true; + profile.credentialIssued = true; + saveDemoProfile(profile); + expect(loadDemoProfile()).toEqual(profile); +}); + +test("a malformed key fails without replacing the saved profile", () => { + const profile = loadDemoProfile(); + profile.databaseKey = [1, 2]; + saveDemoProfile(profile); + const before = [...values.entries()]; + expect(() => loadDemoProfile()).toThrow("invalid"); + expect([...values.entries()]).toEqual(before); +}); + +test("an unsupported profile version is never replaced", () => { + loadDemoProfile(); + const key = [...values.keys()][0]; + values.set(key, '{"version":2}'); + expect(() => loadDemoProfile()).toThrow("invalid"); + expect(values.get(key)).toBe('{"version":2}'); +}); + +test("unavailable browser storage fails instead of starting an ephemeral account", () => { + localStorage.setItem = () => { + throw new Error("Quota exceeded"); + }; + expect(() => loadDemoProfile()).toThrow("Quota exceeded"); +}); diff --git a/examples/web/src/app/demo-profile.ts b/examples/web/src/app/demo-profile.ts new file mode 100644 index 000000000..c270d0187 --- /dev/null +++ b/examples/web/src/app/demo-profile.ts @@ -0,0 +1,57 @@ +/** Demo-only storage: these keys are readable by scripts on this origin. */ +const PROFILE_KEY = "walletkit-staging-demo-v1"; + +export interface DemoProfile { + version: 1; + storageId: string; + seed: number[]; + databaseKey: number[]; + registered: boolean; + credentialIssued: boolean; +} + +function isKey(value: unknown): value is number[] { + return ( + Array.isArray(value) && + value.length === 32 && + value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255) + ); +} + +export function saveDemoProfile(profile: DemoProfile): void { + localStorage.setItem(PROFILE_KEY, JSON.stringify(profile)); +} + +export function loadDemoProfile(): DemoProfile { + const saved = localStorage.getItem(PROFILE_KEY); + if (saved !== null) { + const profile = JSON.parse(saved) as Partial | null; + if ( + !profile || + profile.version !== 1 || + typeof profile.storageId !== "string" || + !/^demo-[a-z0-9-]+$/.test(profile.storageId) || + !isKey(profile.seed) || + !isKey(profile.databaseKey) || + typeof profile.registered !== "boolean" || + typeof profile.credentialIssued !== "boolean" || + (profile.credentialIssued && !profile.registered) + ) { + throw new Error( + "The saved demo profile is invalid. Restore it or clear this site's data to start over.", + ); + } + return profile as DemoProfile; + } + const profile: DemoProfile = { + version: 1, + storageId: `demo-${crypto.randomUUID()}`, + seed: Array.from(crypto.getRandomValues(new Uint8Array(32))), + databaseKey: Array.from(crypto.getRandomValues(new Uint8Array(32))), + registered: false, + credentialIssued: false, + }; + // Persist before opening OPFS, so a failed write cannot orphan a new vault. + saveDemoProfile(profile); + return profile; +} diff --git a/examples/uniffi-web-authenticator-poc/src/app/globals.css b/examples/web/src/app/globals.css similarity index 100% rename from examples/uniffi-web-authenticator-poc/src/app/globals.css rename to examples/web/src/app/globals.css diff --git a/examples/uniffi-web-authenticator-poc/src/app/layout.tsx b/examples/web/src/app/layout.tsx similarity index 100% rename from examples/uniffi-web-authenticator-poc/src/app/layout.tsx rename to examples/web/src/app/layout.tsx diff --git a/examples/uniffi-web-authenticator-poc/src/app/page.tsx b/examples/web/src/app/page.tsx similarity index 69% rename from examples/uniffi-web-authenticator-poc/src/app/page.tsx rename to examples/web/src/app/page.tsx index db65469fa..4763447b8 100644 --- a/examples/uniffi-web-authenticator-poc/src/app/page.tsx +++ b/examples/web/src/app/page.tsx @@ -3,6 +3,11 @@ import { useEffect, useRef, useState } from "react"; import type { RecoveryData, WalletKit } from "walletkit-web"; +import { + loadDemoProfile, + saveDemoProfile, + type DemoProfile, +} from "./demo-profile"; import { createStagingProofRequest, issueFauxCredential } from "./staging"; type Action = "derive" | "register" | "initialize" | "issue" | "prove"; @@ -10,6 +15,7 @@ type Action = "derive" | "register" | "initialize" | "issue" | "prove"; export default function Home() { const wallet = useRef(null); const seed = useRef(new Uint8Array(32)); + const profile = useRef(null); const acting = useRef(false); const [runtime, setRuntime] = useState("Loading…"); const [recovery, setRecovery] = useState(); @@ -21,14 +27,21 @@ export default function Home() { useEffect(() => { const controller = new AbortController(); - const databaseKey = crypto.getRandomValues(new Uint8Array(32)); - seed.current = crypto.getRandomValues(new Uint8Array(32)); + let databaseKey: Uint8Array | undefined; + let client: WalletKit | undefined; + const currentSeed = new Uint8Array(32); void (async () => { try { + const saved = loadDemoProfile(); + profile.current = saved; + databaseKey = new Uint8Array(saved.databaseKey); + currentSeed.set(saved.seed); + seed.current = currentSeed; const { initializeWalletKit } = await import("walletkit-web"); - const client = await initializeWalletKit({ + if (controller.signal.aborted) return; + client = await initializeWalletKit({ databaseKey, - storageId: `demo-${crypto.randomUUID()}`, + storageId: saved.storageId, environment: "staging", region: "us", signal: controller.signal, @@ -37,39 +50,58 @@ export default function Home() { client.terminate(); return; } + const recovery = await client.recoveryDataFromSeed(currentSeed); + if (controller.signal.aborted) return; wallet.current = client; - setRecovery(await client.recoveryDataFromSeed(seed.current)); + setRecovery(recovery); + setRegistered(saved.registered); + setCredentialIssued(saved.credentialIssued); setRuntime("Ready"); - setStatus("Ready. This demo keeps its database key only in memory."); + setStatus( + saved.registered + ? "Saved account loaded. Initialize the authenticator to reopen its stored credentials." + : "Ready. This demo saves its account and database key in this browser.", + ); } catch (error) { + client?.terminate(); if (!controller.signal.aborted) { setRuntime("Failed"); setStatus(String(error)); } } finally { - databaseKey.fill(0); + databaseKey?.fill(0); } })(); return () => { controller.abort(); - wallet.current?.terminate(); - wallet.current = null; - seed.current.fill(0); + client?.terminate(); + if (wallet.current === client) wallet.current = null; + currentSeed.fill(0); }; }, []); async function perform(action: Action) { const client = wallet.current; - if (!client || acting.current) return; + const saved = profile.current; + if (!client || !saved || acting.current) return; acting.current = true; setBusy(true); try { switch (action) { - case "derive": - seed.current.fill(0); - seed.current = crypto.getRandomValues(new Uint8Array(32)); - setRecovery(await client.recoveryDataFromSeed(seed.current)); + case "derive": { + const nextSeed = crypto.getRandomValues(new Uint8Array(32)); + try { + const nextRecovery = await client.recoveryDataFromSeed(nextSeed); + const nextProfile = { ...saved, seed: Array.from(nextSeed) }; + saveDemoProfile(nextProfile); + profile.current = nextProfile; + seed.current.set(nextSeed); + setRecovery(nextRecovery); + } finally { + nextSeed.fill(0); + } break; + } case "register": { await client.register(seed.current); for (;;) { @@ -79,6 +111,8 @@ export default function Home() { if (status.state === "finalized") break; await new Promise((resolve) => setTimeout(resolve, 500)); } + saved.registered = true; + saveDemoProfile(saved); setRegistered(true); break; } @@ -99,6 +133,8 @@ export default function Home() { 2, ), ); + saved.credentialIssued = true; + saveDemoProfile(saved); setCredentialIssued(true); break; } @@ -125,7 +161,7 @@ export default function Home() {

WalletKit web package integration probe

WalletKit credential proof in browser WASM

- Derive and register a temporary staging authenticator, issue a faux + Derive and register a staging authenticator, issue a faux credential, then generate a proof for it entirely in the browser.

@@ -157,10 +193,12 @@ export default function Home() {

Staging credential proof

- This creates a real temporary account and credential in staging. The - seed and database key are discarded when this tab reloads. Encrypted - data remains in browser storage; this demo cannot reopen it after - reload. + This creates a real account and credential in staging. The demo + saves its seed and database key in this browser and reopens the same + encrypted database after reload. These demo keys are readable by + scripts on this site; production apps should use a protected key + source such as a passkey. Use one tab at a time. Clearing site data + removes the saved account.

  1. @@ -189,7 +227,7 @@ export default function Home() {