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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/components/PasskeyUnsupportedCard.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof PasskeyUnsupportedCard>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {};
36 changes: 36 additions & 0 deletions src/components/PasskeyUnsupportedCard.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
data-testid="passkey-unsupported-card"
className="space-y-2 border border-[#2a2a2a] bg-[#1a1a1a] p-4"
>
<p className="text-xs font-semibold text-[#e6e1e5]">Passkey isn&apos;t available here</p>
<p className="text-[11px] leading-relaxed text-[#c4c7c5]">
This browser or device doesn&apos;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+).
</p>
<a
href={installUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block text-[11px] text-[#767575] underline hover:text-[#c4c7c5]"
>
Check device support ↗
</a>
</div>
);
}
17 changes: 13 additions & 4 deletions src/components/StellarWalletPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -31,12 +32,14 @@ export function StellarWalletPicker({ state }: Props) {
connect,
status,
error,
errorCode,
detecting,
available,
setPreconnectedWallet,
} = state;

const [pending, setPending] = useState<WalletId | null>(null);
const [lastAttemptedId, setLastAttemptedId] = useState<WalletId | null>(null);
const [wcUri, setWcUri] = useState<string | null>(null);
const [wcConnecting, setWcConnecting] = useState(false);

Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -261,14 +265,19 @@ export function StellarWalletPicker({ state }: Props) {
</div>

{/* Error message */}
{error && status === 'error' && (
<p className="text-xs text-[#ee7d77] leading-relaxed">{error}</p>
)}
{error &&
status === 'error' &&
(lastAttemptedId === 'passkey' && errorCode === 'NOT_AVAILABLE' ? (
<PasskeyUnsupportedCard installUrl={WALLET_META.passkey.installUrl} />
) : (
<p className="text-xs text-[#ee7d77] leading-relaxed">{error}</p>
))}

{/* Footer note */}
<p className="text-[10px] text-[#333333] leading-relaxed pt-1">
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.
</p>
</div>

Expand Down
16 changes: 15 additions & 1 deletion src/hooks/useStellarWallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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. */
Expand Down Expand Up @@ -59,6 +68,7 @@ export function useStellarWallet(): StellarWalletState {
const [network, setNetwork] = useState<string | null>(null);
const [status, setStatus] = useState<WalletStatus>('idle');
const [error, setError] = useState<string | null>(null);
const [errorCode, setErrorCode] = useState<WalletErrorCode | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [detecting, setDetecting] = useState(true);
const [available, setAvailable] = useState<Partial<Record<WalletId, boolean>>>({});
Expand Down Expand Up @@ -119,6 +129,7 @@ export function useStellarWallet(): StellarWalletState {
connectingRef.current = true;
setStatus('connecting');
setError(null);
setErrorCode(null);

try {
const adapter = getAdapter(id);
Expand All @@ -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;
}
Expand All @@ -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);
Expand Down Expand Up @@ -198,6 +211,7 @@ export function useStellarWallet(): StellarWalletState {
network,
status,
error,
errorCode,
detecting,
available,
pickerOpen,
Expand Down
117 changes: 117 additions & 0 deletions src/lib/stellar/passkey.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading
Loading