diff --git a/apps/web/package.json b/apps/web/package.json index 7c6b9079..d7267f49 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/src/components/PasskeyPanel.tsx b/apps/web/src/components/PasskeyPanel.tsx index e201916d..215d0caf 100644 --- a/apps/web/src/components/PasskeyPanel.tsx +++ b/apps/web/src/components/PasskeyPanel.tsx @@ -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"; @@ -34,7 +38,17 @@ const LINKS = [ }, ]; -export function PasskeyPanel() { +export interface PasskeyPanelProps { + client?: SubRosaClient; + onCommit?: (session: PasskeySession) => Promise; + initialSession?: PasskeySession | null; +} + +export function PasskeyPanel({ + client: injectedClient, + onCommit, + initialSession = null, +}: PasskeyPanelProps = {}) { const { clock } = useTime(); const [status, setStatus] = useState("idle"); const [message, setMessage] = useState( @@ -45,6 +59,8 @@ export function PasskeyPanel() { publicKey?: string; contractId?: string; } | null>(null); + const [session, setSession] = useState(initialSession); + const [panelError, setPanelError] = useState(null); const walletWasmHash = resolvePasskeyWalletWasmHash(); const rpId = import.meta.env.VITE_PASSKEY_RP_ID ?? window.location.hostname; @@ -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)); } } @@ -111,20 +145,36 @@ 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).`, @@ -132,6 +182,65 @@ export function PasskeyPanel() { } } + 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 (
@@ -200,6 +309,11 @@ export function PasskeyPanel() { )}

{message}

