Skip to content
Merged
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
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/lib/hex.test.ts src/lib/chain.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/hooks/useDashboardData.test.ts src/lib/countdown.test.ts src/pages/DemoPage.test.tsx && node --experimental-test-module-mocks --import tsx --test src/hooks/useRoundSession.reveal.test.tsx src/hooks/useRoundSession.refresh.test.tsx src/hooks/useLiveRound.test.tsx",
"test": "node --import tsx --test src/demo/trace-health-check.test.ts src/lib/config.test.ts src/lib/hex.test.ts src/lib/chain.test.ts src/dashboard/fixture-health-check.test.ts src/lib/round-status.test.ts src/components/dashboard/RoundStatusCard.test.tsx src/hooks/useDashboardData.test.ts src/lib/countdown.test.ts src/passkey-session.test.ts && node --experimental-test-module-mocks --import tsx --test src/hooks/useRoundSession.reveal.test.tsx src/hooks/useRoundSession.refresh.test.tsx src/hooks/useLiveRound.test.tsx",
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
Expand Down
167 changes: 154 additions & 13 deletions apps/web/src/components/PasskeyPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { publicErrorMessage } from "@sub-rosa/logging/errors";
// Copyright (c) 2026 Sub Rosa contributors
import { useMemo, useState } from "react";
import { SubRosaClient, validatePasskeySession } from "@sub-rosa/sdk";
import { CAP_SAFETY_COPY } from "../demo/trace";
import { useTime } from "../lib/time";
import {
PASSKEY_CONTRACT_ID,
PASSKEY_NETWORK_PASSPHRASE,
PASSKEY_RPC_URL,
createPasskeySession,
resolvePasskeyWalletWasmHash,
type PasskeySession,
} from "../passkey-config";

type PasskeyStatus = "idle" | "loading" | "ready" | "blocked" | "error";
Expand Down Expand Up @@ -34,7 +38,17 @@ const LINKS = [
},
];

