Compare commits

...
4 Commits
10 changed files with 85 additions and 177 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1, verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
api_key TEXT, api_key TEXT,
+31 -50
View File
@@ -352,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(), securityStamp: generateUUID(),
role: 'user', role: 'user',
status: 'active', status: 'active',
verifyDevices: true, verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null, yubikeyKey1: null,
@@ -553,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env); const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { // Log the attempt for audit purposes, but do not change state.
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
return new Response(null, { status: 200 }); return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
} }
// GET /api/accounts/keys // GET /api/accounts/keys
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
}; };
} }
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> { // New-device verification requires an email delivery channel to send OTP
const enabled = user.verifyDevices !== false; // challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return { return {
Enabled: enabled, Enabled: false,
enabled, enabled: false,
VerifyDevices: enabled, VerifyDevices: false,
verifyDevices: enabled, verifyDevices: false,
Object: 'deviceVerificationSettings', Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings', object: 'deviceVerificationSettings',
}; };
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
} }
// PUT/POST /api/two-factor/device-verification-settings // PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
@@ -929,31 +913,28 @@ export async function handlePutDeviceVerificationSettings(request: Request, env:
} }
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices; const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']); // Log the attempt for audit purposes — never change state.
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings', source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user)); return jsonResponse(deviceVerificationSettingsResponse(user));
} }
+3
View File
@@ -24,8 +24,11 @@ function isWorkerHandledPath(path: string): boolean {
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith('/identity/') || path.startsWith('/identity/') ||
path.startsWith('/icons/') || path.startsWith('/icons/') ||
path.startsWith('/fill-assist/') ||
path.startsWith('/notifications/') || path.startsWith('/notifications/') ||
path.startsWith('/.well-known/') || path.startsWith('/.well-known/') ||
path === '/v1/assetlinks:check' ||
path === '/web-bootstrap' ||
path === '/config' || path === '/config' ||
path === '/api/config' || path === '/api/config' ||
path === '/api/version' path === '/api/version'
+1 -1
View File
@@ -301,7 +301,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []), config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0, yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
+2 -2
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT', 'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
+1 -1
View File
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null, yubikeyKey1: row.yubikey_key1 ?? null,
+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);
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false, // New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role, role: user.role,
status: user.status, status: user.status,
object: 'profile', object: 'profile',
+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) {