diff --git a/modules/passkey-crypto/src/derivePasskeyPrfKey.ts b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
index 6d3580f3e0..0c8ba09133 100644
--- a/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
+++ b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
@@ -3,8 +3,10 @@ import { buildEvalByCredential, matchDeviceByCredentialId } from './prfHelpers';
import { derivePassword } from './derivePassword';
import type { WebAuthnProvider } from './webAuthnTypes';
-interface AssertionChallengeResponse {
+interface AuthChallengeResponse {
challenge: string;
+ allowCredentials?: Array<{ id: string; type: string; transports?: string[] }>;
+ origin?: string;
}
/**
@@ -23,7 +25,7 @@ export async function derivePasskeyPrfKey(params: {
// Fetch the wallet's user keychain to get webauthnDevices
const keychain = await wallet.getEncryptedUserKeychain();
- const devices = keychain.webauthnDevices;
+ const devices = (keychain as any).webauthnDevices ?? (keychain as any).webAuthnDevices;
if (!devices || devices.length === 0) {
throw new Error('No passkey devices available');
@@ -36,15 +38,22 @@ export async function derivePasskeyPrfKey(params: {
throw new Error('No passkey devices available with a valid PRF salt');
}
- // Fetch a server-issued assertion challenge
- const { challenge } = (await bitgo
- .get(bitgo.url('/user/otp/webauthn/assertion', 2))
- .result()) as AssertionChallengeResponse;
+ // Fetch a server-issued assertion challenge via the auth endpoint
+ const { challenge } = (await bitgo.get(bitgo.url('/user/otp/webauthn/auth', 2)).result()) as AuthChallengeResponse;
+
+ // Build allowCredentials so the browser knows which credentials to use.
+ // Pass the Buffer (Uint8Array) directly — not .buffer — so the provider
+ // layer can correctly slice it via ArrayBuffer.isView.
+ const allowCredentials = Object.keys(evalByCredential).map((credId) => ({
+ type: 'public-key' as const,
+ id: Buffer.from(credId.replace(/-/g, '+').replace(/_/g, '/'), 'base64') as unknown as ArrayBuffer,
+ }));
// Trigger WebAuthn assertion with PRF evaluation via the provider (navigator layer)
const result = await provider.get({
publicKey: {
challenge: Buffer.from(challenge, 'base64'),
+ allowCredentials,
} as PublicKeyCredentialRequestOptions,
evalByCredential,
});
diff --git a/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts b/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts
index 4b16913fe2..a215790473 100644
--- a/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts
+++ b/modules/passkey-crypto/test/unit/derivePasskeyPrfKey.test.ts
@@ -73,7 +73,7 @@ describe('derivePasskeyPrfKey', function () {
assert.strictEqual(getCallArgs.evalByCredential['cred-bbb'], 'salt-bbb');
// Verify bitgo was used to fetch the assertion challenge
assert.ok(mockBitGo.get.calledOnce);
- assert.ok(mockBitGo.url.calledWith('/user/otp/webauthn/assertion', 2));
+ assert.ok(mockBitGo.url.calledWith('/user/otp/webauthn/auth', 2));
});
it("should throw 'No passkey devices available' when no devices", async function () {
diff --git a/modules/web-demo/package.json b/modules/web-demo/package.json
index 66fc922428..7b5e64bc00 100644
--- a/modules/web-demo/package.json
+++ b/modules/web-demo/package.json
@@ -60,6 +60,7 @@
"@bitgo/sdk-coin-xtz": "^2.10.7",
"@bitgo/sdk-coin-zec": "^2.8.7",
"@bitgo/sdk-core": "^36.44.0",
+ "@bitgo/passkey-crypto": "*",
"@bitgo/sdk-hmac": "^1.9.0",
"@bitgo/sdk-lib-mpc": "^10.12.0",
"@bitgo/sdk-opensslbytes": "^2.1.0",
diff --git a/modules/web-demo/src/App.tsx b/modules/web-demo/src/App.tsx
index 7210cd93b2..b117539b3c 100644
--- a/modules/web-demo/src/App.tsx
+++ b/modules/web-demo/src/App.tsx
@@ -14,6 +14,7 @@ const EcdsaChallengeComponent = lazy(
() => import('@components/EcdsaChallenge'),
);
const WebCryptoAuthComponent = lazy(() => import('@components/WebCryptoAuth'));
+const PasskeyDemo = lazy(() => import('@components/PasskeyDemo'));
const Loading = () =>
Loading route...
;
@@ -40,6 +41,7 @@ const App = () => {
path="/webcrypto-auth"
element={ }
/>
+ } />
diff --git a/modules/web-demo/src/components/Navbar/index.tsx b/modules/web-demo/src/components/Navbar/index.tsx
index 210bd4961f..d42e19fbee 100644
--- a/modules/web-demo/src/components/Navbar/index.tsx
+++ b/modules/web-demo/src/components/Navbar/index.tsx
@@ -56,6 +56,12 @@ const Navbar = () => {
>
WebCrypto Auth
+ navigate('/passkey-demo')}
+ >
+ Passkey Demo
+
);
};
diff --git a/modules/web-demo/src/components/PasskeyDemo/index.tsx b/modules/web-demo/src/components/PasskeyDemo/index.tsx
new file mode 100644
index 0000000000..399494bd43
--- /dev/null
+++ b/modules/web-demo/src/components/PasskeyDemo/index.tsx
@@ -0,0 +1,1324 @@
+import React, { useState, useCallback, useRef, useEffect } from 'react';
+import { BitGoAPI, BitGoAPIOptions } from '@bitgo/sdk-api';
+import type { EnvironmentName } from '@bitgo/sdk-core';
+import {
+ WebCryptoHmacStrategy,
+ IndexedDbTokenStore,
+ // eslint-disable-next-line import/no-internal-modules
+} from '@bitgo/sdk-hmac/browser';
+import coinFactory from '@components/Coins/coinFactory';
+import {
+ registerPasskey,
+ attachPasskeyToWallet,
+ derivePasskeyPrfKey,
+ removePasskeyFromWallet,
+ removePasskeyFromAccount,
+ type WebAuthnOtpDevice,
+} from '@bitgo/passkey-crypto';
+import {
+ PageContainer,
+ TwoColumnLayout,
+ LeftColumn,
+ RightColumn,
+ Section,
+ SectionTitle,
+ FormGroup,
+ Label,
+ Input,
+ Button,
+ StatusBadge,
+ LogArea,
+ ErrorText,
+ SuccessText,
+} from './styles';
+import styled from 'styled-components';
+
+// ---------------------------------------------------------------------------
+// Extra styled components for the info panels
+// ---------------------------------------------------------------------------
+
+const PanelScroller = styled.div`
+ max-height: 260px;
+ overflow-y: auto;
+ border: 1px solid #e2e8f0;
+ border-radius: 6px;
+ background: #f8fafc;
+`;
+
+const PanelCard = styled.div`
+ padding: 10px 12px;
+ border-bottom: 1px solid #e2e8f0;
+ font-size: 13px;
+ line-height: 1.6;
+
+ &:last-child {
+ border-bottom: none;
+ }
+`;
+
+const PanelCardTitle = styled.div`
+ font-weight: 600;
+ color: #1a202c;
+ margin-bottom: 2px;
+`;
+
+const PanelMeta = styled.div`
+ color: #4a5568;
+ word-break: break-all;
+`;
+
+const PanelTag = styled.span<{ colour?: string }>`
+ display: inline-block;
+ padding: 1px 6px;
+ border-radius: 3px;
+ font-size: 11px;
+ font-weight: 600;
+ background: ${({ colour }) => colour || '#e2e8f0'};
+ color: ${({ colour }) =>
+ colour === '#c6f6d5'
+ ? '#276749'
+ : colour === '#fed7d7'
+ ? '#9b2c2c'
+ : '#4a5568'};
+ margin-right: 4px;
+`;
+
+const QuickActions = styled.div`
+ margin-top: 6px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+`;
+
+const SmallButton = styled.button`
+ padding: 2px 8px;
+ font-size: 11px;
+ border: 1px solid #4a90e2;
+ border-radius: 3px;
+ background: white;
+ color: #4a90e2;
+ cursor: pointer;
+ &:hover {
+ background: #ebf4ff;
+ }
+ &:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+ }
+`;
+
+const RefreshRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 6px;
+`;
+
+const EmptyState = styled.div`
+ padding: 16px;
+ text-align: center;
+ color: #a0aec0;
+ font-size: 13px;
+`;
+
+// ---------------------------------------------------------------------------
+// Types
+// ---------------------------------------------------------------------------
+
+type LogLevel = 'info' | 'success' | 'error';
+type LogEntry = { time: string; message: string; level: LogLevel };
+
+interface OtpDeviceInfo {
+ id: string;
+ credentialId?: string;
+ label?: string;
+ prfSalt?: string;
+ extensions?: Record;
+ isPasskey?: boolean;
+}
+
+interface WalletInfo {
+ id: string;
+ label: string;
+ type: string;
+ coin: string;
+ webauthnDevices?: { otpDeviceId: string; prfSalt?: string }[];
+}
+
+// ---------------------------------------------------------------------------
+// Helpers
+// ---------------------------------------------------------------------------
+
+function ts(): string {
+ return new Date().toLocaleTimeString('en-US', { hour12: false });
+}
+
+/**
+ * Converts any binary value (polyfilled Buffer, TypedArray, base64url string)
+ * to a native ArrayBuffer so the browser WebAuthn API accepts it.
+ */
+function toArrayBuffer(val: any): ArrayBuffer {
+ if (val instanceof ArrayBuffer) return val;
+ if (ArrayBuffer.isView(val)) {
+ return val.buffer.slice(
+ val.byteOffset,
+ val.byteOffset + val.byteLength,
+ ) as ArrayBuffer;
+ }
+ if (typeof val === 'string') {
+ const b64 = val.replace(/-/g, '+').replace(/_/g, '/');
+ const binary = atob(b64);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return bytes.buffer;
+ }
+ return val;
+}
+
+/**
+ * Normalises a PublicKeyCredentialCreationOptions object so all binary fields
+ * are native ArrayBuffers (not polyfilled Buffers) and rp.id matches the
+ * current hostname (required when running on localhost against staging).
+ */
+function normaliseCreateOptions(opts: any): any {
+ const o = { ...opts };
+ o.challenge = toArrayBuffer(o.challenge);
+ o.user = { ...o.user, id: toArrayBuffer(o.user.id) };
+ // Override rp.id to current hostname — server returns the production domain
+ // which won't match localhost, causing the browser to silently reject the call.
+ o.rp = { ...o.rp, id: window.location.hostname };
+ // Drop server-issued excludeCredentials — they reference credentials registered
+ // under the server's rpId, not localhost. Keeping them can cause Chrome to
+ // silently block the prompt if a matching credential exists on the authenticator.
+ o.excludeCredentials = [];
+ // During registration (create), PRF extension should be { prf: {} } to check
+ // support. The server may send prf.eval (with a salt) which is only valid during
+ // assertion (get). Chrome rejects eval during create; Safari ignores it.
+ if (o.extensions?.prf) {
+ o.extensions = { ...o.extensions, prf: {} };
+ }
+ return o;
+}
+
+/**
+ * Normalises a PublicKeyCredentialRequestOptions object so all binary fields
+ * are native ArrayBuffers and evalByCredential salts are decoded correctly.
+ */
+function normaliseGetOptions(
+ publicKey: any,
+ evalByCredential?: Record,
+): any {
+ const o = { ...publicKey };
+ o.challenge = toArrayBuffer(o.challenge);
+ if (o.allowCredentials) {
+ o.allowCredentials = o.allowCredentials.map((c: any) => ({
+ ...c,
+ id: toArrayBuffer(c.id),
+ }));
+ }
+ if (evalByCredential) {
+ const normalisedEval: Record = {};
+ for (const [credId, salt] of Object.entries(evalByCredential)) {
+ normalisedEval[credId] = { first: toArrayBuffer(salt) };
+ }
+ o.extensions = { prf: { evalByCredential: normalisedEval } };
+ }
+ return o;
+}
+
+// browserProvider is created inside the component so it has access to `log`.
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+const PasskeyDemo = () => {
+ // Login
+ const [email, setEmail] = useState('');
+ const [password, setPassword] = useState('');
+ const [otp, setOtp] = useState('');
+
+ // Config — env/enterpriseId/coin persisted to localStorage
+ const [env, setEnv] = useState(
+ () =>
+ (localStorage.getItem('passkey-demo:env') as EnvironmentName) || 'test',
+ );
+ const [enterpriseId, setEnterpriseId] = useState(
+ () => localStorage.getItem('passkey-demo:enterpriseId') || '',
+ );
+ const [coin, setCoin] = useState(
+ () => localStorage.getItem('passkey-demo:coin') || 'tbtc',
+ );
+
+ // Persist config changes
+ useEffect(() => {
+ localStorage.setItem('passkey-demo:env', env);
+ }, [env]);
+ useEffect(() => {
+ localStorage.setItem('passkey-demo:enterpriseId', enterpriseId);
+ }, [enterpriseId]);
+ useEffect(() => {
+ localStorage.setItem('passkey-demo:coin', coin);
+ }, [coin]);
+
+ // SDK state
+ const [sdkReady, setSdkReady] = useState(false);
+
+ // Data
+ const [walletId, setWalletId] = useState('');
+
+ // Operation inputs
+ const [passkeyLabel, setPasskeyLabel] = useState('');
+ const [walletLabel, setWalletLabel] = useState('');
+ const [walletPassphrase, setWalletPassphrase] = useState('');
+ const [attachDeviceId, setAttachDeviceId] = useState('');
+ const [attachPassphrase, setAttachPassphrase] = useState('');
+ const [recipientAddress, setRecipientAddress] = useState('');
+ const [sendAmount, setSendAmount] = useState('');
+ const [lastPrfPassword, setLastPrfPassword] = useState('');
+ const [removeDeviceId, setRemoveDeviceId] = useState('');
+ const [removeWalletPassphrase, setRemoveWalletPassphrase] = useState('');
+
+ // UI
+ const [logs, setLogs] = useState([]);
+ const [error, setError] = useState(null);
+ const [success, setSuccess] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ // Info panels
+ const [registeredPasskeys, setRegisteredPasskeys] = useState(
+ [],
+ );
+ const [wallets, setWallets] = useState([]);
+ const [loadingPasskeys, setLoadingPasskeys] = useState(false);
+ const [loadingWallets, setLoadingWallets] = useState(false);
+
+ const strategyRef = useRef(null);
+ const sdkRef = useRef(null);
+ const logAreaRef = useRef(null);
+
+ const log = useCallback((message: string, level: LogLevel = 'info') => {
+ setLogs((prev) => [...prev, { time: ts(), message, level }]);
+ }, []);
+
+ // Browser WebAuthn provider — defined inside component to have access to `log`.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const browserProvider = React.useMemo(
+ () => ({
+ create: async (options: any) => {
+ log('provider.create: normalising options…');
+ const normalised = normaliseCreateOptions(options);
+
+ log('provider.create: calling navigator.credentials.create…');
+ let cred: any;
+ try {
+ cred = await navigator.credentials.create({ publicKey: normalised });
+ } catch (domErr: any) {
+ log(
+ `provider.create ERROR: ${domErr?.name}: ${domErr?.message}`,
+ 'error',
+ );
+ throw domErr;
+ }
+ if (!cred) {
+ log(
+ 'provider.create: navigator returned null (user cancelled or no authenticator)',
+ 'error',
+ );
+ throw new Error('navigator.credentials.create returned null');
+ }
+ log(`provider.create: credential created, id=${cred.id}`);
+ return cred as any;
+ },
+ get: async ({
+ publicKey,
+ evalByCredential,
+ }: {
+ publicKey: any;
+ evalByCredential?: Record;
+ }) => {
+ log('provider.get: normalising options…');
+ const normalisedGet = normaliseGetOptions(publicKey, evalByCredential);
+ // Challenge is required by the browser; attachPasskeyToWallet doesn't provide one,
+ // so we generate a random one when absent. The assertion result isn't sent to the
+ // server in that flow, so replay protection is not required here.
+ if (!normalisedGet.challenge) {
+ normalisedGet.challenge = crypto.getRandomValues(
+ new Uint8Array(32),
+ ).buffer;
+ log('provider.get: generated random challenge (none provided)');
+ }
+ // rpId must match current hostname when testing on localhost
+ normalisedGet.rpId = window.location.hostname;
+ log(
+ `provider.get: rpId=${normalisedGet.rpId}, allowCredentials=${
+ normalisedGet.allowCredentials?.length ?? 0
+ }`,
+ );
+ log('provider.get: calling navigator.credentials.get…');
+ let cred: any;
+ try {
+ cred = await navigator.credentials.get({
+ publicKey: normalisedGet,
+ });
+ } catch (domErr: any) {
+ log(
+ `provider.get ERROR: ${domErr?.name}: ${domErr?.message}`,
+ 'error',
+ );
+ throw domErr;
+ }
+ if (!cred) {
+ log('provider.get: navigator returned null', 'error');
+ throw new Error('navigator.credentials.get returned null');
+ }
+ const ext = (cred as any).getClientExtensionResults() as {
+ prf?: { results?: { first?: ArrayBuffer } };
+ };
+ // Encode assertion signature as base64url for otpCode — the manual test
+ // does the same; some server flows may need it for verification.
+ let otpCode = '';
+ const sigBuf = cred.response?.signature;
+ if (sigBuf) {
+ const bytes = new Uint8Array(
+ sigBuf instanceof ArrayBuffer ? sigBuf : sigBuf.buffer ?? sigBuf,
+ );
+ otpCode = btoa(String.fromCharCode(...bytes))
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ }
+ return {
+ prfResult: ext.prf?.results?.first,
+ credentialId: cred.id,
+ otpCode,
+ };
+ },
+ }),
+ [log],
+ );
+
+ useEffect(() => {
+ if (logAreaRef.current) {
+ logAreaRef.current.scrollTop = logAreaRef.current.scrollHeight;
+ }
+ }, [logs]);
+
+ const clearStatus = () => {
+ setError(null);
+ setSuccess(null);
+ };
+
+ // --- Device lookup helper ---
+ function findDeviceById(id: string): WebAuthnOtpDevice | null {
+ const d = registeredPasskeys.find((p) => p.id === id);
+ if (!d) return null;
+ return {
+ id: d.id,
+ credentialId: d.credentialId ?? '',
+ prfSalt: d.prfSalt,
+ isPasskey: d.isPasskey,
+ extensions: d.extensions,
+ };
+ }
+
+ // --- Fetch registered passkeys via V2 endpoint ---
+ const fetchPasskeys = useCallback(async () => {
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+ setLoadingPasskeys(true);
+ try {
+ const me = (await sdk.get(sdk.url('/user/me', 2)).result()) as any;
+ const allDevices = me?.otpDevices ?? me?.user?.otpDevices ?? [];
+ // Show WebAuthn devices (passkeys and those with PRF support)
+ const devices: OtpDeviceInfo[] = allDevices.filter(
+ (d: any) => d.type === 'webauthn' || d.isPasskey || d.extensions?.prf,
+ );
+ setRegisteredPasskeys(devices);
+ } catch (e: any) {
+ log(`Failed to fetch passkeys: ${e.message || e}`, 'error');
+ } finally {
+ setLoadingPasskeys(false);
+ }
+ }, [log]);
+
+ // --- Fetch wallets for current coin ---
+ const fetchWallets = useCallback(async () => {
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+ setLoadingWallets(true);
+ try {
+ await coinFactory.getCoin(coin, sdk);
+ const resp = (await (sdk.coin(coin).wallets() as any).list({
+ limit: 25,
+ })) as any;
+ const rawList = (resp?.wallets ?? []).map((w: any) => {
+ const data = w._wallet ?? w;
+ return {
+ id: typeof w.id === 'function' ? w.id() : data.id,
+ label: data.label ?? '',
+ type: data.type ?? w.type?.() ?? 'hot',
+ coin: data.coin ?? w.coin?.() ?? coin,
+ keys: data.keys ?? [],
+ webauthnDevices: [] as { otpDeviceId: string; prfSalt?: string }[],
+ };
+ });
+ // webauthnDevices lives on the user keychain, not the wallet object.
+ // Fetch keychains in parallel to get passkey attachment info.
+ const list: WalletInfo[] = await Promise.all(
+ rawList.map(async (w: any) => {
+ const keychainId = w.keys?.[0];
+ if (keychainId) {
+ try {
+ const keychain = (await sdk
+ .get(sdk.url(`/${coin}/key/${keychainId}`, 2))
+ .result()) as any;
+ w.webauthnDevices =
+ keychain?.webauthnDevices ?? keychain?.webAuthnDevices ?? [];
+ } catch {
+ // keychain fetch failed — leave empty
+ }
+ }
+ return w as WalletInfo;
+ }),
+ );
+ setWallets(list);
+ } catch (e: any) {
+ log(`Failed to fetch wallets: ${e.message || e}`, 'error');
+ } finally {
+ setLoadingWallets(false);
+ }
+ }, [coin, log]);
+
+ // Auto-refresh panels whenever SDK becomes ready
+ useEffect(() => {
+ if (sdkReady) {
+ fetchPasskeys();
+ fetchWallets();
+ }
+ }, [sdkReady, fetchPasskeys, fetchWallets]);
+
+ // --- Login ---
+ const handleLogin = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ try {
+ log(`Creating WebCryptoHmacStrategy with IndexedDbTokenStore...`);
+ const strategy = new WebCryptoHmacStrategy({
+ tokenStore: new IndexedDbTokenStore(),
+ authVersion: 2,
+ });
+
+ const options: BitGoAPIOptions = {
+ env,
+ hmacAuthStrategy: strategy,
+ hmacVerification: true,
+ authVersion: 2,
+ };
+
+ log(`Creating BitGoAPI (env=${env})...`);
+ const sdk = new BitGoAPI(options);
+
+ log(`Authenticating as ${email}...`);
+ const response = await sdk.authenticate({
+ username: email,
+ password,
+ otp,
+ });
+
+ const token = response?.access_token;
+ if (token) {
+ log('Importing access_token into WebCrypto strategy...');
+ await strategy.setToken(token);
+ log('Auth token set. HMAC signing ready.');
+ }
+
+ log('Unlocking session...');
+ await sdk.unlock({ otp });
+ log('Session unlocked.', 'success');
+
+ // Register the selected coin so sdk.coin(coin) works
+ await coinFactory.getCoin(coin, sdk);
+
+ strategyRef.current = strategy;
+ sdkRef.current = sdk;
+ setSdkReady(true);
+ log(`Logged in. Coin "${coin}" registered.`, 'success');
+ setSuccess('Logged in and SDK ready.');
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Login error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [env, email, password, otp, coin, log]);
+
+ // --- registerPasskey ---
+ const handleRegisterPasskey = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log('Registering passkey...');
+ const device = await registerPasskey({
+ bitgo: sdk as any,
+ provider: browserProvider as any,
+ label: passkeyLabel || 'Demo Passkey',
+ });
+ log(`Passkey registered. Device ID: ${device.id}`, 'success');
+ log(`Credential ID: ${device.credentialId}`);
+ log(`PRF supported: ${device.prfSupported}`);
+ if (device.prfSalt) log(`PRF salt: ${device.prfSalt}`);
+ setSuccess('Passkey registered successfully.');
+ await fetchPasskeys();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [passkeyLabel, log, fetchPasskeys, browserProvider]);
+
+ // --- Create Wallet ---
+ const handleCreateWallet = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log(
+ `Creating ${coin} wallet "${walletLabel || 'Passkey Demo Wallet'}"...`,
+ );
+ const result = await sdk
+ .coin(coin)
+ .wallets()
+ .generateWallet({
+ label: walletLabel || 'Passkey Demo Wallet',
+ passphrase: walletPassphrase,
+ enterprise: enterpriseId,
+ multisigType: 'tss',
+ walletVersion: 5,
+ });
+ const wId = result.wallet.id();
+ setWalletId(wId);
+ log(`Wallet created. ID: ${wId}`, 'success');
+ setSuccess(`Wallet created: ${wId}`);
+ await fetchWallets();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletLabel, walletPassphrase, enterpriseId, log, fetchWallets]);
+
+ // --- attachPasskeyToWallet ---
+ const handleAttachPasskey = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ const device = findDeviceById(attachDeviceId);
+ if (!device) {
+ setError(
+ 'No device found for the selected Device ID. Select a passkey from the panel.',
+ );
+ setBusy(false);
+ return;
+ }
+
+ try {
+ log(`Attaching passkey ${attachDeviceId} to wallet ${walletId}...`);
+ const keychain = await attachPasskeyToWallet({
+ bitgo: sdk as any,
+ coin,
+ walletId,
+ device,
+ existingPassphrase: attachPassphrase,
+ provider: browserProvider as any,
+ });
+ log('Passkey attached to wallet successfully.', 'success');
+ log(`Keychain ID: ${(keychain as any).id || 'N/A'}`);
+ setSuccess('Passkey attached to wallet.');
+ await fetchWallets();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [
+ coin,
+ walletId,
+ attachDeviceId,
+ attachPassphrase,
+ registeredPasskeys,
+ log,
+ fetchWallets,
+ browserProvider,
+ ]);
+
+ // --- derivePasskeyPrfKey ---
+ const handleDerivePrfKey = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log(`Deriving PRF key for wallet ${walletId}...`);
+ const wallet = await sdk.coin(coin).wallets().get({ id: walletId });
+ const prfPassword = await derivePasskeyPrfKey({
+ bitgo: sdk as any,
+ wallet,
+ provider: browserProvider as any,
+ });
+ setLastPrfPassword(prfPassword);
+ log(`PRF-derived password: ${prfPassword.slice(0, 16)}...`, 'success');
+ setSuccess(
+ 'PRF key derived. You can now sign transactions without a passphrase.',
+ );
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletId, log, browserProvider]);
+
+ // --- Send Funds ---
+ const handleSendFunds = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log(`Sending ${sendAmount} satoshis to ${recipientAddress}...`);
+ const wallet = await sdk.coin(coin).wallets().get({ id: walletId });
+ const result = await wallet.send({
+ address: recipientAddress,
+ amount: sendAmount,
+ walletPassphrase: lastPrfPassword,
+ });
+ const txId =
+ (result as any).txid || (result as any).transfer?.txid || 'N/A';
+ log(`Transaction sent. TxID: ${txId}`, 'success');
+ log(`Result: ${JSON.stringify(result, null, 2).slice(0, 500)}`);
+ setSuccess(`Funds sent. TxID: ${txId}`);
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletId, recipientAddress, sendAmount, lastPrfPassword, log]);
+
+ // --- removePasskeyFromWallet ---
+ const handleRemoveFromWallet = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ const device = findDeviceById(removeDeviceId);
+ if (!device) {
+ setError(
+ 'No device found for the selected Device ID. Select a passkey from the panel.',
+ );
+ setBusy(false);
+ return;
+ }
+
+ try {
+ log(`Removing passkey ${removeDeviceId} from wallet ${walletId}...`);
+ await removePasskeyFromWallet({
+ bitgo: sdk as any,
+ coin,
+ walletId,
+ device,
+ walletPassphrase: removeWalletPassphrase,
+ });
+ log('Passkey removed from wallet.', 'success');
+ setSuccess('Passkey removed from wallet.');
+ await fetchWallets();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [
+ coin,
+ walletId,
+ removeDeviceId,
+ removeWalletPassphrase,
+ registeredPasskeys,
+ log,
+ fetchWallets,
+ ]);
+
+ // --- removePasskeyFromAccount ---
+ const handleRemoveFromAccount = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ const device = findDeviceById(removeDeviceId);
+ if (!device) {
+ setError(
+ 'No device found for the selected Device ID. Select a passkey from the panel.',
+ );
+ setBusy(false);
+ return;
+ }
+
+ try {
+ log(`Removing passkey ${removeDeviceId} from account...`);
+ await removePasskeyFromAccount({
+ bitgo: sdk as any,
+ device,
+ });
+ log('Passkey removed from account.', 'success');
+ setSuccess('Passkey removed from account.');
+ await fetchPasskeys();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`, 'error');
+ } finally {
+ setBusy(false);
+ }
+ }, [removeDeviceId, registeredPasskeys, log, fetchPasskeys]);
+
+ const selectStyle = {
+ padding: '8px',
+ borderRadius: 4,
+ border: '1px solid #ccc',
+ width: '100%',
+ } as const;
+
+ // ---------------------------------------------------------------------------
+ // Render helpers
+ // ---------------------------------------------------------------------------
+
+ const renderPasskeyPanel = () => (
+
+
+ Registered Passkeys
+
+ {loadingPasskeys ? 'Loading…' : '↻ Refresh'}
+
+
+
+ WebAuthn devices currently registered on your account.
+
+
+ {registeredPasskeys.length === 0 ? (
+
+ {sdkReady ? 'No passkeys found.' : 'Initialize SDK to load.'}
+
+ ) : (
+ registeredPasskeys.map((d) => (
+
+ {d.label || '(unlabelled)'}
+ ID: {d.id}
+ {d.credentialId && (
+ Credential ID: {d.credentialId}
+ )}
+ {d.prfSalt && PRF Salt: {d.prfSalt} }
+
+
+ PRF: {d.extensions?.prf ? 'supported' : 'not supported'}
+
+
+
+ {
+ setAttachDeviceId(d.id);
+ log(`Selected passkey for attach: ${d.id}`);
+ }}
+ >
+ Select for Attach
+
+ {
+ setRemoveDeviceId(d.id);
+ log(`Selected passkey for remove: ${d.id}`);
+ }}
+ >
+ Select for Remove
+
+
+
+ ))
+ )}
+
+
+ );
+
+ const renderWalletPanel = () => (
+
+
+
+ Wallets & Passkey Attachments{' '}
+
+ ({coin})
+
+
+
+ {loadingWallets ? 'Loading…' : '↻ Refresh'}
+
+
+
+ Wallets on your account for the current coin. Scroll to see more.
+
+
+ {wallets.length === 0 ? (
+
+ {sdkReady ? 'No wallets found.' : 'Initialize SDK to load.'}
+
+ ) : (
+ wallets.map((w) => {
+ const attachedDevices = w.webauthnDevices ?? [];
+ return (
+
+ {w.label || '(unlabelled)'}
+ Wallet ID: {w.id}
+
+
{`Type: ${w.type}`}
+
{`Coin: ${w.coin}`}
+
+ {attachedDevices.length > 0 ? (
+
+
+ Passkey attached ({attachedDevices.length})
+
+ {attachedDevices.map((dev, i) => (
+
+ OTP Device ID: {dev.otpDeviceId}
+ {dev.prfSalt && (
+ <>
+
+ PRF Salt: {dev.prfSalt}
+ >
+ )}
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {
+ setWalletId(w.id);
+ log(`Selected wallet: ${w.id} (${w.label})`);
+ }}
+ >
+ Select
+
+
+
+ );
+ })
+ )}
+
+
+ );
+
+ // ---------------------------------------------------------------------------
+ // Main render
+ // ---------------------------------------------------------------------------
+
+ return (
+
+ Passkey Demo
+
+ End-to-end passkey lifecycle: register, attach to wallet, derive PRF
+ key, and clean up. Requires HTTPS or localhost and a PRF-capable
+ authenticator.
+
+
+
+
+ {/* Login */}
+
+
+ {/* Config */}
+
+
+ {/* registerPasskey */}
+
+
+ {/* Create Wallet */}
+
+
+ {/* attachPasskeyToWallet */}
+
+
+ {/* Send Funds */}
+
+
+ {/* derivePasskeyPrfKey */}
+
+ derivePasskeyPrfKey
+ {walletId && (
+
+ Wallet: {walletId}
+
+ )}
+
+ Derives a wallet passphrase from the passkey — no password needed.
+
+
+ Derive PRF Key
+
+
+
+ {/* removePasskeyFromWallet */}
+
+
+ {/* removePasskeyFromAccount */}
+
+
+ {error && {error} }
+ {success && {success} }
+
+
+
+ {/* Activity Log */}
+
+ Activity Log
+
+ {logs.length === 0
+ ? 'Waiting for actions...'
+ : logs.map((entry, i) => (
+
+ [{entry.time}] {entry.message}
+
+ ))}
+
+
+
+ {/* Registered Passkeys panel */}
+ {renderPasskeyPanel()}
+
+ {/* Wallets & Passkey Attachments panel */}
+ {renderWalletPanel()}
+
+
+
+ );
+};
+
+export default PasskeyDemo;
diff --git a/modules/web-demo/src/components/PasskeyDemo/styles.tsx b/modules/web-demo/src/components/PasskeyDemo/styles.tsx
new file mode 100644
index 0000000000..6b39d86a5a
--- /dev/null
+++ b/modules/web-demo/src/components/PasskeyDemo/styles.tsx
@@ -0,0 +1,17 @@
+// Re-export all styled components from WebCryptoAuth styles
+export {
+ PageContainer,
+ TwoColumnLayout,
+ LeftColumn,
+ RightColumn,
+ Section,
+ SectionTitle,
+ FormGroup,
+ Label,
+ Input,
+ Button,
+ StatusBadge,
+ LogArea,
+ ErrorText,
+ SuccessText,
+} from '../WebCryptoAuth/styles';