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..1b12a38 --- /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, diff --git a/src/lib/stellar/passkey.test.ts b/src/lib/stellar/passkey.test.ts new file mode 100644 index 0000000..c97046e --- /dev/null +++ b/src/lib/stellar/passkey.test.ts @@ -0,0 +1,117 @@ +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 { + 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..54a6e02 --- /dev/null +++ b/src/lib/stellar/passkey.ts @@ -0,0 +1,272 @@ +/** + * src/lib/stellar/passkey.ts + * + * 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 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; + }; +} + +// 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; +} + +/** + * 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 account's 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 as BufferSource, 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 ───────────────────────────────────────────────────── + +/** + * The signing key derived from one PRF ceremony is kept resident in memory + * and reused for repeated signs within a browser session, instead of + * re-running the PRF ceremony (and its biometric prompt) on every send. + * Both ceilings are enforced together — whichever is hit first ends the + * session and the next sign re-derives the key from a fresh PRF assertion. + */ +export const SESSION_KEY_TTL_MS = 30 * 60 * 1000; // 30 minutes +export const SESSION_KEY_MAX_SIGNATURES = 20; + +export interface PasskeySession { + 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; +} diff --git a/src/wallets/stellar/PasskeyAdapter.ts b/src/wallets/stellar/PasskeyAdapter.ts new file mode 100644 index 0000000..05f9b92 --- /dev/null +++ b/src/wallets/stellar/PasskeyAdapter.ts @@ -0,0 +1,221 @@ +/** + * src/wallets/stellar/PasskeyAdapter.ts + * + * Passkey wallet mode: no browser extension, no seed phrase. A user's + * device passkey deterministically derives a Stellar signing key via the + * WebAuthn PRF ceremony in src/lib/stellar/passkey.ts — the same key every + * time, for the same passkey, without ever being written to disk. + * + * SCOPE NOTE — read before extending this file: the linked issue (#150) + * describes a Soroban *smart* account with on-chain fee sponsorship and a + * contract-delegated session key, via an SDK export + * (`WebAuthnPasskeyStealthSigner`) referenced in the issue text. That export + * does not exist in any version of @wraith-protocol/sdk published to npm — + * checked every published version through the current latest, 1.4.5 — and a + * real smart-contract account additionally needs a deployed Soroban wallet + * contract (Rust/WASM) that doesn't exist anywhere in this repo. Neither is + * buildable from this environment. + * + * This adapter instead ships a fully working, honestly-scoped-down version: + * a classic Ed25519 Stellar account whose key is deterministically derived + * from the passkey. It satisfies "no extension prompt, funds itself on + * testnet, PRF-gated" end to end, using only real `@stellar/stellar-sdk` + * APIs. Fee sponsorship and contract-delegated session keys are follow-up + * work once a wallet contract exists to target — the "session" implemented + * here is a client-side ceiling on how long the derived key stays resident + * in memory (see SESSION_KEY_TTL_MS / SESSION_KEY_MAX_SIGNATURES in + * passkey.ts), not an on-chain delegation. + */ + +import { sha512 } from '@noble/hashes/sha512'; +import { Keypair, Transaction } from '@stellar/stellar-sdk'; +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( + '', + ); + +/** + * 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 { + 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 keypair: Keypair | 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); + 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() }; + } + + 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 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))); + const { credentialId, prfSecret } = await createPasskeyCredential({ + rpId: window.location.hostname, + rpName: RP_NAME, + userName: `wraith-${userSuffix}`, + }); + + const keypair = deriveKeypairFromPrfSecret(prfSecret); + const address = keypair.publicKey(); + + 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.keypair = keypair; + this.startSession(); + + return { publicKey: address, network: STELLAR_NETWORK.name.toLowerCase() }; + } + + private startSession(): void { + this.session = { createdAt: Date.now(), signatureCount: 0 }; + } + + async signTransaction(xdr: string, opts: SignOpts = {}): Promise { + if (!this.keypair) { + 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.keypair = deriveKeypairFromPrfSecret(prfSecret); + this.startSession(); + } 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 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: tx.toXDR() }; + } catch (err) { + throw new WalletError(`Passkey sign failed: ${String(err)}`, 'SIGN_FAILED', 'passkey'); + } + } + + async disconnect(): Promise { + this.session = 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. + } +} 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