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

+ WebAuthn devices currently registered on your account. +

+ + {registeredPasskeys.length === 0 ? ( + + {sdkReady ? 'No passkeys found.' : 'Initialize SDK to load.'} + + ) : ( + registeredPasskeys.map((d) => ( + + {d.label || '(unlabelled)'} + ID: {d.id} + {d.credentialId && ( + Credential ID: {d.credentialId} + )} + {d.prfSalt && PRF Salt: {d.prfSalt}} +
+ + PRF: {d.extensions?.prf ? 'supported' : 'not supported'} + +
+ + { + setAttachDeviceId(d.id); + log(`Selected passkey for attach: ${d.id}`); + }} + > + Select for Attach + + { + setRemoveDeviceId(d.id); + log(`Selected passkey for remove: ${d.id}`); + }} + > + Select for Remove + + +
+ )) + )} +
+
+ ); + + const renderWalletPanel = () => ( +
+ + + Wallets & Passkey Attachments{' '} + + ({coin}) + + + + {loadingWallets ? 'Loading…' : '↻ Refresh'} + + +

+ Wallets on your account for the current coin. Scroll to see more. +

+ + {wallets.length === 0 ? ( + + {sdkReady ? 'No wallets found.' : 'Initialize SDK to load.'} + + ) : ( + wallets.map((w) => { + const attachedDevices = w.webauthnDevices ?? []; + return ( + + {w.label || '(unlabelled)'} + Wallet ID: {w.id} +
+ {`Type: ${w.type}`} + {`Coin: ${w.coin}`} +
+ {attachedDevices.length > 0 ? ( +
+ + Passkey attached ({attachedDevices.length}) + + {attachedDevices.map((dev, i) => ( + + OTP Device ID: {dev.otpDeviceId} + {dev.prfSalt && ( + <> +
+ PRF Salt: {dev.prfSalt} + + )} +
+ ))} +
+ ) : ( +
+ No passkey attached +
+ )} + + { + setWalletId(w.id); + log(`Selected wallet: ${w.id} (${w.label})`); + }} + > + Select + + +
+ ); + }) + )} +
+
+ ); + + // --------------------------------------------------------------------------- + // Main render + // --------------------------------------------------------------------------- + + return ( + +

Passkey Demo

+

+ End-to-end passkey lifecycle: register, attach to wallet, derive PRF + key, and clean up. Requires HTTPS or localhost and a PRF-capable + authenticator. +

+ + + + {/* Login */} +
+ + Login{' '} + + {sdkReady ? 'Logged In' : 'Not Logged In'} + + + + + + + + + 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 + + + setEnterpriseId(e.target.value)} + placeholder="Enterprise ID" + /> + + + + setCoin(e.target.value)} + placeholder="tbtc" + /> + +
+ + {/* registerPasskey */} +
+ registerPasskey + + + setPasskeyLabel(e.target.value)} + placeholder="My Passkey" + disabled={!sdkReady} + /> + + +
+ + {/* Create Wallet */} +
+ Create Wallet + + + setWalletLabel(e.target.value)} + placeholder="Passkey Demo Wallet" + disabled={!sdkReady} + /> + + + + setWalletPassphrase(e.target.value)} + placeholder="Strong passphrase" + disabled={!sdkReady} + /> + + +
+ + {/* attachPasskeyToWallet */} +
+ attachPasskeyToWallet + {walletId && ( +

+ Wallet: {walletId} +

+ )} + + + + + + + setAttachPassphrase(e.target.value)} + placeholder="Wallet passphrase" + disabled={!sdkReady} + /> + + +
+ + {/* Send Funds */} +
+ Send Funds + {walletId && ( +

+ Wallet: {walletId} +

+ )} + + + setRecipientAddress(e.target.value)} + placeholder="tb1q..." + disabled={!sdkReady} + /> + + + + setSendAmount(e.target.value)} + placeholder="10000" + disabled={!sdkReady} + /> + + +
+ + {/* derivePasskeyPrfKey */} +
+ derivePasskeyPrfKey + {walletId && ( +

+ Wallet: {walletId} +

+ )} +

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

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

+ Wallet: {walletId} +

+ )} + + + + + + + setRemoveWalletPassphrase(e.target.value)} + placeholder="Wallet passphrase" + disabled={!sdkReady} + /> + + +
+ + {/* removePasskeyFromAccount */} +
+ removePasskeyFromAccount + + + + + +
+ + {error && {error}} + {success && {success}} +
+ + + {/* Activity Log */} +
+ Activity Log + + {logs.length === 0 + ? 'Waiting for actions...' + : logs.map((entry, i) => ( +
+ [{entry.time}] {entry.message} +
+ ))} +
+
+ + {/* Registered Passkeys panel */} + {renderPasskeyPanel()} + + {/* Wallets & Passkey Attachments panel */} + {renderWalletPanel()} +
+
+
+ ); +}; + +export default PasskeyDemo; diff --git a/modules/web-demo/src/components/PasskeyDemo/styles.tsx b/modules/web-demo/src/components/PasskeyDemo/styles.tsx new file mode 100644 index 0000000000..6b39d86a5a --- /dev/null +++ b/modules/web-demo/src/components/PasskeyDemo/styles.tsx @@ -0,0 +1,17 @@ +// Re-export all styled components from WebCryptoAuth styles +export { + PageContainer, + TwoColumnLayout, + LeftColumn, + RightColumn, + Section, + SectionTitle, + FormGroup, + Label, + Input, + Button, + StatusBadge, + LogArea, + ErrorText, + SuccessText, +} from '../WebCryptoAuth/styles';