feat: update FIDO2 origins and enable direct unlock for account passkeys

This commit is contained in:
shuaiplus
2026-07-10 14:22:24 +08:00
parent 14dff8ee6a
commit 8c65cb2e80
3 changed files with 43 additions and 121 deletions
+9 -1
View File
@@ -1,5 +1,13 @@
import type { Env } from '../types'; 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 { export function normalizeOrigin(value: unknown): string | null {
const raw = String(value || '').trim(); const raw = String(value || '').trim();
if (!raw) return null; if (!raw) return null;
@@ -25,7 +33,7 @@ export function isBrowserExtensionOrigin(origin: unknown): boolean {
export function getConfiguredWebAuthnAllowedOrigins( export function getConfiguredWebAuthnAllowedOrigins(
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'> env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
): string[] { ): string[] {
const seen = new Set<string>(); const seen = new Set<string>(OFFICIAL_BITWARDEN_BROWSER_EXTENSION_ORIGINS);
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) { for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
const origin = normalizeOrigin(item); const origin = normalizeOrigin(item);
if (origin) seen.add(origin); if (origin) seen.add(origin);
+2 -2
View File
@@ -130,7 +130,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]); const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false); const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey')); const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false); const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(true);
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null); const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false); const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState(''); const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
@@ -509,7 +509,7 @@ export default function SettingsPage(props: SettingsPageProps) {
setCreatePasskeyDialogOpen(false); setCreatePasskeyDialogOpen(false);
setCreatePasskeyMasterPassword(''); setCreatePasskeyMasterPassword('');
setAccountPasskeyName(t('txt_account_passkey')); setAccountPasskeyName(t('txt_account_passkey'));
setAccountPasskeyDirectUnlock(false); setAccountPasskeyDirectUnlock(true);
} }
async function submitCreatePasskeyDialog(): Promise<void> { async function submitCreatePasskeyDialog(): Promise<void> {
+27 -113
View File
@@ -16,6 +16,7 @@ export interface PendingAccountPasskeyCredential {
deviceResponse: PublicKeyCredential; deviceResponse: PublicKeyCredential;
request: Record<string, unknown>; request: Record<string, unknown>;
supportsPrf: boolean; supportsPrf: boolean;
prfKey?: Uint8Array;
} }
export interface AccountPasskeyPrfKeySet { export interface AccountPasskeyPrfKeySet {
@@ -82,20 +83,9 @@ async function getLoginWithPrfSalt(): Promise<Uint8Array> {
return new Uint8Array(hash); 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 }; type PrfEvalInput = { first: Uint8Array };
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> { function buildPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt }; const evalInput: PrfEvalInput = { first: salt };
return { return {
prf: { prf: {
@@ -104,34 +94,23 @@ function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
}; };
} }
function buildCredentialPrfExtension( function withPrfExtension(
salt: Uint8Array, options: PublicKeyCredentialCreationOptions,
credentialIds: Array<string | null | undefined> salt: Uint8Array
): Record<string, unknown> { ): PublicKeyCredentialCreationOptions;
const evalInput = { first: salt };
const evalByCredential = credentialIds
.filter((id): id is string => !!id)
.reduce<Record<string, PrfEvalInput>>((out, id) => {
out[id] = evalInput;
return out;
}, {});
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
return {
prf: {
evalByCredential,
},
};
}
function withPrfExtension( function withPrfExtension(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown> salt: Uint8Array
): PublicKeyCredentialRequestOptions { ): PublicKeyCredentialRequestOptions;
function withPrfExtension(
options: PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions,
salt: Uint8Array
): PublicKeyCredentialCreationOptions | PublicKeyCredentialRequestOptions {
return { return {
...options, ...options,
extensions: { extensions: {
...((options as any).extensions || {}), ...((options as any).extensions || {}),
...extension, ...buildPrfExtension(salt),
} as any, } as any,
}; };
} }
@@ -154,70 +133,17 @@ function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | unde
return result instanceof ArrayBuffer ? result : undefined; 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<boolean> {
if (/\bFirefox\//i.test(navigator.userAgent)) return false;
return true;
}
async function getPublicKeyCredentialWithPrf( async function getPublicKeyCredentialWithPrf(
options: PublicKeyCredentialRequestOptions, options: PublicKeyCredentialRequestOptions,
salt: Uint8Array, salt: Uint8Array
credentialIds: string[] = []
): Promise<PublicKeyCredential> { ): Promise<PublicKeyCredential> {
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({ const credential = await navigator.credentials.get({
publicKey: withPrfExtension(options, attempts[index]), publicKey: withPrfExtension(options, salt),
}); });
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_selected')); throw new Error(t('txt_no_passkey_selected'));
} }
lastCredential = credential;
if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) {
return credential; return credential;
}
} catch (error) {
if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) {
if (lastCredential) return lastCredential;
throw error;
}
}
}
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);
} }
async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> { async function prfOutputToKey(prfOutput: ArrayBuffer): Promise<Uint8Array> {
@@ -282,8 +208,7 @@ export async function assertAccountPasskey(
const nativeOptions = cloneRequestOptions(response.options); const nativeOptions = cloneRequestOptions(response.options);
const credential = await getPublicKeyCredentialWithPrf( const credential = await getPublicKeyCredentialWithPrf(
nativeOptions, nativeOptions,
await getLoginWithPrfSalt(), await getLoginWithPrfSalt()
prfCredentialIdsFromAllowCredentials(nativeOptions)
); );
const prfResult = readPrfFirstResult(credential); const prfResult = readPrfFirstResult(credential);
return { return {
@@ -309,34 +234,22 @@ export async function createAccountPasskeyCredential(
} }
return credential; return credential;
}; };
let credential: PublicKeyCredential; const prfSalt = requestPrf ? await getLoginWithPrfSalt() : null;
if (requestPrf && await canRequestPrfExtension()) { const credential = await createWithOptions(
const prfOptions: PublicKeyCredentialCreationOptions = { prfSalt ? withPrfExtension(noPrfOptions, prfSalt) : noPrfOptions
...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);
}
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created')); 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 { return {
token: response.token, token: response.token,
createOptions: nativeOptions, createOptions: nativeOptions,
deviceResponse: credential, deviceResponse: credential,
request: attestationRequest(credential), request: attestationRequest(credential),
supportsPrf, supportsPrf,
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
}; };
} }
@@ -373,8 +286,10 @@ export async function buildAccountPasskeyPrfKeySet(
pending: PendingAccountPasskeyCredential, pending: PendingAccountPasskeyCredential,
userKey: { symEncKey: string; symMacKey: string } userKey: { symEncKey: string; symMacKey: string }
): Promise<AccountPasskeyPrfKeySet> { ): Promise<AccountPasskeyPrfKeySet> {
if (pending.prfKey) {
return buildAccountPasskeyPrfKeySetFromPrfKey(pending.prfKey, userKey);
}
const rawId = new Uint8Array(pending.deviceResponse.rawId); const rawId = new Uint8Array(pending.deviceResponse.rawId);
const credentialId = bytesToBase64Url(rawId);
const assertionOptions: PublicKeyCredentialRequestOptions = { const assertionOptions: PublicKeyCredentialRequestOptions = {
challenge: pending.createOptions?.challenge!, challenge: pending.createOptions?.challenge!,
rpId: pending.createOptions?.rp?.id, rpId: pending.createOptions?.rp?.id,
@@ -384,8 +299,7 @@ export async function buildAccountPasskeyPrfKeySet(
}; };
const assertion = await getPublicKeyCredentialWithPrf( const assertion = await getPublicKeyCredentialWithPrf(
assertionOptions, assertionOptions,
await getLoginWithPrfSalt(), await getLoginWithPrfSalt()
[credentialId]
); );
const prfResult = readPrfFirstResult(assertion); const prfResult = readPrfFirstResult(assertion);
if (!prfResult) { if (!prfResult) {