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}} +
+ ))} +
+ ) : ( +
+ No passkey attached +
+ )} + + { + 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 */} +
+ + Config{' '} + + {sdkReady ? 'SDK Ready' : 'Not Initialized'} + + + + + setAccessToken(e.target.value)} + placeholder="v2x..." + /> + + + + + + + + setEnterpriseId(e.target.value)} + placeholder="Enterprise ID" + /> + + + + setCoin(e.target.value)} placeholder="tbtc" /> + + +
+ + {/* Step 1: Register Passkey */} +
+ + Step 1: Register Passkey{' '} + + {passkeyRegistered ? 'Done' : 'Pending'} + + + + + setPasskeyLabel(e.target.value)} + placeholder="My Passkey" + disabled={!sdkReady} + /> + + +
+ + {/* Step 2: Create Wallet */} +
+ + Step 2: Create Wallet{' '} + + {walletCreated ? 'Done' : 'Pending'} + + + + + setWalletLabel(e.target.value)} + placeholder="Passkey Demo Wallet" + disabled={!passkeyRegistered} + /> + + + + setWalletPassphrase(e.target.value)} + placeholder="Strong passphrase" + disabled={!passkeyRegistered} + /> + + +
+ + {/* 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} +

+ )} + +
+ + {/* Step 4: Derive PRF Key */} +
+ + Step 4: Derive PRF Key{' '} + + {prfDerived ? 'Done' : 'Pending'} + + +

+ Derives a wallet passphrase from the passkey — no password needed. +

+ +
+ + {/* Step 5: Send Funds */} +
+ + Step 5: Send Funds{' '} + + {fundsSent ? 'Done' : 'Pending'} + + + + + setRecipientAddress(e.target.value)} + placeholder="tb1q..." + disabled={!prfDerived} + /> + + + + setSendAmount(e.target.value)} + placeholder="10000" + disabled={!prfDerived} + /> + + +
+ + {/* Step 6: Remove Passkey from Wallet */} +
+ + Step 6: Remove from Wallet{' '} + + {removedFromWallet ? 'Done' : 'Pending'} + + + {walletId && ( +

+ Wallet: {walletId} +

+ )} + +
+ + {/* Step 7: Remove Passkey from Account */} +
+ + Step 7: Remove from Account{' '} + + {removedFromAccount ? 'Done' : 'Pending'} + + +

+ Must remove from all wallets first. +

+ +
+ + {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{' '} + Login{' '} - {sdkReady ? 'SDK Ready' : 'Not Initialized'} + {sdkReady ? 'Logged In' : 'Not Logged In'} - - - setAccessToken(e.target.value)} - placeholder="v2x..." - /> - - setEnv(e.target.value as EnvironmentName)} + style={selectStyle} + disabled={sdkReady} + > + + + setEmail(e.target.value)} + placeholder="user@example.com" + disabled={sdkReady} + autoComplete="email" + /> + + + + setPassword(e.target.value)} + placeholder="Password" + disabled={sdkReady} + autoComplete="current-password" + /> + + + + setOtp(e.target.value)} + placeholder="000000" + disabled={sdkReady} + autoComplete="one-time-code" + /> + + +
+ + {/* Config */} +
+ Config { - setCoin(e.target.value)} placeholder="tbtc" /> + setCoin(e.target.value)} + placeholder="tbtc" + /> -
- {/* Step 1: Register Passkey */} + {/* registerPasskey */}
- - Step 1: Register Passkey{' '} - - {passkeyRegistered ? 'Done' : 'Pending'} - - + registerPasskey { disabled={!sdkReady} /> -
- {/* Step 2: Create Wallet */} + {/* Create Wallet */}
- - Step 2: Create Wallet{' '} - - {walletCreated ? 'Done' : 'Pending'} - - + Create Wallet setWalletLabel(e.target.value)} placeholder="Passkey Demo Wallet" - disabled={!passkeyRegistered} + disabled={!sdkReady} /> @@ -781,66 +1081,87 @@ const PasskeyDemo = () => { value={walletPassphrase} onChange={(e) => setWalletPassphrase(e.target.value)} placeholder="Strong passphrase" - disabled={!passkeyRegistered} + disabled={!sdkReady} /> -
- {/* Step 3: Attach Passkey to Wallet */} + {/* attachPasskeyToWallet */}
- - Step 3: Attach Passkey to Wallet{' '} - - {passkeyAttached ? 'Done' : 'Pending'} - - -

- Passphrase auto-filled from Step 2. Or pick an existing wallet from the panel → -

+ attachPasskeyToWallet {walletId && ( -

+

Wallet: {walletId}

)} -
- {/* Step 4: Derive PRF Key */} -
- - Step 4: Derive PRF Key{' '} - - {prfDerived ? 'Done' : 'Pending'} - - -

- Derives a wallet passphrase from the passkey — no password needed. -

- -
- - {/* Step 5: Send Funds */} + {/* Send Funds */}
- - Step 5: Send Funds{' '} - - {fundsSent ? 'Done' : 'Pending'} - - + Send Funds + {walletId && ( +

+ Wallet: {walletId} +

+ )} setRecipientAddress(e.target.value)} placeholder="tb1q..." - disabled={!prfDerived} + disabled={!sdkReady} /> @@ -849,44 +1170,113 @@ const PasskeyDemo = () => { value={sendAmount} onChange={(e) => setSendAmount(e.target.value)} placeholder="10000" - disabled={!prfDerived} + disabled={!sdkReady} /> -
- {/* 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. +

+ +
+ + {/* removePasskeyFromWallet */} +
+ removePasskeyFromWallet {walletId && ( -

+

Wallet: {walletId}

)} -
- {/* Step 7: Remove Passkey from Account */} + {/* removePasskeyFromAccount */}
- - Step 7: Remove from Account{' '} - - {removedFromAccount ? 'Done' : 'Pending'} - - -

- Must remove from all wallets first. -

-
@@ -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()}