+ {panelError && ( +
+ {panelError.name}: {message} +
+ )}
+ {session && ( + + )}
- {wallet && ( + {(wallet || session) && (
- {wallet.keyId && ( + {session && ( + <> +
session contractId
+
+ {session.contractId} +
+
session network
+
+ {session.networkPassphrase} +
+
session account
+
+ {session.account} +
+ + )} + {wallet?.keyId && ( <>
keyId
@@ -229,7 +370,7 @@ export function PasskeyPanel() {
)} - {wallet.publicKey && ( + {wallet?.publicKey && ( <>
secp256r1 publicKey
@@ -237,7 +378,7 @@ export function PasskeyPanel() {
)} - {wallet.contractId && ( + {wallet?.contractId && ( <>
contractId
diff --git a/apps/web/src/demo/trace-health-check.test.ts b/apps/web/src/demo/trace-health-check.test.ts index 4596ab06..403de6bc 100644 --- a/apps/web/src/demo/trace-health-check.test.ts +++ b/apps/web/src/demo/trace-health-check.test.ts @@ -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; - 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; - }; - const settle = invalidTrace.lifecycle.find( - (e) => isRecord(e) && e.phase === "settle", - ) as Record | 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; - }; - invalidTrace.bidders.push(structuredClone(invalidTrace.bidders[0])); - - assert.throws( - () => assertDemoTrace(invalidTrace), - /bidders must not contain duplicate labels/, - ); -}); diff --git a/apps/web/src/demo/trace-health-check.ts b/apps/web/src/demo/trace-health-check.ts index 7b9d7217..618cd199 100644 --- a/apps/web/src/demo/trace-health-check.ts +++ b/apps/web/src/demo/trace-health-check.ts @@ -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( diff --git a/apps/web/src/lib/config.test.ts b/apps/web/src/lib/config.test.ts index fb3cf4be..5fc996c3 100644 --- a/apps/web/src/lib/config.test.ts +++ b/apps/web/src/lib/config.test.ts @@ -207,3 +207,50 @@ test("invalid VITE_RPC_URL error message is actionable", () => { const msg = issues.find((i) => i.key === "VITE_RPC_URL")!.message; assert.ok(msg.includes("must start with http:// or https://")); }); + +test("matching VITE_PASSKEY_CONTRACT_ID and VITE_CONTRACT_ID passes without issue", () => { + const issues = validatePublicConfig({ + VITE_RPC_URL: "https://custom-soroban.example.com", + VITE_NETWORK_PASSPHRASE: "Custom Network", + VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + VITE_PASSKEY_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + VITE_PASSKEY_NETWORK_PASSPHRASE: "Custom Network", + }); + const passkeyIssues = issues.filter( + (i) => i.key === "VITE_PASSKEY_CONTRACT_ID" || i.key === "VITE_PASSKEY_NETWORK_PASSPHRASE", + ); + assert.equal(passkeyIssues.length, 0); +}); + +test("swapped VITE_PASSKEY_CONTRACT_ID is reported as configuration mismatch", () => { + const issues = validatePublicConfig({ + VITE_RPC_URL: "https://custom-soroban.example.com", + VITE_NETWORK_PASSPHRASE: "Custom Network", + VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + VITE_PASSKEY_CONTRACT_ID: "CC2QMOXZERI6UOR67YKSORT7QTUHQ5QUGMHQBYVP23YM3NMUNNOEOGZY", + }); + assert.ok( + issues.some( + (i) => + i.key === "VITE_PASSKEY_CONTRACT_ID" && + i.message.includes("does not match VITE_CONTRACT_ID"), + ), + ); +}); + +test("swapped VITE_PASSKEY_NETWORK_PASSPHRASE is reported as configuration mismatch", () => { + const issues = validatePublicConfig({ + VITE_RPC_URL: "https://custom-soroban.example.com", + VITE_NETWORK_PASSPHRASE: "Custom Network", + VITE_CONTRACT_ID: "CA7KSDEYJEPGZEB2ZROTLUWKQQ6GIRIQNGG6Z745MZ34QHP4UJPWODEX", + VITE_PASSKEY_NETWORK_PASSPHRASE: "Public Global Stellar Network ; September 2015", + }); + assert.ok( + issues.some( + (i) => + i.key === "VITE_PASSKEY_NETWORK_PASSPHRASE" && + i.message.includes("does not match VITE_NETWORK_PASSPHRASE"), + ), + ); +}); + diff --git a/apps/web/src/lib/config.ts b/apps/web/src/lib/config.ts index a301f352..2c4d8cf6 100644 --- a/apps/web/src/lib/config.ts +++ b/apps/web/src/lib/config.ts @@ -101,6 +101,36 @@ export function validatePublicConfig( } } + const passkeyContractId = env.VITE_PASSKEY_CONTRACT_ID; + const contractId = env.VITE_CONTRACT_ID; + if ( + passkeyContractId && + contractId && + passkeyContractId.trim() !== "" && + contractId.trim() !== "" && + passkeyContractId.trim() !== contractId.trim() + ) { + issues.push({ + key: "VITE_PASSKEY_CONTRACT_ID", + message: `VITE_PASSKEY_CONTRACT_ID (${passkeyContractId}) does not match VITE_CONTRACT_ID (${contractId}). Passkey session will be bound to a different contract than web config.`, + }); + } + + const passkeyPassphrase = env.VITE_PASSKEY_NETWORK_PASSPHRASE; + const networkPassphrase = env.VITE_NETWORK_PASSPHRASE; + if ( + passkeyPassphrase && + networkPassphrase && + passkeyPassphrase.trim() !== "" && + networkPassphrase.trim() !== "" && + passkeyPassphrase.trim() !== networkPassphrase.trim() + ) { + issues.push({ + key: "VITE_PASSKEY_NETWORK_PASSPHRASE", + message: `VITE_PASSKEY_NETWORK_PASSPHRASE does not match VITE_NETWORK_PASSPHRASE. Passkey session cannot commit across different networks.`, + }); + } + return issues; } diff --git a/apps/web/src/passkey-config.ts b/apps/web/src/passkey-config.ts index 9bff2262..870b6771 100644 --- a/apps/web/src/passkey-config.ts +++ b/apps/web/src/passkey-config.ts @@ -1,17 +1,96 @@ // Copyright (c) 2026 Sub Rosa contributors +import type { CommitParams, SubRosaClient } from "@sub-rosa/sdk"; +import { validatePasskeySession } from "@sub-rosa/sdk"; + /** Public testnet smart-wallet WASM (passkey-kit demo). Not a secret. */ export const PASSKEY_TESTNET_WALLET_WASM_HASH = "ecd990f0b45ca6817149b6175f79b32efb442f35731985a084131e8265c4cd90"; +const env: Record = + typeof import.meta !== "undefined" && import.meta.env + ? import.meta.env + : typeof process !== "undefined" && process.env + ? process.env + : {}; + export const PASSKEY_RPC_URL = - import.meta.env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; + env.VITE_RPC_URL ?? "https://soroban-testnet.stellar.org"; export const PASSKEY_NETWORK_PASSPHRASE = - import.meta.env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + env.VITE_NETWORK_PASSPHRASE ?? "Test SDF Network ; September 2015"; + +export const PASSKEY_CONTRACT_ID = + env.VITE_PASSKEY_CONTRACT_ID ?? env.VITE_CONTRACT_ID; export function resolvePasskeyWalletWasmHash(): string | undefined { - const fromEnv = import.meta.env.VITE_PASSKEY_WALLET_WASM_HASH?.trim(); + const fromEnv = env.VITE_PASSKEY_WALLET_WASM_HASH?.trim(); if (fromEnv) return fromEnv; // Default for local jury demo — same hash as passkey-kit-demo on testnet. return PASSKEY_TESTNET_WALLET_WASM_HASH; } + +export interface PasskeySession { + /** Contract ID the passkey session is bound to. */ + contractId: string; + /** Network passphrase the passkey session is bound to. */ + networkPassphrase: string; + /** Account/signer public address bound to this session. */ + account: string; + keyId?: string; + publicKey?: string; + createdAt?: string; +} + +/** + * Record contract id, network passphrase, and account when a passkey session starts. + */ +export function createPasskeySession(params: { + contractId?: string; + networkPassphrase?: string; + account: string; + keyId?: string; + publicKey?: string; +}): PasskeySession { + const contractId = (params.contractId ?? PASSKEY_CONTRACT_ID)?.trim(); + if (!contractId) { + throw new Error("contractId is required to start a passkey session"); + } + const networkPassphrase = ( + params.networkPassphrase ?? PASSKEY_NETWORK_PASSPHRASE + )?.trim(); + if (!networkPassphrase) { + throw new Error("networkPassphrase is required to start a passkey session"); + } + if (!params.account || params.account.trim() === "") { + throw new Error("account is required to start a passkey session"); + } + return { + contractId, + networkPassphrase, + account: params.account.trim(), + keyId: params.keyId, + publicKey: params.publicKey, + createdAt: new Date().toISOString(), + }; +} + +/** + * Execute a commit bound to a passkey session. + * Refuses commit with a typed error if contract id, network passphrase, or account differ from the current SDK client. + */ +export async function commitWithPasskeySession( + session: PasskeySession, + client: SubRosaClient, + commitParams: Omit, +): Promise { + validatePasskeySession(session, { + contractId: client.contractId, + networkPassphrase: client.networkPassphrase, + account: commitParams.bidder ?? client.account, + }); + return client.commit({ + ...commitParams, + session, + }); +} + diff --git a/apps/web/src/passkey-session.test.ts b/apps/web/src/passkey-session.test.ts new file mode 100644 index 00000000..9ac6695c --- /dev/null +++ b/apps/web/src/passkey-session.test.ts @@ -0,0 +1,160 @@ +// Copyright (c) 2026 Sub Rosa contributors +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { Keypair, StrKey } from "@stellar/stellar-sdk"; +import { + SubRosaClient, + SubRosaNetworkMismatchError, + SubRosaSessionMismatchError, +} from "@sub-rosa/sdk"; +import { + createPasskeySession, + commitWithPasskeySession, + type PasskeySession, +} from "./passkey-config"; + +describe("Passkey session binding fixture test", () => { + const fixtureKeypair = Keypair.random(); + const FIXTURE_SEED = fixtureKeypair.secret(); + const FIXTURE_PUBLIC = fixtureKeypair.publicKey(); + + const FIXTURE_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 7)); + const SWAPPED_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 9)); + const TESTNET_PASSPHRASE = "Test SDF Network ; September 2015"; + const PUBLIC_PASSPHRASE = "Public Global Stellar Network ; September 2015"; + + const FAKE_COMMIT_PARAMS = { + roundId: 1, + sealed: { + commitment: new Uint8Array(32), + ciphertext: new Uint8Array([0x61, 0x67, 0x65]), + auditorBlob: new Uint8Array(1), + }, + escrow: 100_000n, + bidder: FIXTURE_PUBLIC, + }; + + function createFixtureClient(options: { + contractId?: string; + networkPassphrase?: string; + } = {}) { + const contractId = options.contractId ?? FIXTURE_CONTRACT_ID; + const networkPassphrase = options.networkPassphrase ?? TESTNET_PASSPHRASE; + let submitted = false; + + const client = new SubRosaClient({ + rpcUrl: "https://soroban-testnet.stellar.org", + networkPassphrase, + contractId, + secretKey: FIXTURE_SEED, + _server: { + getNetwork: async () => ({ + passphrase: networkPassphrase, + protocolVersion: "23", + }), + getLedgerEntries: async () => ({ + entries: [{}], + latestLedger: 123, + }), + } as never, + }); + + // Mock contract commit to detect submission + Object.defineProperty(client.contract, "commit", { + configurable: true, + value: async () => { + submitted = true; + return { + signAndSend: async () => ({ + result: { unwrap: () => ({}) }, + }), + }; + }, + }); + + return { client, hasSubmitted: () => submitted }; + } + + it("A matching session can commit in the fixture test", async () => { + const { client, hasSubmitted } = createFixtureClient(); + + const matchingSession = createPasskeySession({ + contractId: FIXTURE_CONTRACT_ID, + networkPassphrase: TESTNET_PASSPHRASE, + account: FIXTURE_PUBLIC, + }); + + await commitWithPasskeySession(matchingSession, client, FAKE_COMMIT_PARAMS); + assert.equal(hasSubmitted(), true, "matching session should have submitted commit"); + }); + + it("A swapped contract id does not submit", async () => { + const { client, hasSubmitted } = createFixtureClient(); + + const swappedSession: PasskeySession = { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: TESTNET_PASSPHRASE, + account: FIXTURE_PUBLIC, + }; + + await assert.rejects( + commitWithPasskeySession(swappedSession, client, FAKE_COMMIT_PARAMS), + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "contract_mismatch"); + return true; + }, + ); + + assert.equal(hasSubmitted(), false, "swapped contract id must not submit to contract"); + }); + + it("A swapped passphrase does not submit", async () => { + const { client, hasSubmitted } = createFixtureClient(); + + const swappedSession: PasskeySession = { + contractId: FIXTURE_CONTRACT_ID, + networkPassphrase: PUBLIC_PASSPHRASE, + account: FIXTURE_PUBLIC, + }; + + await assert.rejects( + commitWithPasskeySession(swappedSession, client, FAKE_COMMIT_PARAMS), + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "session_mismatch"); + return true; + }, + ); + + assert.equal(hasSubmitted(), false, "swapped network passphrase must not submit to contract"); + }); + + it("The error text does not contain the fixture seed", async () => { + const { client } = createFixtureClient(); + + const swappedSession: PasskeySession = { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: PUBLIC_PASSPHRASE, + account: FIXTURE_PUBLIC, + }; + + try { + await commitWithPasskeySession(swappedSession, client, FAKE_COMMIT_PARAMS); + assert.fail("commitWithPasskeySession should have failed"); + } catch (e: unknown) { + assert.ok(e instanceof Error); + const errorText = e.message; + assert.ok( + !errorText.includes(FIXTURE_SEED), + "Error message must not include fixture secret seed", + ); + assert.ok( + !/\bS[A-Z2-7]{55}\b/.test(errorText), + "Error message must not match secret seed pattern", + ); + } + }); +}); diff --git a/packages/sdk/src/client.test.ts b/packages/sdk/src/client.test.ts index 983f1297..af0dd010 100644 --- a/packages/sdk/src/client.test.ts +++ b/packages/sdk/src/client.test.ts @@ -1,13 +1,13 @@ // Copyright (c) 2026 Sub Rosa contributors import assert from "node:assert/strict"; import { describe, it } from "node:test"; -import { readFileSync } from "node:fs"; -import { rpc, StrKey } from "@stellar/stellar-sdk"; +import { Keypair, rpc, StrKey } from "@stellar/stellar-sdk"; import { SubRosaClient } from "./client.js"; import { SubRosaClientConfigError, - SubRosaPaginationError, + SubRosaNetworkMismatchError, + SubRosaSessionMismatchError, SubRosaSubmitError, } from "./errors.js"; import type { @@ -303,3 +303,123 @@ describe("SubRosaClient external submitter failures", () => { }); }); }); + +describe("SubRosaClient passkey session binding", () => { + const SWAPPED_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 2)); + const PUBLIC_PASSPHRASE = "Public Global Stellar Network ; September 2015"; + const FIXTURE_SEED = Keypair.random().secret(); + + const VALID_COMMIT_PARAMS = { + roundId: 1, + sealed: { + commitment: new Uint8Array(32), + ciphertext: new Uint8Array([0x61, 0x67, 0x65]), + auditorBlob: new Uint8Array(1), + }, + escrow: 100_000n, + bidder: PUBLIC_KEY, + }; + + it("exposes account and session properties", () => { + const session = { + contractId: BASE_CONFIG.contractId, + networkPassphrase: BASE_CONFIG.networkPassphrase, + account: PUBLIC_KEY, + }; + const client = new SubRosaClient({ + ...BASE_CONFIG, + publicKey: PUBLIC_KEY, + session, + }); + assert.equal(client.account, PUBLIC_KEY); + assert.deepEqual(client.session, session); + }); + + it("refuses commit when session contract id does not match client", async () => { + const client = new SubRosaClient({ + ...BASE_CONFIG, + publicKey: PUBLIC_KEY, + }); + + await assert.rejects( + client.commit({ + ...VALID_COMMIT_PARAMS, + session: { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: BASE_CONFIG.networkPassphrase, + account: PUBLIC_KEY, + }, + }), + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "contract_mismatch"); + return true; + }, + ); + }); + + it("refuses commit when session network passphrase does not match client", async () => { + const client = new SubRosaClient({ + ...BASE_CONFIG, + publicKey: PUBLIC_KEY, + }); + + await assert.rejects( + client.commit({ + ...VALID_COMMIT_PARAMS, + session: { + contractId: BASE_CONFIG.contractId, + networkPassphrase: PUBLIC_PASSPHRASE, + account: PUBLIC_KEY, + }, + }), + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "session_mismatch"); + return true; + }, + ); + }); + + it("refuses preflightCommit when session does not match", async () => { + const client = new SubRosaClient({ + ...BASE_CONFIG, + publicKey: PUBLIC_KEY, + session: { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: BASE_CONFIG.networkPassphrase, + account: PUBLIC_KEY, + }, + }); + + await assert.rejects( + client.preflightCommit(VALID_COMMIT_PARAMS), + SubRosaNetworkMismatchError, + ); + }); + + it("does not leak secret seed in session mismatch errors", async () => { + const client = new SubRosaClient({ + ...BASE_CONFIG, + secretKey: FIXTURE_SEED, + }); + + try { + await client.commit({ + ...VALID_COMMIT_PARAMS, + session: { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: BASE_CONFIG.networkPassphrase, + }, + }); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e instanceof Error); + assert.ok(!e.message.includes(FIXTURE_SEED)); + assert.ok(!/\bS[A-Z2-7]{55}\b/.test(e.message)); + } + }); +}); + diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index d4d95293..aa14f296 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -49,7 +49,11 @@ import { SubRosaTransactionError, } from "./errors.js"; import { normalizeRoundId, normalizeSorobanContractId } from "./ids.js"; -import { validateContractNetwork } from "./network.js"; +import { + validateContractNetwork, + validatePasskeySession, + type PasskeySessionBinding, +} from "./network.js"; import { resolveTimeContext, systemTime, @@ -108,7 +112,8 @@ export interface SubRosaClientConfig { * @internal Testing hook: inject a mock Soroban RPC server for simulation. */ _server?: rpc.Server; - + /** Optional passkey session to bind commits and client operations to. */ + session?: PasskeySessionBinding; } export type ClearingRuleTag = ClearingRule["tag"]; @@ -140,6 +145,8 @@ export interface CommitParams { escrow: bigint; /** Bidder address. Default: the configured signer's public key. */ bidder?: string; + /** Optional passkey session to bind this commit to. */ + session?: PasskeySessionBinding; } export interface RevealParams { @@ -171,6 +178,7 @@ export class SubRosaClient { readonly #clock: Clock; readonly #scheduler: Scheduler; readonly #server: rpc.Server; + readonly #session?: PasskeySessionBinding; #networkValidation?: Promise; @@ -217,6 +225,7 @@ export class SubRosaClient { const time = resolveTimeContext(systemTime, config.time); this.#clock = time.clock; this.#scheduler = time.scheduler; + this.#session = config.session; this.#server = config._server ?? new rpc.Server(config.rpcUrl, { allowHttp }); if (config._sleep) this.#sleep = config._sleep; @@ -238,6 +247,16 @@ export class SubRosaClient { return this.contract.spec; } + /** The configured source account (public key G...) if available. */ + get account(): string | undefined { + return this.#source; + } + + /** The configured passkey session binding, if any. */ + get session(): PasskeySessionBinding | undefined { + return this.#session; + } + #requireSource(role: string): string { if (!this.#source) { throw new SubRosaClientConfigError( @@ -425,6 +444,15 @@ export class SubRosaClient { } async commit(params: CommitParams): Promise { + const session = params.session ?? this.#session; + if (session) { + validatePasskeySession(session, { + contractId: this.contractId, + networkPassphrase: this.networkPassphrase, + account: params.bidder ?? this.#source, + }); + } + // Validate encrypted blobs before submitting — catches size/encoding // issues early, before paying gas for an on-chain revert (PayloadTooLarge). const ciphertextResult = validateEncryptedBlob( @@ -566,7 +594,15 @@ export class SubRosaClient { } /** Simulate `commit` without signing or submitting. */ - preflightCommit(params: CommitParams): Promise> { + async preflightCommit(params: CommitParams): Promise> { + const session = params.session ?? this.#session; + if (session) { + validatePasskeySession(session, { + contractId: this.contractId, + networkPassphrase: this.networkPassphrase, + account: params.bidder ?? this.#source, + }); + } return this.#preflight("commit", () => { const bidder = params.bidder ?? this.#requireSource("bidder"); return this.#validatedContractCall(() => diff --git a/packages/sdk/src/errors.ts b/packages/sdk/src/errors.ts index 38078a91..3d1904e4 100644 --- a/packages/sdk/src/errors.ts +++ b/packages/sdk/src/errors.ts @@ -152,37 +152,72 @@ export class SubRosaClientConfigError extends Error { } } +export type NetworkMismatchReason = + | "passphrase" + | "contract_not_found" + | "contract_mismatch" + | "account_mismatch" + | "session_mismatch"; + export interface NetworkMismatchErrorParams { contractId: string; configuredPassphrase: string; - rpcPassphrase: string; - rpcUrl: string; - reason: "passphrase" | "contract_not_found"; + rpcPassphrase?: string; + rpcUrl?: string; + reason: NetworkMismatchReason; + sessionContractId?: string; + sessionPassphrase?: string; + sessionAccount?: string; + expectedAccount?: string; } -/** Raised before contract simulation/signing when network configuration conflicts. */ +/** Raised before contract simulation/signing when network configuration or session binding conflicts. */ export class SubRosaNetworkMismatchError extends Error { - readonly name = "SubRosaNetworkMismatchError"; + readonly name: string = "SubRosaNetworkMismatchError"; readonly contractId: string; readonly configuredPassphrase: string; - readonly rpcPassphrase: string; - readonly rpcUrl: string; + readonly rpcPassphrase?: string; + readonly rpcUrl?: string; readonly reason: NetworkMismatchErrorParams["reason"]; + readonly sessionContractId?: string; + readonly sessionPassphrase?: string; + readonly sessionAccount?: string; + readonly expectedAccount?: string; constructor(params: NetworkMismatchErrorParams) { - const message = - params.reason === "passphrase" - ? `networkPassphrase ${JSON.stringify(params.configuredPassphrase)} does not match RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; use the passphrase and contract ID from the same deployment` - : `contract ${params.contractId} was not found on RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; check that contractId and networkPassphrase refer to the same deployment`; + let rawMessage: string; + if (params.reason === "passphrase") { + rawMessage = `networkPassphrase ${JSON.stringify(params.configuredPassphrase)} does not match RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; use the passphrase and contract ID from the same deployment`; + } else if (params.reason === "contract_not_found") { + rawMessage = `contract ${params.contractId} was not found on RPC network ${JSON.stringify(params.rpcPassphrase)} at ${params.rpcUrl}; check that contractId and networkPassphrase refer to the same deployment`; + } else if (params.reason === "contract_mismatch") { + rawMessage = `passkey session contract ${params.sessionContractId ?? params.contractId} does not match client contract ${params.contractId}; refuse commit across different contracts`; + } else if (params.reason === "account_mismatch") { + rawMessage = `passkey session account ${params.sessionAccount} does not match client account ${params.expectedAccount}; refuse commit for different account`; + } else { + rawMessage = `passkey session network ${JSON.stringify(params.sessionPassphrase ?? params.configuredPassphrase)} does not match client network ${JSON.stringify(params.configuredPassphrase)}; refuse commit across different networks`; + } + + // Keep secret seeds strictly out of error text + const message = rawMessage.replace(/\bS[A-Z2-7]{55}\b/g, "[REDACTED]"); super(message); this.contractId = params.contractId; this.configuredPassphrase = params.configuredPassphrase; this.rpcPassphrase = params.rpcPassphrase; this.rpcUrl = params.rpcUrl; this.reason = params.reason; + this.sessionContractId = params.sessionContractId; + this.sessionPassphrase = params.sessionPassphrase; + this.sessionAccount = params.sessionAccount; + this.expectedAccount = params.expectedAccount; } } +/** Specific typed error for passkey session binding mismatches. Inherits from SubRosaNetworkMismatchError. */ +export class SubRosaSessionMismatchError extends SubRosaNetworkMismatchError { + override readonly name = "SubRosaSessionMismatchError"; +} + export class SubRosaSubmitError extends Error { readonly name = "SubRosaSubmitError"; readonly retryable?: boolean; diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 35e03582..bdbc4098 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -37,6 +37,7 @@ export { SubRosaMissingReturnValueError, SubRosaNetworkMismatchError, SubRosaPreflightError, + SubRosaSessionMismatchError, SubRosaSubmitError, SubRosaTimeoutError, SubRosaTransactionError, @@ -47,18 +48,18 @@ export type { RoundContractErrorSpec, ContractErrorEntry, NetworkMismatchErrorParams, + NetworkMismatchReason, PreflightFailureKind, SubRosaPreflightErrorParams, TimeoutErrorParams, } from "./errors.js"; export { validateContractNetwork, - networkDisplayName, - networkPassphrasesMatch, - describeNetworkPassphraseMismatch, - SubRosaNetworkPassphraseMismatchError, + validatePasskeySession, type ContractNetworkValidationConfig, type NetworkValidationServer, + type PasskeySessionBinding, + type SessionBindingTarget, } from "./network.js"; export { diff --git a/packages/sdk/src/network.test.ts b/packages/sdk/src/network.test.ts index 7c5881c1..1edb8172 100644 --- a/packages/sdk/src/network.test.ts +++ b/packages/sdk/src/network.test.ts @@ -1,13 +1,13 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { StrKey } from "@stellar/stellar-sdk"; -import { SubRosaNetworkMismatchError } from "./errors.js"; +import { + SubRosaNetworkMismatchError, + SubRosaSessionMismatchError, +} from "./errors.js"; import { validateContractNetwork, - networkDisplayName, - networkPassphrasesMatch, - describeNetworkPassphraseMismatch, - SubRosaNetworkPassphraseMismatchError, + validatePasskeySession, type NetworkValidationServer, } from "./network.js"; @@ -121,3 +121,127 @@ describe("validateContractNetwork", () => { assert.equal(ledgerLookups, 0); }); }); + +describe("validatePasskeySession", () => { + const SESSION_ACCOUNT = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + const SWAPPED_CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); + const FIXTURE_SEED = "SBGWGH5QWWZ2WKKG24YCQAL35EWB64L35KAGL3E7N7H5K3T4K5K3T4K5"; + + it("accepts a matching passkey session and target", () => { + assert.doesNotThrow(() => { + validatePasskeySession( + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + ); + }); + }); + + it("rejects a swapped contract id with a typed mismatch error", () => { + assert.throws( + () => { + validatePasskeySession( + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + ); + }, + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "contract_mismatch"); + assert.match((error as Error).message, /contract/); + return true; + }, + ); + }); + + it("rejects a swapped network passphrase with a typed mismatch error", () => { + assert.throws( + () => { + validatePasskeySession( + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + { + contractId: CONTRACT_ID, + networkPassphrase: PUBLIC, + account: SESSION_ACCOUNT, + }, + ); + }, + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "session_mismatch"); + assert.match((error as Error).message, /network/); + return true; + }, + ); + }); + + it("rejects a swapped account with a typed mismatch error", () => { + const OTHER_ACCOUNT = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBWHF"; + assert.throws( + () => { + validatePasskeySession( + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: OTHER_ACCOUNT, + }, + ); + }, + (error: unknown) => { + assert.ok(error instanceof SubRosaNetworkMismatchError); + assert.ok(error instanceof SubRosaSessionMismatchError); + assert.equal((error as SubRosaNetworkMismatchError).reason, "account_mismatch"); + return true; + }, + ); + }); + + it("ensures the error text does not contain secret seeds", () => { + try { + validatePasskeySession( + { + contractId: CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + { + contractId: SWAPPED_CONTRACT_ID, + networkPassphrase: TESTNET, + account: SESSION_ACCOUNT, + }, + ); + assert.fail("should have thrown"); + } catch (e) { + assert.ok(e instanceof Error); + assert.ok(!e.message.includes(FIXTURE_SEED)); + assert.ok(!/\bS[A-Z2-7]{55}\b/.test(e.message)); + } + }); +}); + diff --git a/packages/sdk/src/network.ts b/packages/sdk/src/network.ts index 9befe659..2cbdefdd 100644 --- a/packages/sdk/src/network.ts +++ b/packages/sdk/src/network.ts @@ -1,5 +1,9 @@ import { Contract, rpc } from "@stellar/stellar-sdk"; -import { SubRosaNetworkMismatchError } from "./errors.js"; +import { + SubRosaNetworkMismatchError, + SubRosaSessionMismatchError, +} from "./errors.js"; +import { normalizeSorobanContractId } from "./ids.js"; /** * Human-readable label for a Stellar network passphrase. Unknown/custom @@ -72,6 +76,76 @@ export interface ContractNetworkValidationConfig { rpcUrl: string; } +export interface PasskeySessionBinding { + /** Contract ID the passkey session was started for. */ + contractId: string; + /** Network passphrase the passkey session was created on. */ + networkPassphrase: string; + /** Account/public key bound to this session. */ + account?: string; +} + +export interface SessionBindingTarget { + /** Target contract ID (e.g., from the SDK client). */ + contractId: string; + /** Target network passphrase (e.g., from the SDK client). */ + networkPassphrase: string; + /** Target account (e.g., from the SDK client). */ + account?: string; +} + +/** + * Validate that a passkey session matches the contract ID, network passphrase, + * and account of the executing SDK client. + * + * Refuses execution and throws SubRosaSessionMismatchError if any differ. + */ +export function validatePasskeySession( + session: PasskeySessionBinding, + target: SessionBindingTarget, +): void { + if (session.networkPassphrase !== target.networkPassphrase) { + throw new SubRosaSessionMismatchError({ + contractId: target.contractId, + configuredPassphrase: target.networkPassphrase, + sessionPassphrase: session.networkPassphrase, + reason: "session_mismatch", + }); + } + + let sessionContract = session.contractId.trim(); + let targetContract = target.contractId.trim(); + try { + sessionContract = normalizeSorobanContractId(session.contractId); + } catch { + // Keep trimmed string + } + try { + targetContract = normalizeSorobanContractId(target.contractId); + } catch { + // Keep trimmed string + } + + if (sessionContract !== targetContract) { + throw new SubRosaSessionMismatchError({ + contractId: targetContract, + configuredPassphrase: target.networkPassphrase, + sessionContractId: sessionContract, + reason: "contract_mismatch", + }); + } + + if (session.account && target.account && session.account !== target.account) { + throw new SubRosaSessionMismatchError({ + contractId: targetContract, + configuredPassphrase: target.networkPassphrase, + sessionAccount: session.account, + expectedAccount: target.account, + reason: "account_mismatch", + }); + } +} + /** * Confirm that the RPC is on the configured network and that the contract * exists there. Contract StrKeys do not encode a network, so both checks are @@ -104,3 +178,4 @@ export async function validateContractNetwork( }); } } + diff --git a/packages/sdk/src/public-api-snapshot.test.ts b/packages/sdk/src/public-api-snapshot.test.ts index 08977116..51ed5608 100644 --- a/packages/sdk/src/public-api-snapshot.test.ts +++ b/packages/sdk/src/public-api-snapshot.test.ts @@ -33,6 +33,7 @@ const EXPECTED_EXPORTS = [ "SubRosaNetworkMismatchError", "SubRosaNetworkPassphraseMismatchError", "SubRosaPreflightError", + "SubRosaSessionMismatchError", "SubRosaSubmitError", "SubRosaTimeoutError", "SubRosaTransactionError", @@ -81,6 +82,7 @@ const EXPECTED_EXPORTS = [ "validateAssetConfigs", "validateEncryptedBlob", "validateContractNetwork", + "validatePasskeySession", "verifyReceipt", "verifyReceiptEvents", "verifySettledRoundProof",