From e56fe184ae44b85c68a22994b8cad164a05a6b79 Mon Sep 17 00:00:00 2001
From: Derran Wijesinghe
Date: Wed, 6 May 2026 17:00:13 -0400
Subject: [PATCH 1/2] feat(web-demo): add PasskeyDemo component with passkey
lifecycle UI
- Register, attach, derive PRF key, send funds, remove passkey flows
- Right-column panels: registered passkeys + associated wallets with refresh
- Quick-action buttons to select passkey/wallet for attach/remove steps
- Config fields (env, enterpriseId, coin) persisted to localStorage
- Coin registered via coinFactory before SDK use
- Staging environment option added
TICKET: WCN-188
---
.../passkey-crypto/src/derivePasskeyPrfKey.ts | 10 +-
modules/web-demo/package.json | 1 +
modules/web-demo/src/App.tsx | 2 +
.../web-demo/src/components/Navbar/index.tsx | 6 +
.../src/components/PasskeyDemo/index.tsx | 920 ++++++++++++++++++
.../src/components/PasskeyDemo/styles.tsx | 17 +
6 files changed, 955 insertions(+), 1 deletion(-)
create mode 100644 modules/web-demo/src/components/PasskeyDemo/index.tsx
create mode 100644 modules/web-demo/src/components/PasskeyDemo/styles.tsx
diff --git a/modules/passkey-crypto/src/derivePasskeyPrfKey.ts b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
index 6d3580f3e0..52e5b0a640 100644
--- a/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
+++ b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
@@ -23,7 +23,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');
@@ -41,10 +41,18 @@ export async function derivePasskeyPrfKey(params: {
.get(bitgo.url('/user/otp/webauthn/assertion', 2))
.result()) as AssertionChallengeResponse;
+ // Build allowCredentials from the evalByCredential map so the browser
+ // knows which credentials are valid for the PRF extension.
+ const allowCredentials = Object.keys(evalByCredential).map((credId) => ({
+ type: 'public-key' as const,
+ id: Buffer.from(credId.replace(/-/g, '+').replace(/_/g, '/'), 'base64').buffer,
+ }));
+
// 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/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..5036a06717
--- /dev/null
+++ b/modules/web-demo/src/components/PasskeyDemo/index.tsx
@@ -0,0 +1,920 @@
+import React, { useState, useCallback, useRef, useEffect } from 'react';
+import { BitGoAPI } from '@bitgo/sdk-api';
+import type { EnvironmentName } from '@bitgo/sdk-core';
+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 LogEntry = { time: string; message: string };
+
+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 });
+}
+
+/**
+ * Browser WebAuthn provider that wraps navigator.credentials for use with
+ * @bitgo/passkey-crypto functions.
+ */
+const browserProvider = {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ create: async (options: any) => {
+ const cred = await navigator.credentials.create({ publicKey: options });
+ return cred as any;
+ },
+ get: async ({
+ publicKey,
+ evalByCredential,
+ }: {
+ publicKey: any;
+ evalByCredential?: Record;
+ }) => {
+ const cred = (await navigator.credentials.get({
+ publicKey: {
+ ...publicKey,
+ extensions: { prf: { evalByCredential } } as any,
+ },
+ })) as any;
+ const ext = (cred as any).getClientExtensionResults() as {
+ prf?: { results?: { first?: ArrayBuffer } };
+ };
+ return {
+ prfResult: ext.prf?.results?.first,
+ credentialId: cred.id,
+ otpCode: '',
+ };
+ },
+};
+
+// ---------------------------------------------------------------------------
+// Component
+// ---------------------------------------------------------------------------
+
+const PasskeyDemo = () => {
+ // Config — env/enterpriseId/coin persisted to localStorage (not the token)
+ const [accessToken, setAccessToken] = useState('');
+ 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]);
+
+ // Step state
+ const [sdkReady, setSdkReady] = useState(false);
+ const [passkeyRegistered, setPasskeyRegistered] = useState(false);
+ const [walletCreated, setWalletCreated] = useState(false);
+ const [passkeyAttached, setPasskeyAttached] = useState(false);
+ const [prfDerived, setPrfDerived] = useState(false);
+ const [fundsSent, setFundsSent] = useState(false);
+ const [removedFromWallet, setRemovedFromWallet] = useState(false);
+ const [removedFromAccount, setRemovedFromAccount] = useState(false);
+
+ // Data
+ const [lastDevice, setLastDevice] = useState<(WebAuthnOtpDevice & { prfSupported?: boolean }) | null>(null);
+ const [walletId, setWalletId] = useState('');
+ const [passphrase, setPassphrase] = useState('');
+ const [lastPrfPassword, setLastPrfPassword] = useState('');
+
+ // Step 1 inputs
+ const [passkeyLabel, setPasskeyLabel] = useState('');
+
+ // Step 2 inputs
+ const [walletLabel, setWalletLabel] = useState('');
+ const [walletPassphrase, setWalletPassphrase] = useState('');
+
+ // Step 5 inputs
+ const [recipientAddress, setRecipientAddress] = useState('');
+ const [sendAmount, setSendAmount] = 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 sdkRef = useRef(null);
+ const logAreaRef = useRef(null);
+
+ const log = useCallback((message: string) => {
+ setLogs((prev) => [...prev, { time: ts(), message }]);
+ }, []);
+
+ useEffect(() => {
+ if (logAreaRef.current) {
+ logAreaRef.current.scrollTop = logAreaRef.current.scrollHeight;
+ }
+ }, [logs]);
+
+ const clearStatus = () => {
+ setError(null);
+ setSuccess(null);
+ };
+
+ // --- Fetch registered passkeys from /user/me ---
+ const fetchPasskeys = useCallback(async () => {
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+ setLoadingPasskeys(true);
+ try {
+ const me = (await (sdk as any).me()) as any;
+ const devices: OtpDeviceInfo[] = (me?.otpDevices ?? me?.user?.otpDevices ?? []).filter(
+ (d: any) => d.isPasskey || d.extensions?.prf
+ );
+ setRegisteredPasskeys(devices);
+ } catch (e: any) {
+ log(`Failed to fetch passkeys: ${e.message || e}`);
+ } 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 list: WalletInfo[] = (resp?.wallets ?? []).map((w: any) => ({
+ id: w.id,
+ label: w.label,
+ type: w.type,
+ coin: w.coin,
+ webauthnDevices: w.webauthnDevices ?? w.webAuthnDevices ?? [],
+ }));
+ setWallets(list);
+ } catch (e: any) {
+ log(`Failed to fetch wallets: ${e.message || e}`);
+ } finally {
+ setLoadingWallets(false);
+ }
+ }, [coin, log]);
+
+ // Auto-refresh panels whenever SDK becomes ready
+ useEffect(() => {
+ if (sdkReady) {
+ fetchPasskeys();
+ fetchWallets();
+ }
+ }, [sdkReady, fetchPasskeys, fetchWallets]);
+
+ // --- Config: Initialize SDK ---
+ const handleInitSdk = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ try {
+ log(`Creating BitGoAPI (env=${env})...`);
+ const sdk = new BitGoAPI({ env, accessToken });
+ // Register the selected coin so sdk.coin(coin) works
+ await coinFactory.getCoin(coin, sdk);
+ sdkRef.current = sdk;
+ setSdkReady(true);
+ log(`SDK initialized. Coin "${coin}" registered.`);
+ setSuccess('SDK ready. Proceed to Step 1.');
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [env, accessToken, coin, log]);
+
+ // --- Step 1: Register Passkey ---
+ const handleRegisterPasskey = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log('Step 1: Registering passkey...');
+ const device = await registerPasskey({
+ bitgo: sdk as any,
+ provider: browserProvider as any,
+ label: passkeyLabel || 'Demo Passkey',
+ });
+ setLastDevice(device);
+ setPasskeyRegistered(true);
+ log(`Passkey registered. Device ID: ${device.id}`);
+ 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}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [passkeyLabel, log, fetchPasskeys]);
+
+ // --- Step 2: Create Wallet ---
+ const handleCreateWallet = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log(`Step 2: Creating ${coin} wallet "${walletLabel}"...`);
+ 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);
+ setPassphrase(walletPassphrase);
+ setWalletCreated(true);
+ log(`Wallet created. ID: ${wId}`);
+ setSuccess(`Wallet created: ${wId}`);
+ await fetchWallets();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletLabel, walletPassphrase, enterpriseId, log, fetchWallets]);
+
+ // --- Step 3: Attach Passkey to Wallet ---
+ const handleAttachPasskey = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk || !lastDevice) return;
+
+ try {
+ log('Step 3: Attaching passkey to wallet...');
+ const keychain = await attachPasskeyToWallet({
+ bitgo: sdk as any,
+ coin,
+ walletId,
+ device: lastDevice,
+ existingPassphrase: passphrase,
+ provider: browserProvider as any,
+ });
+ setPasskeyAttached(true);
+ log('Passkey attached to wallet successfully.');
+ 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}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletId, lastDevice, passphrase, log, fetchWallets]);
+
+ // --- Step 4: Derive PRF Key ---
+ const handleDerivePrfKey = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log('Step 4: Deriving PRF key (wallet passphrase from passkey)...');
+ 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);
+ setPrfDerived(true);
+ log(`PRF-derived password: ${prfPassword.slice(0, 16)}...`);
+ 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}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletId, log]);
+
+ // --- Step 5: Send Funds ---
+ const handleSendFunds = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk) return;
+
+ try {
+ log(`Step 5: 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,
+ });
+ setFundsSent(true);
+ const txId = (result as any).txid || (result as any).transfer?.txid || 'N/A';
+ log(`Transaction sent. TxID: ${txId}`);
+ 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}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletId, recipientAddress, sendAmount, lastPrfPassword, log]);
+
+ // --- Step 6: Remove Passkey from Wallet ---
+ const handleRemoveFromWallet = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk || !lastDevice) return;
+
+ try {
+ log('Step 6: Removing passkey from wallet...');
+ await removePasskeyFromWallet({
+ bitgo: sdk as any,
+ coin,
+ walletId,
+ device: lastDevice,
+ walletPassphrase: lastPrfPassword,
+ });
+ setRemovedFromWallet(true);
+ log('Passkey removed from wallet.');
+ setSuccess('Passkey removed from wallet.');
+ await fetchWallets();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [coin, walletId, lastDevice, lastPrfPassword, log, fetchWallets]);
+
+ // --- Step 7: Remove Passkey from Account ---
+ const handleRemoveFromAccount = useCallback(async () => {
+ clearStatus();
+ setBusy(true);
+ const sdk = sdkRef.current;
+ if (!sdk || !lastDevice) return;
+
+ try {
+ log('Step 7: Removing passkey from account...');
+ await removePasskeyFromAccount({
+ bitgo: sdk as any,
+ device: lastDevice,
+ });
+ setRemovedFromAccount(true);
+ log('Passkey removed from account.');
+ setSuccess('Passkey removed from account. Full lifecycle complete.');
+ await fetchPasskeys();
+ } catch (e: any) {
+ setError(e.message || String(e));
+ log(`Error: ${e.message || e}`);
+ } finally {
+ setBusy(false);
+ }
+ }, [lastDevice, 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'}
+
+
+
+ {
+ setLastDevice({
+ id: d.id,
+ credentialId: d.credentialId ?? '',
+ prfSalt: d.prfSalt,
+ isPasskey: d.isPasskey,
+ extensions: d.extensions,
+ prfSupported: !!d.extensions?.prf,
+ });
+ log(`Selected passkey for attach: ${d.id}`);
+ }}
+ >
+ Use for Attach
+
+ {
+ setLastDevice({
+ id: d.id,
+ credentialId: d.credentialId ?? '',
+ prfSalt: d.prfSalt,
+ isPasskey: d.isPasskey,
+ extensions: d.extensions,
+ prfSupported: !!d.extensions?.prf,
+ });
+ log(`Selected passkey for removal: ${d.id}`);
+ }}
+ >
+ Use for Remove
+
+
+
+ ))
+ )}
+
+
+ );
+
+ const renderWalletPanel = () => (
+
+
+
+ Associated Wallets{' '}
+ ({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);
+ setWalletCreated(true);
+ log(`Selected wallet for attach: ${w.id} (${w.label})`);
+ }}
+ >
+ Choose for Attach
+
+ {
+ setWalletId(w.id);
+ log(`Selected wallet for removal: ${w.id} (${w.label})`);
+ }}
+ >
+ Choose for Remove
+
+
+
+ );
+ })
+ )}
+
+
+ );
+
+ // ---------------------------------------------------------------------------
+ // Main render
+ // ---------------------------------------------------------------------------
+
+ return (
+
+ Passkey Demo
+
+ End-to-end passkey lifecycle: register, attach to wallet, derive PRF key,
+ send funds, and clean up. Requires HTTPS or localhost and a
+ PRF-capable authenticator.
+
+
+
+
+ {/* Config */}
+
+
+ {/* Step 1: Register Passkey */}
+
+
+ {/* Step 2: Create Wallet */}
+
+
+ {/* Step 3: Attach Passkey to Wallet */}
+
+
+ Step 3: Attach Passkey to Wallet{' '}
+
+ {passkeyAttached ? 'Done' : 'Pending'}
+
+
+
+ Passphrase auto-filled from Step 2. Or pick an existing wallet from the panel →
+
+ {walletId && (
+
+ Wallet: {walletId}
+
+ )}
+
+ Attach Passkey
+
+
+
+ {/* Step 4: Derive PRF Key */}
+
+
+ Step 4: Derive PRF Key{' '}
+
+ {prfDerived ? 'Done' : 'Pending'}
+
+
+
+ Derives a wallet passphrase from the passkey — no password needed.
+
+
+ Derive PRF Key
+
+
+
+ {/* Step 5: Send Funds */}
+
+
+ {/* Step 6: Remove Passkey from Wallet */}
+
+
+ Step 6: Remove from Wallet{' '}
+
+ {removedFromWallet ? 'Done' : 'Pending'}
+
+
+ {walletId && (
+
+ Wallet: {walletId}
+
+ )}
+
+ Remove Passkey from Wallet
+
+
+
+ {/* Step 7: Remove Passkey from Account */}
+
+
+ Step 7: Remove from Account{' '}
+
+ {removedFromAccount ? 'Done' : 'Pending'}
+
+
+
+ Must remove from all wallets first.
+
+
+ Remove Passkey from Account
+
+
+
+ {error && {error} }
+ {success && {success} }
+
+
+
+ {/* Activity Log */}
+
+ Activity Log
+
+ {logs.length === 0
+ ? 'Waiting for actions...'
+ : logs.map((entry) => `[${entry.time}] ${entry.message}`).join('\n')}
+
+
+
+ {/* Registered Passkeys panel */}
+ {renderPasskeyPanel()}
+
+ {/* Associated Wallets 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';
From 4b30c682abf4a0c7878081457d6bd8f4fc9ca5bb Mon Sep 17 00:00:00 2001
From: Derran Wijesinghe
Date: Thu, 7 May 2026 13:46:27 -0400
Subject: [PATCH 2/2] feat(web-demo): add login flow and rework PasskeyDemo UI
Replace access-token-based initialization with a proper login flow
using WebCryptoHmacStrategy + IndexedDbTokenStore for browser-compatible
HMAC auth. Rework the UI to map 1:1 to passkey-crypto SDK functions
with explicit inputs, split passkey selection buttons, colored activity
log, and wallet keychain fetching for webauthnDevices display.
TICKET: WCN-188
---
.../passkey-crypto/src/derivePasskeyPrfKey.ts | 17 +-
.../test/unit/derivePasskeyPrfKey.test.ts | 2 +-
.../src/components/PasskeyDemo/index.tsx | 982 ++++++++++++------
3 files changed, 703 insertions(+), 298 deletions(-)
diff --git a/modules/passkey-crypto/src/derivePasskeyPrfKey.ts b/modules/passkey-crypto/src/derivePasskeyPrfKey.ts
index 52e5b0a640..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;
}
/**
@@ -36,16 +38,15 @@ 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 from the evalByCredential map so the browser
- // knows which credentials are valid for the PRF extension.
+ // 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').buffer,
+ id: Buffer.from(credId.replace(/-/g, '+').replace(/_/g, '/'), 'base64') as unknown as ArrayBuffer,
}));
// Trigger WebAuthn assertion with PRF evaluation via the provider (navigator layer)
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/src/components/PasskeyDemo/index.tsx b/modules/web-demo/src/components/PasskeyDemo/index.tsx
index 5036a06717..399494bd43 100644
--- a/modules/web-demo/src/components/PasskeyDemo/index.tsx
+++ b/modules/web-demo/src/components/PasskeyDemo/index.tsx
@@ -1,6 +1,11 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
-import { BitGoAPI } from '@bitgo/sdk-api';
+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,
@@ -69,7 +74,12 @@ const PanelTag = styled.span<{ colour?: string }>`
font-size: 11px;
font-weight: 600;
background: ${({ colour }) => colour || '#e2e8f0'};
- color: ${({ colour }) => (colour === '#c6f6d5' ? '#276749' : colour === '#fed7d7' ? '#9b2c2c' : '#4a5568')};
+ color: ${({ colour }) =>
+ colour === '#c6f6d5'
+ ? '#276749'
+ : colour === '#fed7d7'
+ ? '#9b2c2c'
+ : '#4a5568'};
margin-right: 4px;
`;
@@ -115,7 +125,8 @@ const EmptyState = styled.div`
// Types
// ---------------------------------------------------------------------------
-type LogEntry = { time: string; message: string };
+type LogLevel = 'info' | 'success' | 'error';
+type LogEntry = { time: string; message: string; level: LogLevel };
interface OtpDeviceInfo {
id: string;
@@ -143,87 +154,130 @@ function ts(): string {
}
/**
- * Browser WebAuthn provider that wraps navigator.credentials for use with
- * @bitgo/passkey-crypto functions.
+ * Converts any binary value (polyfilled Buffer, TypedArray, base64url string)
+ * to a native ArrayBuffer so the browser WebAuthn API accepts it.
*/
-const browserProvider = {
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- create: async (options: any) => {
- const cred = await navigator.credentials.create({ publicKey: options });
- return cred as any;
- },
- get: async ({
- publicKey,
- evalByCredential,
- }: {
- publicKey: any;
- evalByCredential?: Record;
- }) => {
- const cred = (await navigator.credentials.get({
- publicKey: {
- ...publicKey,
- extensions: { prf: { evalByCredential } } as any,
- },
- })) as any;
- const ext = (cred as any).getClientExtensionResults() as {
- prf?: { results?: { first?: ArrayBuffer } };
- };
- return {
- prfResult: ext.prf?.results?.first,
- credentialId: cred.id,
- otpCode: '',
- };
- },
-};
+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 = () => {
- // Config — env/enterpriseId/coin persisted to localStorage (not the token)
- const [accessToken, setAccessToken] = useState('');
+ // 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'
+ () =>
+ (localStorage.getItem('passkey-demo:env') as EnvironmentName) || 'test',
);
const [enterpriseId, setEnterpriseId] = useState(
- () => localStorage.getItem('passkey-demo:enterpriseId') || ''
+ () => localStorage.getItem('passkey-demo:enterpriseId') || '',
);
const [coin, setCoin] = useState(
- () => localStorage.getItem('passkey-demo:coin') || 'tbtc'
+ () => 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]);
+ useEffect(() => {
+ localStorage.setItem('passkey-demo:env', env);
+ }, [env]);
+ useEffect(() => {
+ localStorage.setItem('passkey-demo:enterpriseId', enterpriseId);
+ }, [enterpriseId]);
+ useEffect(() => {
+ localStorage.setItem('passkey-demo:coin', coin);
+ }, [coin]);
- // Step state
+ // SDK state
const [sdkReady, setSdkReady] = useState(false);
- const [passkeyRegistered, setPasskeyRegistered] = useState(false);
- const [walletCreated, setWalletCreated] = useState(false);
- const [passkeyAttached, setPasskeyAttached] = useState(false);
- const [prfDerived, setPrfDerived] = useState(false);
- const [fundsSent, setFundsSent] = useState(false);
- const [removedFromWallet, setRemovedFromWallet] = useState(false);
- const [removedFromAccount, setRemovedFromAccount] = useState(false);
// Data
- const [lastDevice, setLastDevice] = useState<(WebAuthnOtpDevice & { prfSupported?: boolean }) | null>(null);
const [walletId, setWalletId] = useState('');
- const [passphrase, setPassphrase] = useState('');
- const [lastPrfPassword, setLastPrfPassword] = useState('');
- // Step 1 inputs
+ // Operation inputs
const [passkeyLabel, setPasskeyLabel] = useState('');
-
- // Step 2 inputs
const [walletLabel, setWalletLabel] = useState('');
const [walletPassphrase, setWalletPassphrase] = useState('');
-
- // Step 5 inputs
+ 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([]);
@@ -232,18 +286,118 @@ const PasskeyDemo = () => {
const [busy, setBusy] = useState(false);
// Info panels
- const [registeredPasskeys, setRegisteredPasskeys] = useState([]);
+ 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) => {
- setLogs((prev) => [...prev, { time: ts(), message }]);
+ 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;
@@ -255,19 +409,34 @@ const PasskeyDemo = () => {
setSuccess(null);
};
- // --- Fetch registered passkeys from /user/me ---
+ // --- 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 as any).me()) as any;
- const devices: OtpDeviceInfo[] = (me?.otpDevices ?? me?.user?.otpDevices ?? []).filter(
- (d: any) => d.isPasskey || d.extensions?.prf
+ 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}`);
+ log(`Failed to fetch passkeys: ${e.message || e}`, 'error');
} finally {
setLoadingPasskeys(false);
}
@@ -280,17 +449,42 @@ const PasskeyDemo = () => {
setLoadingWallets(true);
try {
await coinFactory.getCoin(coin, sdk);
- const resp = (await (sdk.coin(coin).wallets() as any).list({ limit: 25 })) as any;
- const list: WalletInfo[] = (resp?.wallets ?? []).map((w: any) => ({
- id: w.id,
- label: w.label,
- type: w.type,
- coin: w.coin,
- webauthnDevices: w.webauthnDevices ?? w.webAuthnDevices ?? [],
- }));
+ 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}`);
+ log(`Failed to fetch wallets: ${e.message || e}`, 'error');
} finally {
setLoadingWallets(false);
}
@@ -304,28 +498,62 @@ const PasskeyDemo = () => {
}
}, [sdkReady, fetchPasskeys, fetchWallets]);
- // --- Config: Initialize SDK ---
- const handleInitSdk = useCallback(async () => {
+ // --- 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({ env, accessToken });
+ 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(`SDK initialized. Coin "${coin}" registered.`);
- setSuccess('SDK ready. Proceed to Step 1.');
+ log(`Logged in. Coin "${coin}" registered.`, 'success');
+ setSuccess('Logged in and SDK ready.');
} catch (e: any) {
setError(e.message || String(e));
- log(`Error: ${e.message || e}`);
+ log(`Login error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
- }, [env, accessToken, coin, log]);
+ }, [env, email, password, otp, coin, log]);
- // --- Step 1: Register Passkey ---
+ // --- registerPasskey ---
const handleRegisterPasskey = useCallback(async () => {
clearStatus();
setBusy(true);
@@ -333,15 +561,13 @@ const PasskeyDemo = () => {
if (!sdk) return;
try {
- log('Step 1: Registering passkey...');
+ log('Registering passkey...');
const device = await registerPasskey({
bitgo: sdk as any,
provider: browserProvider as any,
label: passkeyLabel || 'Demo Passkey',
});
- setLastDevice(device);
- setPasskeyRegistered(true);
- log(`Passkey registered. Device ID: ${device.id}`);
+ 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}`);
@@ -349,13 +575,13 @@ const PasskeyDemo = () => {
await fetchPasskeys();
} catch (e: any) {
setError(e.message || String(e));
- log(`Error: ${e.message || e}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
- }, [passkeyLabel, log, fetchPasskeys]);
+ }, [passkeyLabel, log, fetchPasskeys, browserProvider]);
- // --- Step 2: Create Wallet ---
+ // --- Create Wallet ---
const handleCreateWallet = useCallback(async () => {
clearStatus();
setBusy(true);
@@ -363,60 +589,80 @@ const PasskeyDemo = () => {
if (!sdk) return;
try {
- log(`Step 2: Creating ${coin} wallet "${walletLabel}"...`);
- const result = await sdk.coin(coin).wallets().generateWallet({
- label: walletLabel || 'Passkey Demo Wallet',
- passphrase: walletPassphrase,
- enterprise: enterpriseId,
- multisigType: 'tss',
- walletVersion: 5,
- });
+ 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);
- setPassphrase(walletPassphrase);
- setWalletCreated(true);
- log(`Wallet created. ID: ${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}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
}, [coin, walletLabel, walletPassphrase, enterpriseId, log, fetchWallets]);
- // --- Step 3: Attach Passkey to Wallet ---
+ // --- attachPasskeyToWallet ---
const handleAttachPasskey = useCallback(async () => {
clearStatus();
setBusy(true);
const sdk = sdkRef.current;
- if (!sdk || !lastDevice) return;
+ 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('Step 3: Attaching passkey to wallet...');
+ log(`Attaching passkey ${attachDeviceId} to wallet ${walletId}...`);
const keychain = await attachPasskeyToWallet({
bitgo: sdk as any,
coin,
walletId,
- device: lastDevice,
- existingPassphrase: passphrase,
+ device,
+ existingPassphrase: attachPassphrase,
provider: browserProvider as any,
});
- setPasskeyAttached(true);
- log('Passkey attached to wallet successfully.');
+ 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}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
- }, [coin, walletId, lastDevice, passphrase, log, fetchWallets]);
-
- // --- Step 4: Derive PRF Key ---
+ }, [
+ coin,
+ walletId,
+ attachDeviceId,
+ attachPassphrase,
+ registeredPasskeys,
+ log,
+ fetchWallets,
+ browserProvider,
+ ]);
+
+ // --- derivePasskeyPrfKey ---
const handleDerivePrfKey = useCallback(async () => {
clearStatus();
setBusy(true);
@@ -424,7 +670,7 @@ const PasskeyDemo = () => {
if (!sdk) return;
try {
- log('Step 4: Deriving PRF key (wallet passphrase from passkey)...');
+ 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,
@@ -432,18 +678,19 @@ const PasskeyDemo = () => {
provider: browserProvider as any,
});
setLastPrfPassword(prfPassword);
- setPrfDerived(true);
- log(`PRF-derived password: ${prfPassword.slice(0, 16)}...`);
- setSuccess('PRF key derived. You can now sign transactions without a passphrase.');
+ 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}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
- }, [coin, walletId, log]);
+ }, [coin, walletId, log, browserProvider]);
- // --- Step 5: Send Funds ---
+ // --- Send Funds ---
const handleSendFunds = useCallback(async () => {
clearStatus();
setBusy(true);
@@ -451,78 +698,102 @@ const PasskeyDemo = () => {
if (!sdk) return;
try {
- log(`Step 5: Sending ${sendAmount} satoshis to ${recipientAddress}...`);
+ 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,
});
- setFundsSent(true);
- const txId = (result as any).txid || (result as any).transfer?.txid || 'N/A';
- log(`Transaction sent. TxID: ${txId}`);
+ 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}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
}, [coin, walletId, recipientAddress, sendAmount, lastPrfPassword, log]);
- // --- Step 6: Remove Passkey from Wallet ---
+ // --- removePasskeyFromWallet ---
const handleRemoveFromWallet = useCallback(async () => {
clearStatus();
setBusy(true);
const sdk = sdkRef.current;
- if (!sdk || !lastDevice) return;
+ 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('Step 6: Removing passkey from wallet...');
+ log(`Removing passkey ${removeDeviceId} from wallet ${walletId}...`);
await removePasskeyFromWallet({
bitgo: sdk as any,
coin,
walletId,
- device: lastDevice,
- walletPassphrase: lastPrfPassword,
+ device,
+ walletPassphrase: removeWalletPassphrase,
});
- setRemovedFromWallet(true);
- log('Passkey removed from wallet.');
+ 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}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
- }, [coin, walletId, lastDevice, lastPrfPassword, log, fetchWallets]);
-
- // --- Step 7: Remove Passkey from Account ---
+ }, [
+ coin,
+ walletId,
+ removeDeviceId,
+ removeWalletPassphrase,
+ registeredPasskeys,
+ log,
+ fetchWallets,
+ ]);
+
+ // --- removePasskeyFromAccount ---
const handleRemoveFromAccount = useCallback(async () => {
clearStatus();
setBusy(true);
const sdk = sdkRef.current;
- if (!sdk || !lastDevice) return;
+ 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('Step 7: Removing passkey from account...');
+ log(`Removing passkey ${removeDeviceId} from account...`);
await removePasskeyFromAccount({
bitgo: sdk as any,
- device: lastDevice,
+ device,
});
- setRemovedFromAccount(true);
- log('Passkey removed from account.');
- setSuccess('Passkey removed from account. Full lifecycle complete.');
+ 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}`);
+ log(`Error: ${e.message || e}`, 'error');
} finally {
setBusy(false);
}
- }, [lastDevice, log, fetchPasskeys]);
+ }, [removeDeviceId, registeredPasskeys, log, fetchPasskeys]);
const selectStyle = {
padding: '8px',
@@ -539,7 +810,10 @@ const PasskeyDemo = () => {
Registered Passkeys
-
+
{loadingPasskeys ? 'Loading…' : '↻ Refresh'}
@@ -548,13 +822,17 @@ const PasskeyDemo = () => {
{registeredPasskeys.length === 0 ? (
- {sdkReady ? 'No passkeys found.' : 'Initialize SDK to load.'}
+
+ {sdkReady ? 'No passkeys found.' : 'Initialize SDK to load.'}
+
) : (
registeredPasskeys.map((d) => (
{d.label || '(unlabelled)'}
ID: {d.id}
- {d.credentialId && Credential ID: {d.credentialId} }
+ {d.credentialId && (
+ Credential ID: {d.credentialId}
+ )}
{d.prfSalt && PRF Salt: {d.prfSalt} }
@@ -563,38 +841,24 @@ const PasskeyDemo = () => {
{
- setLastDevice({
- id: d.id,
- credentialId: d.credentialId ?? '',
- prfSalt: d.prfSalt,
- isPasskey: d.isPasskey,
- extensions: d.extensions,
- prfSupported: !!d.extensions?.prf,
- });
+ setAttachDeviceId(d.id);
log(`Selected passkey for attach: ${d.id}`);
}}
>
- Use for Attach
+ Select for Attach
{
- setLastDevice({
- id: d.id,
- credentialId: d.credentialId ?? '',
- prfSalt: d.prfSalt,
- isPasskey: d.isPasskey,
- extensions: d.extensions,
- prfSupported: !!d.extensions?.prf,
- });
- log(`Selected passkey for removal: ${d.id}`);
+ setRemoveDeviceId(d.id);
+ log(`Selected passkey for remove: ${d.id}`);
}}
>
- Use for Remove
+ Select for Remove
@@ -608,10 +872,15 @@ const PasskeyDemo = () => {
- Associated Wallets{' '}
- ({coin})
+ Wallets & Passkey Attachments{' '}
+
+ ({coin})
+
-
+
{loadingWallets ? 'Loading…' : '↻ Refresh'}
@@ -620,7 +889,9 @@ const PasskeyDemo = () => {
{wallets.length === 0 ? (
- {sdkReady ? 'No wallets found.' : 'Initialize SDK to load.'}
+
+ {sdkReady ? 'No wallets found.' : 'Initialize SDK to load.'}
+
) : (
wallets.map((w) => {
const attachedDevices = w.webauthnDevices ?? [];
@@ -634,11 +905,18 @@ const PasskeyDemo = () => {
{attachedDevices.length > 0 ? (
-
Passkey attached ({attachedDevices.length})
+
+ Passkey attached ({attachedDevices.length})
+
{attachedDevices.map((dev, i) => (
OTP Device ID: {dev.otpDeviceId}
- {dev.prfSalt && <> PRF Salt: {dev.prfSalt}>}
+ {dev.prfSalt && (
+ <>
+
+ PRF Salt: {dev.prfSalt}
+ >
+ )}
))}
@@ -649,25 +927,14 @@ const PasskeyDemo = () => {
)}
{
- setWalletId(w.id);
- setWalletCreated(true);
- log(`Selected wallet for attach: ${w.id} (${w.label})`);
- }}
- >
- Choose for Attach
-
- {
setWalletId(w.id);
- log(`Selected wallet for removal: ${w.id} (${w.label})`);
+ log(`Selected wallet: ${w.id} (${w.label})`);
}}
>
- Choose for Remove
+ Select
@@ -686,38 +953,77 @@ const PasskeyDemo = () => {
Passkey Demo
- End-to-end passkey lifecycle: register, attach to wallet, derive PRF key,
- send funds, and clean up. Requires HTTPS or localhost and a
- PRF-capable authenticator.
+ End-to-end passkey lifecycle: register, attach to wallet, derive PRF
+ key, and clean up. Requires HTTPS or localhost and a PRF-capable
+ authenticator.
- {/* Config */}
+ {/* Login */}
+
+ {/* Config */}
+
- {/* Step 1: Register Passkey */}
+ {/* registerPasskey */}
- {/* Step 2: Create Wallet */}
+ {/* Create Wallet */}
- {/* Step 3: Attach Passkey to Wallet */}
+ {/* attachPasskeyToWallet */}
- {/* Step 4: Derive PRF Key */}
-
-
- Step 4: Derive PRF Key{' '}
-
- {prfDerived ? 'Done' : 'Pending'}
-
-
-
- Derives a wallet passphrase from the passkey — no password needed.
-
-
- Derive PRF Key
-
-
-
- {/* Step 5: Send Funds */}
+ {/* Send Funds */}
- {/* Step 6: Remove Passkey from Wallet */}
+ {/* derivePasskeyPrfKey */}
-
- Step 6: Remove from Wallet{' '}
-
- {removedFromWallet ? 'Done' : 'Pending'}
-
-
+ derivePasskeyPrfKey
+ {walletId && (
+
+ Wallet: {walletId}
+
+ )}
+
+ Derives a wallet passphrase from the passkey — no password needed.
+
+
+ Derive PRF Key
+
+
+
+ {/* removePasskeyFromWallet */}
+
- {/* Step 7: Remove Passkey from Account */}
+ {/* removePasskeyFromAccount */}
@@ -902,14 +1292,28 @@ const PasskeyDemo = () => {
{logs.length === 0
? 'Waiting for actions...'
- : logs.map((entry) => `[${entry.time}] ${entry.message}`).join('\n')}
+ : logs.map((entry, i) => (
+
+ [{entry.time}] {entry.message}
+
+ ))}
{/* Registered Passkeys panel */}
{renderPasskeyPanel()}
- {/* Associated Wallets panel */}
+ {/* Wallets & Passkey Attachments panel */}
{renderWalletPanel()}