Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies {

Replace `VERSION` with the desired WalletKit version.

2. Sync Gradle.
1. Sync Gradle.

## Local development (iOS/Swift)

Expand Down Expand Up @@ -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.

Expand All @@ -87,13 +87,15 @@ 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
```

> **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/`
Expand Down Expand Up @@ -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.
Expand Down
File renamed without changes.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "walletkit-uniffi-web-authenticator-poc",
"name": "walletkit-web-example",
"private": true,
"version": "0.0.0",
"type": "module",
Expand All @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions examples/web/src/app/demo-profile.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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");
});
57 changes: 57 additions & 0 deletions examples/web/src/app/demo-profile.ts
Original file line number Diff line number Diff line change
@@ -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<DemoProfile> | 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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,19 @@
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";

export default function Home() {
const wallet = useRef<WalletKit | null>(null);
const seed = useRef(new Uint8Array(32));
const profile = useRef<DemoProfile | null>(null);
const acting = useRef(false);
const [runtime, setRuntime] = useState("Loading…");
const [recovery, setRecovery] = useState<RecoveryData>();
Expand All @@ -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,
Expand All @@ -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 (;;) {
Expand All @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Failed save leaves profile flags inconsistent

Low Severity

register and issue set saved.registered / saved.credentialIssued on the live profile.current object before saveDemoProfile returns. A failed write leaves memory marked complete while React state and localStorage stay incomplete, so Derive stays enabled and can persist a new seed as already registered.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 6e36f51. Configure here.

setRegistered(true);
break;
}
Expand All @@ -99,6 +133,8 @@ export default function Home() {
2,
),
);
saved.credentialIssued = true;
saveDemoProfile(saved);
setCredentialIssued(true);
break;
}
Expand All @@ -125,7 +161,7 @@ export default function Home() {
<p className="eyebrow">WalletKit web package integration probe</p>
<h1>WalletKit credential proof in browser WASM</h1>
<p>
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.
</p>
<dl>
Expand Down Expand Up @@ -157,10 +193,12 @@ export default function Home() {
<section className="card actions-card">
<h2>Staging credential proof</h2>
<p>
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.
</p>
<ol>
<li>
Expand Down Expand Up @@ -189,7 +227,7 @@ export default function Home() {
</li>
<li>
<button
disabled={!credentialIssued || busy}
disabled={!authenticatorReady || !credentialIssued || busy}
onClick={() => perform("prove")}
>
Generate proof
Expand Down
Loading
Loading