From 92272d86e8802c87df12b16137d2f105a4c3246f Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 00:30:58 +0100 Subject: [PATCH 1/5] feat(stellar): add WebAuthn PRF primitives for passkey wallet mode --- src/lib/stellar/passkey.test.ts | 118 ++++++++++++++ src/lib/stellar/passkey.ts | 267 ++++++++++++++++++++++++++++++++ 2 files changed, 385 insertions(+) create mode 100644 src/lib/stellar/passkey.test.ts create mode 100644 src/lib/stellar/passkey.ts diff --git a/src/lib/stellar/passkey.test.ts b/src/lib/stellar/passkey.test.ts new file mode 100644 index 0000000..68dfae1 --- /dev/null +++ b/src/lib/stellar/passkey.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { + parsePrfExtensionResult, + bufferToBase64Url, + base64UrlToBuffer, + isSessionValid, + SESSION_KEY_TTL_MS, + SESSION_KEY_MAX_SIGNATURES, + PasskeyError, + type PasskeySession, +} from './passkey'; + +// ── parsePrfExtensionResult ───────────────────────────────────────────────── + +describe('parsePrfExtensionResult', () => { + it('extracts the PRF secret when present', () => { + const secretBytes = new Uint8Array(32).fill(7); + const result = parsePrfExtensionResult({ + prf: { enabled: true, results: { first: secretBytes } }, + } as AuthenticationExtensionsClientOutputs); + + expect(result).toEqual(secretBytes); + }); + + it('accepts an ArrayBuffer for the first result', () => { + const secretBytes = new Uint8Array(32).fill(3); + const result = parsePrfExtensionResult({ + prf: { enabled: true, results: { first: secretBytes.buffer } }, + } as AuthenticationExtensionsClientOutputs); + + expect(result).toEqual(secretBytes); + }); + + it('throws PRF_UNSUPPORTED when the prf member is absent', () => { + expect(() => parsePrfExtensionResult({} as AuthenticationExtensionsClientOutputs)).toThrow( + PasskeyError, + ); + try { + parsePrfExtensionResult({} as AuthenticationExtensionsClientOutputs); + } catch (err) { + expect((err as PasskeyError).code).toBe('PRF_UNSUPPORTED'); + } + }); + + it('throws PRF_UNSUPPORTED when the extension results are null', () => { + expect(() => parsePrfExtensionResult(null)).toThrow(PasskeyError); + }); + + it('throws PRF_UNSUPPORTED when enabled is explicitly false', () => { + expect(() => + parsePrfExtensionResult({ + prf: { enabled: false }, + } as AuthenticationExtensionsClientOutputs), + ).toThrow(/unavailable/); + }); + + it('throws PRF_UNSUPPORTED when results.first is missing', () => { + expect(() => + parsePrfExtensionResult({ + prf: { enabled: true, results: {} }, + } as AuthenticationExtensionsClientOutputs), + ).toThrow(/did not evaluate/); + }); + + it('throws PRF_UNSUPPORTED when the secret is empty', () => { + expect(() => + parsePrfExtensionResult({ + prf: { enabled: true, results: { first: new Uint8Array(0) } }, + } as AuthenticationExtensionsClientOutputs), + ).toThrow(/empty secret/); + }); +}); + +// ── base64url helpers ─────────────────────────────────────────────────────── + +describe('base64url helpers', () => { + it('round-trips arbitrary byte sequences', () => { + const bytes = new Uint8Array([0, 1, 2, 253, 254, 255, 16, 32, 64, 128]); + expect(base64UrlToBuffer(bufferToBase64Url(bytes))).toEqual(bytes); + }); + + it('produces URL-safe output with no padding', () => { + const bytes = new Uint8Array(33).fill(255); + const encoded = bufferToBase64Url(bytes); + expect(encoded).not.toMatch(/[+/=]/); + }); +}); + +// ── session ceiling ───────────────────────────────────────────────────────── + +describe('isSessionValid', () => { + function makeSession(overrides: Partial = {}): PasskeySession { + return { + secret: new Uint8Array(32), + createdAt: Date.now(), + signatureCount: 0, + ...overrides, + }; + } + + it('returns false for null', () => { + expect(isSessionValid(null)).toBe(false); + }); + + it('returns true for a fresh session under both ceilings', () => { + expect(isSessionValid(makeSession())).toBe(true); + }); + + it('returns false once the TTL has elapsed', () => { + const session = makeSession({ createdAt: Date.now() - SESSION_KEY_TTL_MS - 1 }); + expect(isSessionValid(session)).toBe(false); + }); + + it('returns false once the signature ceiling is reached', () => { + const session = makeSession({ signatureCount: SESSION_KEY_MAX_SIGNATURES }); + expect(isSessionValid(session)).toBe(false); + }); +}); diff --git a/src/lib/stellar/passkey.ts b/src/lib/stellar/passkey.ts new file mode 100644 index 0000000..31b09fe --- /dev/null +++ b/src/lib/stellar/passkey.ts @@ -0,0 +1,267 @@ +/** + * src/lib/stellar/passkey.ts + * + * Browser-side WebAuthn plumbing for the Passkey smart-account wallet mode. + * Everything here is pure Web Authentication API + PRF extension handling — + * it never talks to Horizon, Soroban, or the SDK. `PasskeyAdapter` composes + * this with `WebAuthnPasskeyStealthSigner` from `@wraith-protocol/sdk/chains/stellar` + * to do the actual smart-account signing. + * + * The PRF extension (https://w3c.github.io/webauthn/#prf-extension) lets a + * passkey act as a deterministic key-derivation function: evaluating the same + * salt against the same credential always returns the same 32-byte secret, + * without ever exposing the authenticator's private key. That secret is what + * seeds the smart account's signing key. + */ + +const RP_SALT_LABEL = new TextEncoder().encode('wraith-protocol:stellar:passkey:v1'); + +export type PasskeyErrorCode = + | 'PRF_UNSUPPORTED' + | 'NO_CREDENTIAL' + | 'USER_REJECTED' + | 'CREATE_FAILED' + | 'GET_FAILED'; + +export class PasskeyError extends Error { + constructor( + message: string, + public readonly code: PasskeyErrorCode, + ) { + super(message); + this.name = 'PasskeyError'; + } +} + +// ─── base64url helpers ────────────────────────────────────────────────────── + +export function bufferToBase64Url(buf: ArrayBuffer | Uint8Array): string { + const bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function base64UrlToBuffer(value: string): Uint8Array { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const padLength = (4 - (padded.length % 4)) % 4; + const binary = atob(padded + '='.repeat(padLength)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes; +} + +// ─── Feature detection ────────────────────────────────────────────────────── + +/** + * Best-effort check for whether this browser can plausibly support the PRF + * extension. WebAuthn's `getClientCapabilities()` (when present) reports it + * directly; older browsers only reveal PRF support at credential-creation + * time, so this is a necessary-but-not-sufficient gate used to decide + * whether to attempt the first-run flow at all. + */ +export async function isPrfLikelySupported(): Promise { + if (typeof window === 'undefined' || !window.PublicKeyCredential) return false; + + const getClientCapabilities = ( + window.PublicKeyCredential as unknown as { + getClientCapabilities?: () => Promise>; + } + ).getClientCapabilities; + + if (typeof getClientCapabilities === 'function') { + try { + const capabilities = await getClientCapabilities(); + if ('extension:prf' in capabilities) return capabilities['extension:prf']; + } catch { + // Fall through to the permissive default below. + } + } + + // No capability API available — assume support and let credential + // creation/assertion surface a PRF_UNSUPPORTED error if it turns out wrong. + return true; +} + +// ─── PRF extension result parsing (pure — unit tested) ───────────────────── + +interface PrfExtensionOutput { + enabled?: boolean; + results?: { + first?: BufferSource; + second?: BufferSource; + }; +} + +interface ExtensionResultsWithPrf extends AuthenticationExtensionsClientOutputs { + prf?: PrfExtensionOutput; +} + +/** + * Extracts the 32-byte PRF secret from a WebAuthn credential's client + * extension results. Used for both `create()` and `get()` outputs — the + * shape of the `prf` extension member is identical in both. + * + * Throws `PasskeyError('PRF_UNSUPPORTED', …)` whenever the authenticator + * did not evaluate the PRF extension, so callers can render the no-PRF + * next-step card instead of failing silently. + */ +export function parsePrfExtensionResult( + extensionResults: AuthenticationExtensionsClientOutputs | null | undefined, +): Uint8Array { + const prf = (extensionResults as ExtensionResultsWithPrf | null | undefined)?.prf; + + if (!prf) { + throw new PasskeyError( + 'This authenticator did not return a PRF extension result.', + 'PRF_UNSUPPORTED', + ); + } + + if (prf.enabled === false) { + throw new PasskeyError( + 'This authenticator reported the PRF extension as unavailable.', + 'PRF_UNSUPPORTED', + ); + } + + const first = prf.results?.first; + if (!first) { + throw new PasskeyError( + 'The authenticator did not evaluate the PRF salt for this credential.', + 'PRF_UNSUPPORTED', + ); + } + + const secret = first instanceof Uint8Array ? first : new Uint8Array(first as ArrayBuffer); + if (secret.length === 0) { + throw new PasskeyError('The PRF extension returned an empty secret.', 'PRF_UNSUPPORTED'); + } + + return secret; +} + +// ─── Credential creation / assertion ──────────────────────────────────────── + +export interface CreatePasskeyResult { + credentialId: Uint8Array; + prfSecret: Uint8Array; +} + +/** + * Registers a new platform passkey with the PRF extension requested, and + * returns both the credential id (to persist for future sign-in) and the + * derived secret (to seed the smart-account signing key). + */ +export async function createPasskeyCredential(opts: { + rpId: string; + rpName: string; + userName: string; +}): Promise { + if (typeof navigator === 'undefined' || !navigator.credentials) { + throw new PasskeyError('WebAuthn is not available in this browser.', 'PRF_UNSUPPORTED'); + } + + const userId = crypto.getRandomValues(new Uint8Array(16)); + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + let credential: Credential | null; + try { + credential = await navigator.credentials.create({ + publicKey: { + rp: { id: opts.rpId, name: opts.rpName }, + user: { id: userId, name: opts.userName, displayName: opts.userName }, + challenge, + pubKeyCredParams: [ + { type: 'public-key', alg: -7 }, // ES256 + { type: 'public-key', alg: -257 }, // RS256 fallback + ], + authenticatorSelection: { + residentKey: 'required', + userVerification: 'required', + }, + extensions: { + prf: { eval: { first: RP_SALT_LABEL } }, + } as AuthenticationExtensionsClientInputs, + }, + }); + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + throw new PasskeyError('Passkey creation was cancelled.', 'USER_REJECTED'); + } + throw new PasskeyError(`Passkey creation failed: ${String(err)}`, 'CREATE_FAILED'); + } + + if (!credential) { + throw new PasskeyError('Passkey creation returned no credential.', 'CREATE_FAILED'); + } + + const publicKeyCredential = credential as PublicKeyCredential; + const prfSecret = parsePrfExtensionResult(publicKeyCredential.getClientExtensionResults()); + + return { + credentialId: new Uint8Array(publicKeyCredential.rawId), + prfSecret, + }; +} + +/** + * Re-authenticates against a previously registered credential and + * re-derives the same PRF secret (deterministic for a given credential + + * salt), so the smart-account signing key never needs to be persisted. + */ +export async function getPasskeyAssertion(credentialId: Uint8Array): Promise { + if (typeof navigator === 'undefined' || !navigator.credentials) { + throw new PasskeyError('WebAuthn is not available in this browser.', 'PRF_UNSUPPORTED'); + } + + const challenge = crypto.getRandomValues(new Uint8Array(32)); + + let assertion: Credential | null; + try { + assertion = await navigator.credentials.get({ + publicKey: { + challenge, + allowCredentials: [{ id: credentialId, type: 'public-key' }], + userVerification: 'required', + extensions: { + prf: { eval: { first: RP_SALT_LABEL } }, + } as AuthenticationExtensionsClientInputs, + }, + }); + } catch (err) { + if (err instanceof DOMException && err.name === 'NotAllowedError') { + throw new PasskeyError('Passkey sign-in was cancelled.', 'USER_REJECTED'); + } + throw new PasskeyError(`Passkey sign-in failed: ${String(err)}`, 'GET_FAILED'); + } + + if (!assertion) { + throw new PasskeyError('No matching passkey was found.', 'NO_CREDENTIAL'); + } + + const publicKeyCredential = assertion as PublicKeyCredential; + return parsePrfExtensionResult(publicKeyCredential.getClientExtensionResults()); +} + +// ─── Session-key ceiling ───────────────────────────────────────────────────── + +/** + * A derived session key is reused for repeated signs within one browser + * session instead of re-running the PRF ceremony every time. Both ceilings + * are enforced together — whichever is hit first ends the session. + */ +export const SESSION_KEY_TTL_MS = 30 * 60 * 1000; // 30 minutes +export const SESSION_KEY_MAX_SIGNATURES = 20; + +export interface PasskeySession { + secret: Uint8Array; + createdAt: number; + signatureCount: number; +} + +export function isSessionValid(session: PasskeySession | null): session is PasskeySession { + if (!session) return false; + const age = Date.now() - session.createdAt; + return age < SESSION_KEY_TTL_MS && session.signatureCount < SESSION_KEY_MAX_SIGNATURES; +} From ced97bec8db563737a6d62275f2a0c570142e1a2 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 06:41:33 +0100 Subject: [PATCH 2/5] feat(stellar): register PasskeyAdapter as a smart-account wallet mode --- src/wallets/stellar/PasskeyAdapter.ts | 215 ++++++++++++++++++++++++++ src/wallets/stellar/index.ts | 20 ++- src/wallets/stellar/types.ts | 2 +- 3 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 src/wallets/stellar/PasskeyAdapter.ts diff --git a/src/wallets/stellar/PasskeyAdapter.ts b/src/wallets/stellar/PasskeyAdapter.ts new file mode 100644 index 0000000..3331f8d --- /dev/null +++ b/src/wallets/stellar/PasskeyAdapter.ts @@ -0,0 +1,215 @@ +/** + * src/wallets/stellar/PasskeyAdapter.ts + * + * Passkey smart-account wallet mode. Unlike the other adapters, this one + * never talks to a browser extension: it drives the WebAuthn PRF ceremony + * directly (see src/lib/stellar/passkey.ts) and hands the derived secret to + * the SDK's `WebAuthnPasskeyStealthSigner`, which owns the Soroban smart + * account (deployment, session-key delegation, and transaction signing). + * + * ASSUMPTION (flag during review): the exact constructor/method shape of + * `WebAuthnPasskeyStealthSigner` below is inferred from the issue's + * description of `sdk/src/chains/stellar/signer.ts` — it was not possible to + * inspect the installed package from this environment. `tsc` will catch a + * mismatch; adjust the call sites to match the real export if it differs. + */ + +import { + createPasskeyCredential, + getPasskeyAssertion, + isPrfLikelySupported, + isSessionValid, + type PasskeySession, + PasskeyError, + bufferToBase64Url, + base64UrlToBuffer, +} from '@/lib/stellar/passkey'; +import { STELLAR_NETWORK } from '@/config'; +import type { StellarWallet, ConnectResult, SignResult, SignOpts } from './types'; +import { WalletError } from './types'; + +const STORAGE_KEY_CREDENTIAL_ID = 'wraith:passkey:credentialId'; +const STORAGE_KEY_ADDRESS = 'wraith:passkey:address'; +const RP_NAME = 'Wraith Demo'; +const FRIENDBOT_URL = 'https://friendbot.stellar.org'; + +// Self-contained key glyph — avoids depending on an external icon host. +export const PASSKEY_ICON = + 'data:image/svg+xml;utf8,' + + encodeURIComponent( + '', + ); + +interface PasskeySigner { + getAddress(): string; + signTransaction(xdr: string): Promise<{ signedXdr: string }>; + deploySmartAccount?(): Promise; +} + +export class PasskeyAdapter implements StellarWallet { + readonly id = 'passkey' as const; + readonly name = 'Passkey'; + readonly icon = PASSKEY_ICON; + readonly installUrl = 'https://passkeys.dev/device-support/'; + + private session: PasskeySession | null = null; + private signer: PasskeySigner | null = null; + + async isAvailable(): Promise { + try { + return await isPrfLikelySupported(); + } catch { + return false; + } + } + + async connect(): Promise { + const supported = await this.isAvailable(); + if (!supported) { + throw new WalletError( + 'This browser or device does not support passkeys with the PRF extension.', + 'NOT_AVAILABLE', + 'passkey', + ); + } + + const storedCredentialId = localStorage.getItem(STORAGE_KEY_CREDENTIAL_ID); + const storedAddress = localStorage.getItem(STORAGE_KEY_ADDRESS); + + try { + if (storedCredentialId && storedAddress) { + const credentialId = base64UrlToBuffer(storedCredentialId); + const prfSecret = await getPasskeyAssertion(credentialId); + this.signer = await this.deriveSigner(credentialId, prfSecret, storedAddress); + this.startSession(prfSecret); + + return { publicKey: storedAddress, network: STELLAR_NETWORK.name.toLowerCase() }; + } + + return await this.firstRun(); + } catch (err) { + if (err instanceof WalletError) throw err; + if (err instanceof PasskeyError) { + if (err.code === 'PRF_UNSUPPORTED') { + throw new WalletError(err.message, 'NOT_AVAILABLE', 'passkey'); + } + if (err.code === 'USER_REJECTED') { + throw new WalletError(err.message, 'USER_REJECTED', 'passkey'); + } + throw new WalletError(err.message, 'CONNECT_FAILED', 'passkey'); + } + throw new WalletError(`Passkey connect failed: ${String(err)}`, 'CONNECT_FAILED', 'passkey'); + } + } + + /** + * Create-or-import a smart account: register a fresh passkey, deploy its + * Soroban smart account, and fund it via friendbot on testnet so it can + * pay its own fees immediately. Never touches a browser extension. + */ + private async firstRun(): Promise { + const userSuffix = bufferToBase64Url(crypto.getRandomValues(new Uint8Array(6))); + const { credentialId, prfSecret } = await createPasskeyCredential({ + rpId: window.location.hostname, + rpName: RP_NAME, + userName: `wraith-${userSuffix}`, + }); + + this.signer = await this.deriveSigner(credentialId, prfSecret, null); + const address = this.signer.getAddress(); + + if (STELLAR_NETWORK.name.toLowerCase().includes('testnet')) { + try { + await fetch(`${FRIENDBOT_URL}?addr=${encodeURIComponent(address)}`); + } catch { + // Funding is best-effort — the account still exists, it just has no + // balance yet. The receive/send flows surface that as a normal + // insufficient-balance error rather than a connect failure. + } + } + + localStorage.setItem(STORAGE_KEY_CREDENTIAL_ID, bufferToBase64Url(credentialId)); + localStorage.setItem(STORAGE_KEY_ADDRESS, address); + this.startSession(prfSecret); + + return { publicKey: address, network: STELLAR_NETWORK.name.toLowerCase() }; + } + + private async deriveSigner( + credentialId: Uint8Array, + prfSecret: Uint8Array, + knownAddress: string | null, + ): Promise { + const { WebAuthnPasskeyStealthSigner } = await import( + /* webpackChunkName: "passkey-signer" */ + '@wraith-protocol/sdk/chains/stellar' + ); + + const signer = new WebAuthnPasskeyStealthSigner({ + networkPassphrase: STELLAR_NETWORK.networkPassphrase, + rpcUrl: STELLAR_NETWORK.rpcUrl, + credentialId, + prfSecret, + }) as unknown as PasskeySigner; + + if (!knownAddress && typeof signer.deploySmartAccount === 'function') { + await signer.deploySmartAccount(); + } + + return signer; + } + + private startSession(secret: Uint8Array): void { + this.session = { secret, createdAt: Date.now(), signatureCount: 0 }; + } + + async signTransaction(xdr: string, _opts: SignOpts = {}): Promise { + if (!this.signer) { + throw new WalletError('No passkey session — connect first.', 'SIGN_FAILED', 'passkey'); + } + + if (!isSessionValid(this.session)) { + const storedCredentialId = localStorage.getItem(STORAGE_KEY_CREDENTIAL_ID); + const storedAddress = localStorage.getItem(STORAGE_KEY_ADDRESS); + if (!storedCredentialId || !storedAddress) { + throw new WalletError( + 'Passkey session expired and no stored credential was found.', + 'SIGN_FAILED', + 'passkey', + ); + } + + try { + const credentialId = base64UrlToBuffer(storedCredentialId); + const prfSecret = await getPasskeyAssertion(credentialId); + this.signer = await this.deriveSigner(credentialId, prfSecret, storedAddress); + this.startSession(prfSecret); + } catch (err) { + if (err instanceof PasskeyError && err.code === 'USER_REJECTED') { + throw new WalletError(err.message, 'USER_REJECTED', 'passkey'); + } + throw new WalletError( + `Passkey re-authentication failed: ${String(err)}`, + 'SIGN_FAILED', + 'passkey', + ); + } + } + + try { + const result = await this.signer.signTransaction(xdr); + if (this.session) this.session.signatureCount += 1; + return { signedXdr: result.signedXdr }; + } catch (err) { + throw new WalletError(`Passkey sign failed: ${String(err)}`, 'SIGN_FAILED', 'passkey'); + } + } + + async disconnect(): Promise { + this.session = null; + this.signer = null; + // Deliberately keeps the persisted credential id / address — the + // passkey itself lives in the platform authenticator and re-connecting + // should not force the user through the first-run flow again. + } +} diff --git a/src/wallets/stellar/index.ts b/src/wallets/stellar/index.ts index 3202ed9..dbc8cc5 100644 --- a/src/wallets/stellar/index.ts +++ b/src/wallets/stellar/index.ts @@ -17,8 +17,10 @@ export { WalletConnectAdapter } from './WalletConnectAdapter'; export { AlbedoAdapter } from './AlbedoAdapter'; export { XBullAdapter } from './XBullAdapter'; export { LOBSTRAdapter } from './LOBSTRAdapter'; +export { PasskeyAdapter, PASSKEY_ICON } from './PasskeyAdapter'; import type { StellarWallet, WalletId } from './types'; +import { PASSKEY_ICON } from './PasskeyAdapter'; /** * Returns a fresh adapter instance for the given wallet ID. @@ -47,6 +49,10 @@ export function getAdapter(id: WalletId): StellarWallet { const { LOBSTRAdapter } = require('./LOBSTRAdapter'); return new LOBSTRAdapter(); } + case 'passkey': { + const { PasskeyAdapter } = require('./PasskeyAdapter'); + return new PasskeyAdapter(); + } default: { const { FreighterAdapter } = require('./FreighterAdapter'); return new FreighterAdapter(); @@ -55,7 +61,14 @@ export function getAdapter(id: WalletId): StellarWallet { } /** All wallet IDs in display order. */ -export const WALLET_IDS: WalletId[] = ['freighter', 'albedo', 'xbull', 'lobstr', 'walletconnect']; +export const WALLET_IDS: WalletId[] = [ + 'freighter', + 'albedo', + 'xbull', + 'lobstr', + 'walletconnect', + 'passkey', +]; /** Metadata used by the picker without instantiating adapters. */ export const WALLET_META: Record = { @@ -84,4 +97,9 @@ export const WALLET_META: Record Date: Wed, 26 Aug 2026 06:41:48 +0100 Subject: [PATCH 3/5] feat(stellar): render a next-step card when passkey PRF is unsupported --- .../PasskeyUnsupportedCard.stories.tsx | 13 +++++++ src/components/PasskeyUnsupportedCard.tsx | 36 +++++++++++++++++++ src/components/StellarWalletPicker.tsx | 17 ++++++--- src/hooks/useStellarWallet.ts | 16 ++++++++- 4 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/components/PasskeyUnsupportedCard.stories.tsx create mode 100644 src/components/PasskeyUnsupportedCard.tsx diff --git a/src/components/PasskeyUnsupportedCard.stories.tsx b/src/components/PasskeyUnsupportedCard.stories.tsx new file mode 100644 index 0000000..9862279 --- /dev/null +++ b/src/components/PasskeyUnsupportedCard.stories.tsx @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { PasskeyUnsupportedCard } from './PasskeyUnsupportedCard'; + +const meta = { + title: 'Stellar/PasskeyUnsupportedCard', + component: PasskeyUnsupportedCard, + args: { installUrl: 'https://passkeys.dev/device-support/' }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/src/components/PasskeyUnsupportedCard.tsx b/src/components/PasskeyUnsupportedCard.tsx new file mode 100644 index 0000000..9b6cc76 --- /dev/null +++ b/src/components/PasskeyUnsupportedCard.tsx @@ -0,0 +1,36 @@ +/** + * src/components/PasskeyUnsupportedCard.tsx + * + * Rendered in the Stellar wallet picker instead of a generic error string + * when a device can't complete the passkey PRF ceremony. Pure and + * prop-driven — see CONTRIBUTING.md's view/container convention. + */ + +interface PasskeyUnsupportedCardProps { + installUrl: string; +} + +export function PasskeyUnsupportedCard({ installUrl }: PasskeyUnsupportedCardProps) { + return ( +
+

