diff --git a/src/utils/origins.ts b/src/utils/origins.ts index 216b30e..d6a12fd 100644 --- a/src/utils/origins.ts +++ b/src/utils/origins.ts @@ -1,5 +1,13 @@ import type { Env } from '../types'; +// Keep this list aligned with Bitwarden server's default FIDO2 origins. +// These are the stable store IDs for the official Chromium-based extensions. +export const OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS = [ + 'chrome-extension://nngceckbapebfimnlniiiahkandclblb', + 'chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh', + 'chrome-extension://ccnckbpmaceehanjmeomladnmlffdjgn', +] as const; + export function normalizeOrigin(value: unknown): string | null { const raw = String(value || '').trim(); if (!raw) return null; @@ -25,7 +33,7 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean { export function getConfiguredWebAuthnAllowedOrigins( env: Pick ): string[] { - const seen = new Set(); + const seen = new Set(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS); for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { const origin = normalizeOrigin(item); if (origin) seen.add(origin); diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index c0a1719..d7cc65f 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) { const [accountPasskeys, setAccountPasskeys] = useState([]); const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false); const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey')); - const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false); + const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true); const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState(null); const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false); const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState(''); @@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) { setCreatePasskeyDialogOpen(false); setCreatePasskeyMasterPassword(''); setAccountPasskeyName(t('txt_account_passkey')); - setAccountPasskeyDirectUnlock(false); + setAccountPasskeyDirectUnlock(true); } async function submitCreatePasskeyDialog(): Promise { diff --git a/webapp/src/lib/account-passkeys.ts b/webapp/src/lib/account-passkeys.ts index 0dc7709..c312b43 100644 --- a/webapp/src/lib/account-passkeys.ts +++ b/webapp/src/lib/account-passkeys.ts @@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential { deviceResponse: PublicKeyCredential; request: Record; supportsPrf: boolean; + prfKey?: Uint8Array; } export interface AccountPasskeyPrfKeySet { @@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise { return new Uint8Array(hash); } -function credentialIdToBase64Url(id: BufferSource): string | null { - try { - const bytes = id instanceof ArrayBuffer - ? new Uint8Array(id) - : new Uint8Array(id.buffer, id.byteOffset, id.byteLength); - return bytesToBase64Url(bytes); - } catch { - return null; - } -} - type PrfEvalInput = { first: Uint8Array }; -function buildLegacyPrfExtension(salt: Uint8Array): Record { +function buildPrfExtension(salt: Uint8Array): Record { const evalInput: PrfEvalInput = { first: salt }; return { prf: { @@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record { }; } -function buildCredentialPrfExtension( - salt: Uint8Array, - credentialIds: Array -): Record { - const evalInput = { first: salt }; - const evalByCredential = credentialIds - .filter((id): id is string => !!id) - .reduce>((out, id) => { - out[id] = evalInput; - return out; - }, {}); - if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt); - return { - prf: { - evalByCredential, - }, - }; -} - +function withPrfExtension( + options: PublicKeyCredentialCreationOptions, + salt: Uint8Array +): PublicKeyCredentialCreationOptions; function withPrfExtension( options: PublicKeyCredentialRequestOptions, - extension: Record -): PublicKeyCredentialRequestOptions { + salt: Uint8Array +): PublicKeyCredentialRequestOptions; +function withPrfExtension( + options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions, + salt: Uint8Array +): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions { return { ...options, extensions: { ...((options as any).extensions || {}), - ...extension, + ...buildPrfExtension(salt), } as any, }; } @@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde return result instanceof ArrayBuffer ? result : undefined; } -function hasPrfExtensionResult(credential: PublicKeyCredential): boolean { - return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf'); -} - -function shouldRetryWithLegacyPrf(error: unknown): boolean { - const name = error instanceof DOMException || error instanceof Error ? error.name : ''; - return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError'; -} - -function shouldRetryCreateWithoutPrf(error: unknown): boolean { - const name = error instanceof DOMException || error instanceof Error ? error.name : ''; - const message = error instanceof DOMException || error instanceof Error ? error.message : ''; - return ( - name === 'NotSupportedError' || - name === 'SyntaxError' || - name === 'TypeError' || - (name === 'UnknownError' && /transient/i.test(message)) - ); -} - -async function canRequestPrfExtension(): Promise { - if (/\bFirefox\//i.test(navigator.userAgent)) return false; - return true; -} - async function getPublicKeyCredentialWithPrf( options: PublicKeyCredentialRequestOptions, - salt: Uint8Array, - credentialIds: string[] = [] + salt: Uint8Array ): Promise { - const attempts = credentialIds.length - ? [ - buildCredentialPrfExtension(salt, credentialIds), - buildLegacyPrfExtension(salt), - ] - : [buildLegacyPrfExtension(salt)]; - let lastCredential: PublicKeyCredential | null = null; - for (let index = 0; index < attempts.length; index += 1) { - try { - const credential = await navigator.credentials.get({ - publicKey: withPrfExtension(options, attempts[index]), - }); - if (!(credential instanceof PublicKeyCredential)) { - throw new Error(t('txt_no_passkey_selected')); - } - lastCredential = credential; - if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) { - return credential; - } - } catch (error) { - if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) { - if (lastCredential) return lastCredential; - throw error; - } - } + const credential = await navigator.credentials.get({ + publicKey: withPrfExtension(options, salt), + }); + if (!(credential instanceof PublicKeyCredential)) { + throw new Error(t('txt_no_passkey_selected')); } - if (lastCredential) return lastCredential; - throw new Error(t('txt_no_passkey_selected')); -} - -function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] { - return (options.allowCredentials || []) - .map((credential) => credentialIdToBase64Url(credential.id)) - .filter((id): id is string => !!id); + return credential; } async function prfOutputToKey(prfOutput: ArrayBuffer): Promise { @@ -282,8 +208,7 @@ export async function assertAccountPasskey( const nativeOptions = cloneRequestOptions(response.options); const credential = await getPublicKeyCredentialWithPrf( nativeOptions, - await getLoginWithPrfSalt(), - prfCredentialIdsFromAllowCredentials(nativeOptions) + await getLoginWithPrfSalt() ); const prfResult = readPrfFirstResult(credential); return { @@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential( } return credential; }; - let credential: PublicKeyCredential; - if (requestPrf && await canRequestPrfExtension()) { - const prfOptions: PublicKeyCredentialCreationOptions = { - ...noPrfOptions, - extensions: { - ...((noPrfOptions as any).extensions || {}), - prf: {}, - } as any, - }; - try { - credential = await createWithOptions(prfOptions); - } catch (error) { - if (!shouldRetryCreateWithoutPrf(error)) throw error; - credential = await createWithOptions(noPrfOptions); - } - } else { - credential = await createWithOptions(noPrfOptions); - } + const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null; + const credential = await createWithOptions( + prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions + ); if (!(credential instanceof PublicKeyCredential)) { throw new Error(t('txt_no_passkey_created')); } - const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled; + const prfResult = readPrfFirstResult(credential); + const supportsPrf = !!prfResult || (credential.getClientExtensionResults() as any).prf?.enabled === true; return { token: response.token, createOptions: nativeOptions, deviceResponse: credential, request: attestationRequest(credential), supportsPrf, + prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined, }; } @@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet( pending: PendingAccountPasskeyCredential, userKey: { symEncKey: string; symMacKey: string } ): Promise { + if (pending.prfKey) { + return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey); + } const rawId = new Uint8Array(pending.deviceResponse.rawId); - const credentialId = bytesToBase64Url(rawId); const assertionOptions: PublicKeyCredentialRequestOptions = { challenge: pending.createOptions?.challenge!, rpId: pending.createOptions?.rp?.id, @@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet( }; const assertion = await getPublicKeyCredentialWithPrf( assertionOptions, - await getLoginWithPrfSalt(), - [credentialId] + await getLoginWithPrfSalt() ); const prfResult = readPrfFirstResult(assertion); if (!prfResult) {