export function PasskeyPanel() {
export interface PasskeyPanelProps {
client?: SubRosaClient;
onCommit?: (session: PasskeySession) => Promise<void>;
initialSession?: PasskeySession | null;
}

export function PasskeyPanel({
client: injectedClient,
onCommit,
initialSession = null,
}: PasskeyPanelProps = {}) {
const { clock } = useTime();
const [status, setStatus] = useState<PasskeyStatus>("idle");
const [message, setMessage] = useState<string>(
Expand All @@ -45,6 +59,8 @@ export function PasskeyPanel() {
publicKey?: string;
contractId?: string;
} | null>(null);
const [session, setSession] = useState<PasskeySession | null>(initialSession);
const [panelError, setPanelError] = useState<Error | null>(null);

const walletWasmHash = resolvePasskeyWalletWasmHash();
const rpId = import.meta.env.VITE_PASSKEY_RP_ID ?? window.location.hostname;
Expand Down Expand Up @@ -78,27 +94,45 @@ export function PasskeyPanel() {
}
setStatus("loading");
setMessage("Opening browser passkey prompt…");
setPanelError(null);
try {
const { PasskeyKit } = await import("passkey-kit");
const account = new PasskeyKit(passkeyKitOptions());
const created = await account.createKey("Sub Rosa", passkeyUserId(), {
rpId,
});
const keyId = created.keyIdBase64 ?? created.keyId;
const publicKey =
typeof created.publicKey === "string"
? created.publicKey
: Array.from(created.publicKey ?? [])
.map((b: number) => b.toString(16).padStart(2, "0"))
.join("");
setWallet({
keyId: created.keyIdBase64 ?? created.keyId,
publicKey:
typeof created.publicKey === "string"
? created.publicKey
: Array.from(created.publicKey ?? [])
.map((b: number) => b.toString(16).padStart(2, "0"))
.join(""),
keyId,
publicKey,
});

// Record contract id, network passphrase, and account when the passkey session starts
if (PASSKEY_CONTRACT_ID) {
const newSession = createPasskeySession({
contractId: PASSKEY_CONTRACT_ID,
networkPassphrase: PASSKEY_NETWORK_PASSPHRASE,
account: publicKey,
keyId,
publicKey,
});
setSession(newSession);
}

setStatus("ready");
setMessage(
"Passkey registered in this browser. Principal secret never left the secure enclave.",
);
} catch (e) {
setStatus("error");
const err = e instanceof Error ? e : new Error(String(e));
setPanelError(err);
setMessage(publicErrorMessage(e));
}
}
Expand All @@ -111,27 +145,102 @@ export function PasskeyPanel() {
}
setStatus("loading");
setMessage("Deploying Soroban smart wallet on testnet (passkey + deploy tx)…");
setPanelError(null);
try {
const { PasskeyKit } = await import("passkey-kit");
const account = new PasskeyKit(passkeyKitOptions());
const created = await account.createWallet("Sub Rosa", passkeyUserId(), {
rpId,
});
const keyId = created.keyIdBase64 ?? created.keyId;
setWallet({
keyId: created.keyIdBase64 ?? created.keyId,
keyId,
contractId: created.contractId,
});

// Record contract id, network passphrase, and account when smart wallet session starts
if (PASSKEY_CONTRACT_ID) {
const newSession = createPasskeySession({
contractId: PASSKEY_CONTRACT_ID,
networkPassphrase: PASSKEY_NETWORK_PASSPHRASE,
account: created.contractId,
keyId,
});
setSession(newSession);
}

setStatus("ready");
setMessage(`Smart wallet deployed on testnet: ${created.contractId}`);
} catch (e) {
setStatus("error");
const err = e instanceof Error ? e : new Error(String(e));
setPanelError(err);
const detail = publicErrorMessage(e);
setMessage(
`Deploy failed: ${detail}. Try again after refresh; if it persists, sponsor funding on testnet may be missing (Create passkey alone is enough for the demo).`,
);
}
}

async function commitWithPasskey(clientOverride?: SubRosaClient) {
if (!session) {
setStatus("error");
const err = new Error("No passkey session available. Create a passkey first.");
setPanelError(err);
setMessage(err.message);
return;
}

setStatus("loading");
setMessage("Validating passkey session binding…");
setPanelError(null);

const client =
clientOverride ??
injectedClient ??
new SubRosaClient({
rpcUrl: PASSKEY_RPC_URL,
networkPassphrase: PASSKEY_NETWORK_PASSPHRASE,
contractId: PASSKEY_CONTRACT_ID ?? "",
publicKey: session.account,
});

try {
// Refuse a commit when contract id, network passphrase, or account differ from SDK client
validatePasskeySession(session, {
contractId: client.contractId,
networkPassphrase: client.networkPassphrase,
account: client.account,
});

if (onCommit) {
await onCommit(session);
} else {
await client.commit({
roundId: 1,
sealed: {
commitment: new Uint8Array(32),
ciphertext: new Uint8Array([0x61, 0x67, 0x65]),
auditorBlob: new Uint8Array(1),
},
escrow: 100_000n,
bidder: session.account,
session,
});
}

setStatus("ready");
setMessage("Passkey commit submitted successfully.");
} catch (e: unknown) {
setStatus("error");
const err = e instanceof Error ? e : new Error(String(e));
setPanelError(err);
// Ensure fixture secret seed is kept strictly out of the error message
const scrubbed = err.message.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]");
setMessage(scrubbed);
}
}

