feat: enhance account passkey functionality and improve error handling

This commit is contained in:
shuaiplus
2026-06-10 12:09:25 +08:00
parent 18d3490c4f
commit 18e0396c0a
10 changed files with 165 additions and 31 deletions
+2 -1
View File
@@ -12,6 +12,7 @@ export interface AppConfirmState {
cancelText?: string;
hideCancel?: boolean;
onConfirm: () => void;
onCancel?: () => void;
}
interface AppGlobalOverlaysProps {
@@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm}
onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/>
<ConfirmDialog
+1 -1
View File
@@ -113,7 +113,7 @@ export interface AppMainRoutesProps {
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
+3 -3
View File
@@ -19,7 +19,7 @@ interface SettingsPageProps {
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential>;
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
@@ -179,8 +179,8 @@ export default function SettingsPage(props: SettingsPageProps) {
setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated'));
} else if (masterPasswordPrompt === 'createPasskey') {
await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
await refreshAccountPasskeys();
const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
if (credential) await refreshAccountPasskeys();
} else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') {
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword);
+40 -5
View File
@@ -22,6 +22,7 @@ import {
updateProfile,
} from '@/lib/api/auth';
import {
AccountPasskeyPrfUnavailableError,
assertAccountPasskey,
buildAccountPasskeyPrfKeySet,
buildAccountPasskeyPrfKeySetFromPrfKey,
@@ -66,7 +67,29 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} = options;
return useMemo(
() => ({
() => {
function confirmSaveLoginOnlyAccountPasskey(): Promise<boolean> {
return new Promise((resolve) => {
let settled = false;
const finish = (shouldSave: boolean) => {
if (settled) return;
settled = true;
onSetConfirm(null);
resolve(shouldSave);
};
onSetConfirm({
title: t('txt_account_passkey_direct_unlock_unavailable_title'),
message: t('txt_account_passkey_direct_unlock_unavailable_message'),
confirmText: t('txt_save_login_only_passkey'),
cancelText: t('txt_do_not_save'),
showIcon: true,
onConfirm: () => finish(true),
onCancel: () => finish(false),
});
});
}
return ({
async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return;
if (!currentPassword || !nextPassword) {
@@ -188,7 +211,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
return listAccountPasskeys(authedFetch);
},
async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise<AccountPasskeyCredential> {
async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise<AccountPasskeyCredential | null> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
@@ -197,21 +220,32 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
const pending = await createAccountPasskeyCredential(options);
let keySet = null;
let savedWithoutDirectUnlock = false;
if (directUnlock) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
try {
keySet = await buildAccountPasskeyPrfKeySet(pending, {
symEncKey: session.symEncKey,
symMacKey: session.symMacKey,
});
} catch (error) {
if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error;
const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey();
if (!shouldSaveLoginOnly) {
onNotify('warning', t('txt_account_passkey_not_saved'));
return null;
}
savedWithoutDirectUnlock = true;
}
}
const credential = await saveAccountPasskey(authedFetch, {
name: normalizedName,
token: pending.token,
deviceResponse: pending.request,
supportsPrf: pending.supportsPrf,
supportsPrf: keySet ? true : savedWithoutDirectUnlock ? false : pending.supportsPrf,
keySet,
});
onNotify('success', t('txt_account_passkey_saved'));
onNotify('success', savedWithoutDirectUnlock ? t('txt_account_passkey_saved_login_only') : t('txt_account_passkey_saved'));
return credential;
},
@@ -369,7 +403,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
},
});
},
}),
});
},
[
authedFetch,
clearDisableTotpDialog,
+21 -13
View File
@@ -1,4 +1,5 @@
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
import { t } from './i18n';
import type { AccountPasskeyPrfOption } from './types';
const LOGIN_WITH_PRF_SALT = 'passwordless-login';
@@ -23,6 +24,13 @@ export interface AccountPasskeyPrfKeySet {
encryptedPrivateKey: string;
}
export class AccountPasskeyPrfUnavailableError extends Error {
constructor() {
super(t('txt_account_passkey_direct_unlock_unavailable_error'));
this.name = 'AccountPasskeyPrfUnavailableError';
}
}
function bytesToBase64Url(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
@@ -38,7 +46,7 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
}
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions {
if (!options || typeof options !== 'object') throw new Error('Invalid passkey creation options');
if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_creation_options'));
return {
...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
@@ -56,7 +64,7 @@ function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions
}
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions {
if (!options || typeof options !== 'object') throw new Error('Invalid passkey assertion options');
if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_assertion_options'));
return {
...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
@@ -131,7 +139,7 @@ function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid passkey assertion response');
throw new Error(t('txt_invalid_passkey_assertion_response'));
}
return {
...publicKeyCredentialBase(credential),
@@ -148,7 +156,7 @@ function assertionRequest(credential: PublicKeyCredential): Record<string, unkno
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
throw new Error('Invalid passkey registration response');
throw new Error(t('txt_invalid_passkey_registration_response'));
}
const transports = typeof credential.response.getTransports === 'function'
? credential.response.getTransports()
@@ -167,7 +175,7 @@ export async function assertAccountPasskey(
response: { options: unknown; token: string }
): Promise<AccountPasskeyAssertion> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error('Passkey is not supported in this browser');
throw new Error(t('txt_passkey_browser_not_supported'));
}
const nativeOptions = cloneRequestOptions(response.options);
(nativeOptions as any).extensions = {
@@ -176,7 +184,7 @@ export async function assertAccountPasskey(
};
const credential = await navigator.credentials.get({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('No passkey was selected');
throw new Error(t('txt_no_passkey_selected'));
}
const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first;
return {
@@ -190,7 +198,7 @@ export async function createAccountPasskeyCredential(
response: { options: unknown; token: string }
): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error('Passkey is not supported in this browser');
throw new Error(t('txt_passkey_browser_not_supported'));
}
const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = {
@@ -199,7 +207,7 @@ export async function createAccountPasskeyCredential(
};
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error('No passkey was created');
throw new Error(t('txt_no_passkey_created'));
}
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
return {
@@ -214,7 +222,7 @@ export async function createAccountPasskeyCredential(
function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim();
const [type, payload] = text.split('.');
if (type !== '4' || !payload) throw new Error('Unsupported encrypted user key');
if (type !== '4' || !payload) throw new Error(t('txt_unsupported_encrypted_user_key'));
return base64ToBytes(payload);
}
@@ -236,11 +244,11 @@ export async function buildAccountPasskeyPrfKeySet(
};
const assertion = await navigator.credentials.get({ publicKey: assertionOptions });
if (!(assertion instanceof PublicKeyCredential)) {
throw new Error('Passkey verification failed');
throw new Error(t('txt_passkey_verification_failed'));
}
const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first;
if (!prfResult) {
throw new Error('This passkey does not support direct vault unlock');
throw new AccountPasskeyPrfUnavailableError();
}
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
}
@@ -285,7 +293,7 @@ export async function unlockVaultKeyWithAccountPasskeyPrf(
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
if (!encryptedPrivateKey || !encryptedUserKey) {
throw new Error('Passkey cannot unlock this vault');
throw new Error(t('txt_passkey_cannot_unlock_vault'));
}
const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
const privateKey = await crypto.subtle.importKey(
@@ -300,7 +308,7 @@ export async function unlockVaultKeyWithAccountPasskeyPrf(
privateKey,
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey))
));
if (userKeyBytes.length < 64) throw new Error('Invalid passkey vault key');
if (userKeyBytes.length < 64) throw new Error(t('txt_invalid_passkey_vault_key'));
return {
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)),
+18
View File
@@ -644,6 +644,13 @@ const en: Record<string, string> = {
"txt_account_passkey_not_found": "Account passkey not found",
"txt_account_passkey_prf_not_available": "This passkey cannot return a PRF key",
"txt_account_passkey_direct_unlock_enabled": "Direct vault unlock enabled",
"txt_account_passkey_direct_unlock_unavailable_title": "Direct unlock unavailable",
"txt_account_passkey_direct_unlock_unavailable_message": "This passkey did not return a PRF key, so it cannot unlock the vault directly. You can still save it for account login; unlocking the vault will require your master password.",
"txt_account_passkey_direct_unlock_unavailable_error": "This passkey cannot unlock the vault directly",
"txt_account_passkey_saved_login_only": "Account passkey saved for login only",
"txt_account_passkey_not_saved": "Account passkey was not saved",
"txt_save_login_only_passkey": "Save for login only",
"txt_do_not_save": "Do not save",
"txt_add_account_passkey": "Add account passkey",
"txt_delete_account_passkey": "Delete account passkey",
"txt_direct_unlock": "Direct unlock",
@@ -654,6 +661,17 @@ const en: Record<string, string> = {
"txt_passkey_name": "Passkey name",
"txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.",
"txt_prf_not_supported": "PRF not supported",
"txt_invalid_passkey_creation_options": "Invalid passkey creation options",
"txt_invalid_passkey_assertion_options": "Invalid passkey verification options",
"txt_invalid_passkey_assertion_response": "Invalid passkey verification response",
"txt_invalid_passkey_registration_response": "Invalid passkey registration response",
"txt_passkey_browser_not_supported": "This browser does not support passkeys",
"txt_no_passkey_selected": "No passkey was selected",
"txt_no_passkey_created": "No passkey was created",
"txt_unsupported_encrypted_user_key": "Unsupported encrypted account key",
"txt_passkey_verification_failed": "Passkey verification failed",
"txt_passkey_cannot_unlock_vault": "This passkey cannot unlock this vault",
"txt_invalid_passkey_vault_key": "Invalid passkey vault key",
"txt_phone": "Phone",
"txt_please_input_email_and_password": "Please input email and password",
"txt_please_input_master_password": "Please input master password",
+18
View File
@@ -644,6 +644,13 @@ const es: Record<string, string> = {
"txt_account_passkey_not_found": "Passkey de cuenta no encontrada",
"txt_account_passkey_prf_not_available": "Esta passkey no puede devolver una clave PRF",
"txt_account_passkey_direct_unlock_enabled": "Desbloqueo directo activado",
"txt_account_passkey_direct_unlock_unavailable_title": "Desbloqueo directo no disponible",
"txt_account_passkey_direct_unlock_unavailable_message": "Esta passkey no devolvió una clave PRF, por lo que no puede desbloquear la bóveda directamente. Aun así puede guardarla para iniciar sesión; para desbloquear la bóveda necesitará la contraseña maestra.",
"txt_account_passkey_direct_unlock_unavailable_error": "Esta passkey no puede desbloquear la bóveda directamente",
"txt_account_passkey_saved_login_only": "Passkey de cuenta guardada solo para inicio de sesión",
"txt_account_passkey_not_saved": "La passkey de cuenta no se guardó",
"txt_save_login_only_passkey": "Guardar solo para inicio",
"txt_do_not_save": "No guardar",
"txt_add_account_passkey": "Añadir passkey de cuenta",
"txt_delete_account_passkey": "Eliminar passkey de cuenta",
"txt_direct_unlock": "Desbloqueo directo",
@@ -654,6 +661,17 @@ const es: Record<string, string> = {
"txt_passkey_name": "Nombre de passkey",
"txt_passkey_requires_master_password": "Passkey verificada. Introduzca su contraseña maestra para desbloquear la bóveda.",
"txt_prf_not_supported": "PRF no compatible",
"txt_invalid_passkey_creation_options": "Opciones de creación de passkey no válidas",
"txt_invalid_passkey_assertion_options": "Opciones de verificación de passkey no válidas",
"txt_invalid_passkey_assertion_response": "Respuesta de verificación de passkey no válida",
"txt_invalid_passkey_registration_response": "Respuesta de registro de passkey no válida",
"txt_passkey_browser_not_supported": "Este navegador no admite passkeys",
"txt_no_passkey_selected": "No se seleccionó ninguna passkey",
"txt_no_passkey_created": "No se creó ninguna passkey",
"txt_unsupported_encrypted_user_key": "Clave de cuenta cifrada no compatible",
"txt_passkey_verification_failed": "Error al verificar la passkey",
"txt_passkey_cannot_unlock_vault": "Esta passkey no puede desbloquear esta bóveda",
"txt_invalid_passkey_vault_key": "Clave de bóveda de passkey no válida",
"txt_phone": "Teléfono",
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
+18
View File
@@ -644,6 +644,13 @@ const ru: Record<string, string> = {
"txt_account_passkey_not_found": "Passkey аккаунта не найдена",
"txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ",
"txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена",
"txt_account_passkey_direct_unlock_unavailable_title": "Прямая разблокировка недоступна",
"txt_account_passkey_direct_unlock_unavailable_message": "Эта passkey не вернула PRF-ключ, поэтому не может напрямую разблокировать хранилище. Ее все равно можно сохранить для входа в аккаунт; для разблокировки хранилища потребуется мастер-пароль.",
"txt_account_passkey_direct_unlock_unavailable_error": "Эта passkey не может напрямую разблокировать хранилище",
"txt_account_passkey_saved_login_only": "Passkey аккаунта сохранена только для входа",
"txt_account_passkey_not_saved": "Passkey аккаунта не сохранена",
"txt_save_login_only_passkey": "Сохранить только для входа",
"txt_do_not_save": "Не сохранять",
"txt_add_account_passkey": "Добавить passkey аккаунта",
"txt_delete_account_passkey": "Удалить passkey аккаунта",
"txt_direct_unlock": "Прямая разблокировка",
@@ -654,6 +661,17 @@ const ru: Record<string, string> = {
"txt_passkey_name": "Название passkey",
"txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.",
"txt_prf_not_supported": "PRF не поддерживается",
"txt_invalid_passkey_creation_options": "Недопустимые параметры создания passkey",
"txt_invalid_passkey_assertion_options": "Недопустимые параметры проверки passkey",
"txt_invalid_passkey_assertion_response": "Недопустимый ответ проверки passkey",
"txt_invalid_passkey_registration_response": "Недопустимый ответ регистрации passkey",
"txt_passkey_browser_not_supported": "Этот браузер не поддерживает passkeys",
"txt_no_passkey_selected": "Passkey не выбрана",
"txt_no_passkey_created": "Passkey не создана",
"txt_unsupported_encrypted_user_key": "Неподдерживаемый зашифрованный ключ аккаунта",
"txt_passkey_verification_failed": "Не удалось проверить passkey",
"txt_passkey_cannot_unlock_vault": "Эта passkey не может разблокировать это хранилище",
"txt_invalid_passkey_vault_key": "Недопустимый ключ хранилища passkey",
"txt_phone": "Телефон",
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
+20 -2
View File
@@ -644,16 +644,34 @@ const zhCN: Record<string, string> = {
"txt_account_passkey_not_found": "未找到账号通行密钥",
"txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥",
"txt_account_passkey_direct_unlock_enabled": "已开启直接解锁",
"txt_account_passkey_direct_unlock_unavailable_title": "无法直接解锁",
"txt_account_passkey_direct_unlock_unavailable_message": "这把通行密钥没有返回 PRF 密钥,因此不能直接解锁密码库。你仍然可以把它保存为仅登录通行密钥;登录后需要输入主密码解锁。",
"txt_account_passkey_direct_unlock_unavailable_error": "这把通行密钥无法直接解锁密码库",
"txt_account_passkey_saved_login_only": "已保存为仅登录通行密钥",
"txt_account_passkey_not_saved": "通行密钥未保存",
"txt_save_login_only_passkey": "保存为仅登录",
"txt_do_not_save": "不保存",
"txt_add_account_passkey": "添加账号通行密钥",
"txt_delete_account_passkey": "删除账号通行密钥",
"txt_direct_unlock": "直接解锁",
"txt_enable_passkey_direct_unlock": "开启直接解锁",
"txt_login_only": "仅登录",
"txt_login_with_passkey": "使用 Passkey 登录",
"txt_login_with_passkey": "使用通行密钥登录",
"txt_no_account_passkeys": "暂无账号通行密钥",
"txt_passkey_name": "通行密钥名称",
"txt_passkey_requires_master_password": "Passkey 已验证,请输入主密码解锁密码库。",
"txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。",
"txt_prf_not_supported": "不支持 PRF",
"txt_invalid_passkey_creation_options": "通行密钥创建选项无效",
"txt_invalid_passkey_assertion_options": "通行密钥验证选项无效",
"txt_invalid_passkey_assertion_response": "通行密钥验证响应无效",
"txt_invalid_passkey_registration_response": "通行密钥注册响应无效",
"txt_passkey_browser_not_supported": "当前浏览器不支持通行密钥",
"txt_no_passkey_selected": "未选择通行密钥",
"txt_no_passkey_created": "未创建通行密钥",
"txt_unsupported_encrypted_user_key": "不支持的加密账户密钥",
"txt_passkey_verification_failed": "通行密钥验证失败",
"txt_passkey_cannot_unlock_vault": "这把通行密钥无法解锁此密码库",
"txt_invalid_passkey_vault_key": "通行密钥密码库密钥无效",
"txt_phone": "电话",
"txt_please_input_email_and_password": "请输入邮箱和密码",
"txt_please_input_master_password": "请输入主密码",
+20 -2
View File
@@ -644,16 +644,34 @@ const zhTW: Record<string, string> = {
"txt_account_passkey_not_found": "未找到賬號通行密鑰",
"txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰",
"txt_account_passkey_direct_unlock_enabled": "已開啟直接解鎖",
"txt_account_passkey_direct_unlock_unavailable_title": "無法直接解鎖",
"txt_account_passkey_direct_unlock_unavailable_message": "這把通行密鑰沒有返回 PRF 密鑰,因此不能直接解鎖密碼庫。你仍然可以把它保存為僅登錄通行密鑰;登錄後需要輸入主密碼解鎖。",
"txt_account_passkey_direct_unlock_unavailable_error": "這把通行密鑰無法直接解鎖密碼庫",
"txt_account_passkey_saved_login_only": "已保存為僅登錄通行密鑰",
"txt_account_passkey_not_saved": "通行密鑰未保存",
"txt_save_login_only_passkey": "保存為僅登錄",
"txt_do_not_save": "不保存",
"txt_add_account_passkey": "添加賬號通行密鑰",
"txt_delete_account_passkey": "刪除賬號通行密鑰",
"txt_direct_unlock": "直接解鎖",
"txt_enable_passkey_direct_unlock": "開啟直接解鎖",
"txt_login_only": "僅登錄",
"txt_login_with_passkey": "使用 Passkey 登錄",
"txt_login_with_passkey": "使用通行密鑰登錄",
"txt_no_account_passkeys": "暫無賬號通行密鑰",
"txt_passkey_name": "通行密鑰名稱",
"txt_passkey_requires_master_password": "Passkey 已驗證,請輸入主密碼解鎖密碼庫。",
"txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。",
"txt_prf_not_supported": "不支持 PRF",
"txt_invalid_passkey_creation_options": "通行密鑰創建選項無效",
"txt_invalid_passkey_assertion_options": "通行密鑰驗證選項無效",
"txt_invalid_passkey_assertion_response": "通行密鑰驗證響應無效",
"txt_invalid_passkey_registration_response": "通行密鑰註冊響應無效",
"txt_passkey_browser_not_supported": "當前瀏覽器不支持通行密鑰",
"txt_no_passkey_selected": "未選擇通行密鑰",
"txt_no_passkey_created": "未創建通行密鑰",
"txt_unsupported_encrypted_user_key": "不支持的加密賬號密鑰",
"txt_passkey_verification_failed": "通行密鑰驗證失敗",
"txt_passkey_cannot_unlock_vault": "這把通行密鑰無法解鎖此密碼庫",
"txt_invalid_passkey_vault_key": "通行密鑰密碼庫密鑰無效",
"txt_phone": "電話",
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
"txt_please_input_master_password": "請輸入主密碼",