From 6e36f518f7cf52daf55b8a9c574ee635f901b84e Mon Sep 17 00:00:00 2001 From: dzejkop Date: Wed, 9 Sep 2026 16:02:44 +0200 Subject: [PATCH 1/2] Persist the browser demo account and storage keys across reloads --- .../uniffi-web-authenticator-poc/README.md | 22 +++-- .../uniffi-web-authenticator-poc/package.json | 1 + .../src/app/demo-profile.test.ts | 52 ++++++++++++ .../src/app/demo-profile.ts | 57 +++++++++++++ .../src/app/page.tsx | 80 ++++++++++++++----- .../tsconfig.json | 2 +- 6 files changed, 186 insertions(+), 28 deletions(-) create mode 100644 examples/uniffi-web-authenticator-poc/src/app/demo-profile.test.ts create mode 100644 examples/uniffi-web-authenticator-poc/src/app/demo-profile.ts diff --git a/examples/uniffi-web-authenticator-poc/README.md b/examples/uniffi-web-authenticator-poc/README.md index 01e6a33d8..dfa1073d5 100644 --- a/examples/uniffi-web-authenticator-poc/README.md +++ b/examples/uniffi-web-authenticator-poc/README.md @@ -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/package.json b/examples/uniffi-web-authenticator-poc/package.json index 636a0af2d..6d60b52c2 100644 --- a/examples/uniffi-web-authenticator-poc/package.json +++ b/examples/uniffi-web-authenticator-poc/package.json @@ -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/demo-profile.test.ts b/examples/uniffi-web-authenticator-poc/src/app/demo-profile.test.ts new file mode 100644 index 000000000..2cc789e05 --- /dev/null +++ b/examples/uniffi-web-authenticator-poc/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/uniffi-web-authenticator-poc/src/app/demo-profile.ts b/examples/uniffi-web-authenticator-poc/src/app/demo-profile.ts new file mode 100644 index 000000000..c270d0187 --- /dev/null +++ b/examples/uniffi-web-authenticator-poc/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/page.tsx b/examples/uniffi-web-authenticator-poc/src/app/page.tsx index db65469fa..4763447b8 100644 --- a/examples/uniffi-web-authenticator-poc/src/app/page.tsx +++ b/examples/uniffi-web-authenticator-poc/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() {