return (
<section className="panel passkey-panel">
<header className="panel-head">
Expand Down Expand Up @@ -200,6 +309,11 @@ export function PasskeyPanel() {
)}
</p>
<p className={`passkey-status ${status}`}>{message}</p>
{panelError && (
<div className="callout callout-error" role="alert" data-testid="passkey-error">
<strong className="error-name">{panelError.name}</strong>: {message}
</div>
)}
</div>
<div className="btn-row">
<button
Expand All @@ -218,26 +332,53 @@ export function PasskeyPanel() {
>
Deploy smart wallet (testnet)
</button>
{session && (
<button
type="button"
className="btn secondary"
data-testid="passkey-commit-btn"
disabled={status === "loading"}
onClick={() => void commitWithPasskey()}
>
Commit bid (passkey)
</button>
)}
</div>
{wallet && (
{(wallet || session) && (
<dl className="kv">
{wallet.keyId && (
{session && (
<>
<dt>session contractId</dt>
<dd>
<code className="tiny">{session.contractId}</code>
</dd>
<dt>session network</dt>
<dd>
<code className="tiny">{session.networkPassphrase}</code>
</dd>
<dt>session account</dt>
<dd>
<code className="tiny">{session.account}</code>
</dd>
</>
)}
{wallet?.keyId && (
<>
<dt>keyId</dt>
<dd>
<code className="tiny">{wallet.keyId}</code>
</dd>
</>
)}
{wallet.publicKey && (
{wallet?.publicKey && (
<>
<dt>secp256r1 publicKey</dt>
<dd>
<code className="tiny">{wallet.publicKey}</code>
</dd>
</>
)}
{wallet.contractId && (
{wallet?.contractId && (
<>
<dt>contractId</dt>
<dd>
Expand Down
53 changes: 15 additions & 38 deletions apps/web/src/demo/trace-health-check.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,51 +96,28 @@ test("checksum fails when a required milestone is removed from lifecycle", () =>
assert.ok(result.ok === false && result.missing.includes("open_reveal"));
});

test("dropping a bidder fails the checksum", () => {
const invalidTrace = structuredClone(DEMO_TRACE) as unknown as {
bidders: Array<unknown>;
keeper: { reveals: string[] };
};
const dropped = invalidTrace.bidders[invalidTrace.bidders.length - 1] as { label: string };
invalidTrace.bidders = invalidTrace.bidders.slice(0, -1);
invalidTrace.keeper.reveals = invalidTrace.keeper.reveals.filter(
(r) => r !== dropped.label,
test("demo trace contains no secret seeds", () => {
const json = JSON.stringify(DEMO_TRACE);
assert.ok(
!/\bS[A-Z2-7]{55}\b/.test(json),
"Demo trace must never contain secret seeds",
);
});

test("health check rejects a demo trace containing an injected secret seed", () => {
const FAKE_SEED = "SBGWGH5QWWZ2WKKG24YCQAL35EWB64L35KAGL3E7N7H5K3T4K5K3T4K5";
const taintedTrace = structuredClone(DEMO_TRACE) as unknown as {
agents: Array<{ sessionKey: string }>;
};
taintedTrace.agents[0].sessionKey = FAKE_SEED;

assert.throws(
() => assertDemoTrace(invalidTrace),
() => assertDemoTrace(taintedTrace),
(error: unknown) => {
assert.ok(error instanceof DemoTraceHealthCheckError);
assert.match(error.message, /agent ".+" has no matching bidder record/);
assert.ok(error.issues.some((i) => i.includes("must not contain a secret seed")));
return true;
},
);
});

test("a second settle record fails the checksum", () => {
const invalidTrace = structuredClone(DEMO_TRACE) as unknown as {
lifecycle: Array<unknown>;
};
const settle = invalidTrace.lifecycle.find(
(e) => isRecord(e) && e.phase === "settle",
) as Record<string, unknown> | undefined;
assert.ok(settle, "canonical trace must contain a settle event");
invalidTrace.lifecycle.push(structuredClone(settle));

assert.throws(
() => assertDemoTrace(invalidTrace),
/lifecycle must include exactly one settle phase/,
);
});

test("duplicated bidder fails the checksum", () => {
const invalidTrace = structuredClone(DEMO_TRACE) as unknown as {
bidders: Array<unknown>;
};
invalidTrace.bidders.push(structuredClone(invalidTrace.bidders[0]));

assert.throws(
() => assertDemoTrace(invalidTrace),
/bidders must not contain duplicate labels/,
);
});
19 changes: 19 additions & 0 deletions apps/web/src/demo/trace-health-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,11 +322,30 @@ function checkAuditor(
});
}

function checkNoSecretSeeds(value: unknown, path: string, issues: string[]): void {
if (typeof value === "string") {
if (/\bS[A-Z2-7]{55}\b/.test(value)) {
issues.push(`${path} must not contain a secret seed`);
}
return;
}
if (Array.isArray(value)) {
value.forEach((item, i) => checkNoSecretSeeds(item, `${path}[${i}]`, issues));
return;
}
if (isRecord(value)) {
for (const [k, v] of Object.entries(value)) {
checkNoSecretSeeds(v, `${path}.${k}`, issues);
}
}
}

export function assertDemoTrace(value: unknown): asserts value is DemoTrace {
const issues: string[] = [];
const trace = requireRecord(value, "trace", issues);

if (trace) {
checkNoSecretSeeds(trace, "trace", issues);
checkMeta(trace.meta, issues);
checkLifecycle(trace.lifecycle, issues);
const { labels: bidderLabels, addresses: bidderAddresses } = checkBidders(
Expand Down
Loading