Passkey isn't available here

+

+ This browser or device doesn't support passkeys with the PRF extension, which the + smart account needs to derive its signing key. Try a recent Chrome, Safari, or Edge on a + device with Touch ID, Face ID, Windows Hello, or a PRF-capable hardware security key (e.g. + a YubiKey with firmware 5.2.7+). +

+ + Check device support ↗ + +
+ ); +} diff --git a/src/components/StellarWalletPicker.tsx b/src/components/StellarWalletPicker.tsx index 8a182c3..41ec80c 100644 --- a/src/components/StellarWalletPicker.tsx +++ b/src/components/StellarWalletPicker.tsx @@ -19,6 +19,7 @@ import { useState, useEffect } from 'react'; import { QRCodeSVG as QRCode } from 'qrcode.react'; import { WALLET_IDS, WALLET_META, type WalletId } from '@/wallets/stellar'; import type { StellarWalletState } from '@/hooks/useStellarWallet'; +import { PasskeyUnsupportedCard } from '@/components/PasskeyUnsupportedCard'; interface Props { state: StellarWalletState; @@ -31,12 +32,14 @@ export function StellarWalletPicker({ state }: Props) { connect, status, error, + errorCode, detecting, available, setPreconnectedWallet, } = state; const [pending, setPending] = useState(null); + const [lastAttemptedId, setLastAttemptedId] = useState(null); const [wcUri, setWcUri] = useState(null); const [wcConnecting, setWcConnecting] = useState(false); @@ -85,6 +88,7 @@ export function StellarWalletPicker({ state }: Props) { async function handleSelect(id: WalletId) { if (pending) return; setPending(id); + setLastAttemptedId(id); // Special handling for WalletConnect to capture URI if (id === 'walletconnect') { @@ -261,14 +265,19 @@ export function StellarWalletPicker({ state }: Props) { {/* Error message */} - {error && status === 'error' && ( -

{error}

- )} + {error && + status === 'error' && + (lastAttemptedId === 'passkey' && errorCode === 'NOT_AVAILABLE' ? ( + + ) : ( +

{error}

+ ))} {/* Footer note */}

Albedo, LOBSTR, and WalletConnect work in any browser — no extension needed. Freighter and - xBull require their browser extension to be installed. + xBull require their browser extension to be installed. Passkey needs no extension either — + it signs with your device's built-in authenticator or a hardware security key.

diff --git a/src/hooks/useStellarWallet.ts b/src/hooks/useStellarWallet.ts index ff21407..e6954ec 100644 --- a/src/hooks/useStellarWallet.ts +++ b/src/hooks/useStellarWallet.ts @@ -12,7 +12,14 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { getAdapter, WALLET_IDS, type StellarWallet, type WalletId } from '@/wallets/stellar'; +import { + getAdapter, + WALLET_IDS, + WalletError, + type StellarWallet, + type WalletId, + type WalletErrorCode, +} from '@/wallets/stellar'; const STORAGE_KEY_WALLET = 'wraith:stellar:wallet'; const STORAGE_KEY_PUBKEY = 'wraith:stellar:pubkey'; @@ -28,6 +35,8 @@ export interface StellarWalletState { network: string | null; status: WalletStatus; error: string | null; + /** Machine-readable code for the last connect error, if any. */ + errorCode: WalletErrorCode | null; /** True while availability checks are running on mount. */ detecting: boolean; /** Availability map populated after detection. */ @@ -59,6 +68,7 @@ export function useStellarWallet(): StellarWalletState { const [network, setNetwork] = useState(null); const [status, setStatus] = useState('idle'); const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [detecting, setDetecting] = useState(true); const [available, setAvailable] = useState>>({}); @@ -119,6 +129,7 @@ export function useStellarWallet(): StellarWalletState { connectingRef.current = true; setStatus('connecting'); setError(null); + setErrorCode(null); try { const adapter = getAdapter(id); @@ -138,6 +149,7 @@ export function useStellarWallet(): StellarWalletState { } catch (err) { setStatus('error'); setError(err instanceof Error ? err.message : String(err)); + setErrorCode(err instanceof WalletError ? err.code : null); } finally { connectingRef.current = false; } @@ -157,6 +169,7 @@ export function useStellarWallet(): StellarWalletState { setNetwork(null); setStatus('idle'); setError(null); + setErrorCode(null); localStorage.removeItem(STORAGE_KEY_WALLET); localStorage.removeItem(STORAGE_KEY_PUBKEY); localStorage.removeItem(STORAGE_KEY_NETWORK); @@ -198,6 +211,7 @@ export function useStellarWallet(): StellarWalletState { network, status, error, + errorCode, detecting, available, pickerOpen, From 697d1ced521808301420ac812a4a9f976ff45cbd Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 06:56:03 +0100 Subject: [PATCH 4/5] style(stellar): fix prettier formatting in PasskeyUnsupportedCard --- src/components/PasskeyUnsupportedCard.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/PasskeyUnsupportedCard.tsx b/src/components/PasskeyUnsupportedCard.tsx index 9b6cc76..1b12a38 100644 --- a/src/components/PasskeyUnsupportedCard.tsx +++ b/src/components/PasskeyUnsupportedCard.tsx @@ -18,16 +18,16 @@ export function PasskeyUnsupportedCard({ installUrl }: PasskeyUnsupportedCardPro >

Passkey isn't available here

- This browser or device doesn't support passkeys with the PRF extension, which the - smart account needs to derive its signing key. Try a recent Chrome, Safari, or Edge on a - device with Touch ID, Face ID, Windows Hello, or a PRF-capable hardware security key (e.g. - a YubiKey with firmware 5.2.7+). + This browser or device doesn't support passkeys with the PRF extension, which the smart + account needs to derive its signing key. Try a recent Chrome, Safari, or Edge on a device + with Touch ID, Face ID, Windows Hello, or a PRF-capable hardware security key (e.g. a + YubiKey with firmware 5.2.7+).

Check device support ↗ From 4c30f54df6615fd7544d36606c83ae2ba65a66a9 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Wed, 26 Aug 2026 12:08:15 +0100 Subject: [PATCH 5/5] fix(stellar): derive passkey signing key locally instead of nonexistent SDK class --- src/lib/stellar/passkey.test.ts | 1 - src/lib/stellar/passkey.ts | 31 ++++--- src/wallets/stellar/PasskeyAdapter.ts | 118 ++++++++++++++------------ 3 files changed, 80 insertions(+), 70 deletions(-) diff --git a/src/lib/stellar/passkey.test.ts b/src/lib/stellar/passkey.test.ts index 68dfae1..c97046e 100644 --- a/src/lib/stellar/passkey.test.ts +++ b/src/lib/stellar/passkey.test.ts @@ -91,7 +91,6 @@ describe('base64url helpers', () => { describe('isSessionValid', () => { function makeSession(overrides: Partial = {}): PasskeySession { return { - secret: new Uint8Array(32), createdAt: Date.now(), signatureCount: 0, ...overrides, diff --git a/src/lib/stellar/passkey.ts b/src/lib/stellar/passkey.ts index 31b09fe..54a6e02 100644 --- a/src/lib/stellar/passkey.ts +++ b/src/lib/stellar/passkey.ts @@ -1,17 +1,18 @@ /** * src/lib/stellar/passkey.ts * - * Browser-side WebAuthn plumbing for the Passkey smart-account wallet mode. - * Everything here is pure Web Authentication API + PRF extension handling — - * it never talks to Horizon, Soroban, or the SDK. `PasskeyAdapter` composes - * this with `WebAuthnPasskeyStealthSigner` from `@wraith-protocol/sdk/chains/stellar` - * to do the actual smart-account signing. + * Browser-side WebAuthn plumbing for the Passkey wallet mode. Everything + * here is pure Web Authentication API + PRF extension handling — it never + * talks to Horizon or Soroban. `PasskeyAdapter` uses the secret this module + * derives to seed a classic Ed25519 Stellar signing key (see the scope note + * at the top of PasskeyAdapter.ts for why it's classic rather than a + * Soroban smart account). * * The PRF extension (https://w3c.github.io/webauthn/#prf-extension) lets a * passkey act as a deterministic key-derivation function: evaluating the same * salt against the same credential always returns the same 32-byte secret, * without ever exposing the authenticator's private key. That secret is what - * seeds the smart account's signing key. + * seeds the account's signing key. */ const RP_SALT_LABEL = new TextEncoder().encode('wraith-protocol:stellar:passkey:v1'); @@ -93,7 +94,10 @@ interface PrfExtensionOutput { }; } -interface ExtensionResultsWithPrf extends AuthenticationExtensionsClientOutputs { +// Deliberately not `extends AuthenticationExtensionsClientOutputs` — lib.dom's +// AuthenticationExtensionsPRFOutputs requires `results.first` whenever `results` +// is present, which is stricter than what we want to assert before validating it. +interface ExtensionResultsWithPrf { prf?: PrfExtensionOutput; } @@ -208,7 +212,7 @@ export async function createPasskeyCredential(opts: { /** * Re-authenticates against a previously registered credential and * re-derives the same PRF secret (deterministic for a given credential + - * salt), so the smart-account signing key never needs to be persisted. + * salt), so the account's signing key never needs to be persisted. */ export async function getPasskeyAssertion(credentialId: Uint8Array): Promise { if (typeof navigator === 'undefined' || !navigator.credentials) { @@ -222,7 +226,7 @@ export async function getPasskeyAssertion(credentialId: Uint8Array): Promise; - deploySmartAccount?(): Promise; +/** + * Hashes the PRF secret once more before using it as an Ed25519 seed, so the + * raw authenticator output is never used verbatim as key material. + */ +function deriveKeypairFromPrfSecret(prfSecret: Uint8Array): Keypair { + const seed = sha512(prfSecret).slice(0, 32); + return Keypair.fromRawEd25519Seed(Buffer.from(seed)); } export class PasskeyAdapter implements StellarWallet { @@ -53,7 +71,7 @@ export class PasskeyAdapter implements StellarWallet { readonly installUrl = 'https://passkeys.dev/device-support/'; private session: PasskeySession | null = null; - private signer: PasskeySigner | null = null; + private keypair: Keypair | null = null; async isAvailable(): Promise { try { @@ -80,9 +98,18 @@ export class PasskeyAdapter implements StellarWallet { if (storedCredentialId && storedAddress) { const credentialId = base64UrlToBuffer(storedCredentialId); const prfSecret = await getPasskeyAssertion(credentialId); - this.signer = await this.deriveSigner(credentialId, prfSecret, storedAddress); - this.startSession(prfSecret); + const keypair = deriveKeypairFromPrfSecret(prfSecret); + + if (keypair.publicKey() !== storedAddress) { + throw new WalletError( + 'The derived key no longer matches the stored account — this passkey may have changed.', + 'CONNECT_FAILED', + 'passkey', + ); + } + this.keypair = keypair; + this.startSession(); return { publicKey: storedAddress, network: STELLAR_NETWORK.name.toLowerCase() }; } @@ -103,9 +130,9 @@ export class PasskeyAdapter implements StellarWallet { } /** - * Create-or-import a smart account: register a fresh passkey, deploy its - * Soroban smart account, and fund it via friendbot on testnet so it can - * pay its own fees immediately. Never touches a browser extension. + * Create-or-import flow: register a fresh passkey, derive its Stellar + * keypair from the PRF secret, and fund it via friendbot on testnet so it + * can pay its own fees immediately. Never touches a browser extension. */ private async firstRun(): Promise { const userSuffix = bufferToBase64Url(crypto.getRandomValues(new Uint8Array(6))); @@ -115,8 +142,8 @@ export class PasskeyAdapter implements StellarWallet { userName: `wraith-${userSuffix}`, }); - this.signer = await this.deriveSigner(credentialId, prfSecret, null); - const address = this.signer.getAddress(); + const keypair = deriveKeypairFromPrfSecret(prfSecret); + const address = keypair.publicKey(); if (STELLAR_NETWORK.name.toLowerCase().includes('testnet')) { try { @@ -130,41 +157,18 @@ export class PasskeyAdapter implements StellarWallet { localStorage.setItem(STORAGE_KEY_CREDENTIAL_ID, bufferToBase64Url(credentialId)); localStorage.setItem(STORAGE_KEY_ADDRESS, address); - this.startSession(prfSecret); + this.keypair = keypair; + this.startSession(); return { publicKey: address, network: STELLAR_NETWORK.name.toLowerCase() }; } - private async deriveSigner( - credentialId: Uint8Array, - prfSecret: Uint8Array, - knownAddress: string | null, - ): Promise { - const { WebAuthnPasskeyStealthSigner } = await import( - /* webpackChunkName: "passkey-signer" */ - '@wraith-protocol/sdk/chains/stellar' - ); - - const signer = new WebAuthnPasskeyStealthSigner({ - networkPassphrase: STELLAR_NETWORK.networkPassphrase, - rpcUrl: STELLAR_NETWORK.rpcUrl, - credentialId, - prfSecret, - }) as unknown as PasskeySigner; - - if (!knownAddress && typeof signer.deploySmartAccount === 'function') { - await signer.deploySmartAccount(); - } - - return signer; - } - - private startSession(secret: Uint8Array): void { - this.session = { secret, createdAt: Date.now(), signatureCount: 0 }; + private startSession(): void { + this.session = { createdAt: Date.now(), signatureCount: 0 }; } - async signTransaction(xdr: string, _opts: SignOpts = {}): Promise { - if (!this.signer) { + async signTransaction(xdr: string, opts: SignOpts = {}): Promise { + if (!this.keypair) { throw new WalletError('No passkey session — connect first.', 'SIGN_FAILED', 'passkey'); } @@ -182,8 +186,8 @@ export class PasskeyAdapter implements StellarWallet { try { const credentialId = base64UrlToBuffer(storedCredentialId); const prfSecret = await getPasskeyAssertion(credentialId); - this.signer = await this.deriveSigner(credentialId, prfSecret, storedAddress); - this.startSession(prfSecret); + this.keypair = deriveKeypairFromPrfSecret(prfSecret); + this.startSession(); } catch (err) { if (err instanceof PasskeyError && err.code === 'USER_REJECTED') { throw new WalletError(err.message, 'USER_REJECTED', 'passkey'); @@ -197,9 +201,11 @@ export class PasskeyAdapter implements StellarWallet { } try { - const result = await this.signer.signTransaction(xdr); + const networkPassphrase = opts.networkPassphrase ?? STELLAR_NETWORK.networkPassphrase; + const tx = new Transaction(xdr, networkPassphrase); + tx.sign(this.keypair); if (this.session) this.session.signatureCount += 1; - return { signedXdr: result.signedXdr }; + return { signedXdr: tx.toXDR() }; } catch (err) { throw new WalletError(`Passkey sign failed: ${String(err)}`, 'SIGN_FAILED', 'passkey'); } @@ -207,7 +213,7 @@ export class PasskeyAdapter implements StellarWallet { async disconnect(): Promise { this.session = null; - this.signer = null; + this.keypair = null; // Deliberately keeps the persisted credential id / address — the // passkey itself lives in the platform authenticator and re-connecting // should not force the user through the first-run flow again.