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; cancelText?: string;
hideCancel?: boolean; hideCancel?: boolean;
onConfirm: () => void; onConfirm: () => void;
onCancel?: () => void;
} }
interface AppGlobalOverlaysProps { interface AppGlobalOverlaysProps {
@@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
cancelText={props.confirm?.cancelText} cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel} hideCancel={props.confirm?.hideCancel}
onConfirm={() => props.confirm?.onConfirm()} onConfirm={() => props.confirm?.onConfirm()}
onCancel={props.onCancelConfirm} onCancel={props.confirm?.onCancel || props.onCancelConfirm}
/> />
<ConfirmDialog <ConfirmDialog
+1 -1
View File
@@ -113,7 +113,7 @@ export interface AppMainRoutesProps {
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>; 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>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
+3 -3
View File
@@ -19,7 +19,7 @@ interface SettingsPageProps {
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>; onRotateApiKey: (masterPassword: string) => Promise<string>;
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>; 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>; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
@@ -179,8 +179,8 @@ export default function SettingsPage(props: SettingsPageProps) {
setApiKeyDialogOpen(true); setApiKeyDialogOpen(true);
props.onNotify?.('success', t('txt_api_key_rotated')); props.onNotify?.('success', t('txt_api_key_rotated'));
} else if (masterPasswordPrompt === 'createPasskey') { } else if (masterPasswordPrompt === 'createPasskey') {
await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock); const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
await refreshAccountPasskeys(); if (credential) await refreshAccountPasskeys();
} else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') { } else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') {
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found')); if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword); await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword);
+44 -9
View File
@@ -22,6 +22,7 @@ import {
updateProfile, updateProfile,
} from '@/lib/api/auth'; } from '@/lib/api/auth';
import { import {
AccountPasskeyPrfUnavailableError,
assertAccountPasskey, assertAccountPasskey,
buildAccountPasskeyPrfKeySet, buildAccountPasskeyPrfKeySet,
buildAccountPasskeyPrfKeySetFromPrfKey, buildAccountPasskeyPrfKeySetFromPrfKey,
@@ -66,7 +67,29 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} = options; } = options;
return useMemo( 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) { async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) {
if (!profile) return; if (!profile) return;
if (!currentPassword || !nextPassword) { if (!currentPassword || !nextPassword) {
@@ -188,7 +211,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
return listAccountPasskeys(authedFetch); 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')); if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || ''); const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required')); 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 options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
const pending = await createAccountPasskeyCredential(options); const pending = await createAccountPasskeyCredential(options);
let keySet = null; let keySet = null;
let savedWithoutDirectUnlock = false;
if (directUnlock) { if (directUnlock) {
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
keySet = await buildAccountPasskeyPrfKeySet(pending, { try {
symEncKey: session.symEncKey, keySet = await buildAccountPasskeyPrfKeySet(pending, {
symMacKey: session.symMacKey, 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, { const credential = await saveAccountPasskey(authedFetch, {
name: normalizedName, name: normalizedName,
token: pending.token, token: pending.token,
deviceResponse: pending.request, deviceResponse: pending.request,
supportsPrf: pending.supportsPrf, supportsPrf: keySet ? true : savedWithoutDirectUnlock ? false : pending.supportsPrf,
keySet, 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; return credential;
}, },
@@ -369,7 +403,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
}, },
}); });
}, },
}), });
},
[ [
authedFetch, authedFetch,
clearDisableTotpDialog, clearDisableTotpDialog,
+21 -13
View File
@@ -1,4 +1,5 @@
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto'; import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
import { t } from './i18n';
import type { AccountPasskeyPrfOption } from './types'; import type { AccountPasskeyPrfOption } from './types';
const LOGIN_WITH_PRF_SALT = 'passwordless-login'; const LOGIN_WITH_PRF_SALT = 'passwordless-login';
@@ -23,6 +24,13 @@ export interface AccountPasskeyPrfKeySet {
encryptedPrivateKey: string; 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 { function bytesToBase64Url(bytes: Uint8Array): string {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
} }
@@ -38,7 +46,7 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
} }
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions { 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 { return {
...options, ...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)), challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
@@ -56,7 +64,7 @@ function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions
} }
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions { 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 { return {
...options, ...options,
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)), challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
@@ -131,7 +139,7 @@ function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> { function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAssertionResponse)) { if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
throw new Error('Invalid passkey assertion response'); throw new Error(t('txt_invalid_passkey_assertion_response'));
} }
return { return {
...publicKeyCredentialBase(credential), ...publicKeyCredentialBase(credential),
@@ -148,7 +156,7 @@ function assertionRequest(credential: PublicKeyCredential): Record<string, unkno
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> { function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
if (!(credential.response instanceof AuthenticatorAttestationResponse)) { 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' const transports = typeof credential.response.getTransports === 'function'
? credential.response.getTransports() ? credential.response.getTransports()
@@ -167,7 +175,7 @@ export async function assertAccountPasskey(
response: { options: unknown; token: string } response: { options: unknown; token: string }
): Promise<AccountPasskeyAssertion> { ): Promise<AccountPasskeyAssertion> {
if (!window.PublicKeyCredential || !navigator.credentials) { 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); const nativeOptions = cloneRequestOptions(response.options);
(nativeOptions as any).extensions = { (nativeOptions as any).extensions = {
@@ -176,7 +184,7 @@ export async function assertAccountPasskey(
}; };
const credential = await navigator.credentials.get({ publicKey: nativeOptions }); const credential = await navigator.credentials.get({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) { 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; const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first;
return { return {
@@ -190,7 +198,7 @@ export async function createAccountPasskeyCredential(
response: { options: unknown; token: string } response: { options: unknown; token: string }
): Promise<PendingAccountPasskeyCredential> { ): Promise<PendingAccountPasskeyCredential> {
if (!window.PublicKeyCredential || !navigator.credentials) { 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); const nativeOptions = cloneCreationOptions(response.options);
(nativeOptions as any).extensions = { (nativeOptions as any).extensions = {
@@ -199,7 +207,7 @@ export async function createAccountPasskeyCredential(
}; };
const credential = await navigator.credentials.create({ publicKey: nativeOptions }); const credential = await navigator.credentials.create({ publicKey: nativeOptions });
if (!(credential instanceof PublicKeyCredential)) { 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; const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
return { return {
@@ -214,7 +222,7 @@ export async function createAccountPasskeyCredential(
function parseRsaEncryptedUserKey(value: string): Uint8Array { function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim(); const text = String(value || '').trim();
const [type, payload] = text.split('.'); 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); return base64ToBytes(payload);
} }
@@ -236,11 +244,11 @@ export async function buildAccountPasskeyPrfKeySet(
}; };
const assertion = await navigator.credentials.get({ publicKey: assertionOptions }); const assertion = await navigator.credentials.get({ publicKey: assertionOptions });
if (!(assertion instanceof PublicKeyCredential)) { 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; const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first;
if (!prfResult) { if (!prfResult) {
throw new Error('This passkey does not support direct vault unlock'); throw new AccountPasskeyPrfUnavailableError();
} }
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey); return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
} }
@@ -285,7 +293,7 @@ export async function unlockVaultKeyWithAccountPasskeyPrf(
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || ''; const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || ''; const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
if (!encryptedPrivateKey || !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 privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
@@ -300,7 +308,7 @@ export async function unlockVaultKeyWithAccountPasskeyPrf(
privateKey, privateKey,
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey)) 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 { return {
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)), symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)), 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_not_found": "Account passkey not found",
"txt_account_passkey_prf_not_available": "This passkey cannot return a PRF key", "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_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_add_account_passkey": "Add account passkey",
"txt_delete_account_passkey": "Delete account passkey", "txt_delete_account_passkey": "Delete account passkey",
"txt_direct_unlock": "Direct unlock", "txt_direct_unlock": "Direct unlock",
@@ -654,6 +661,17 @@ const en: Record<string, string> = {
"txt_passkey_name": "Passkey name", "txt_passkey_name": "Passkey name",
"txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.", "txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.",
"txt_prf_not_supported": "PRF not supported", "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_phone": "Phone",
"txt_please_input_email_and_password": "Please input email and password", "txt_please_input_email_and_password": "Please input email and password",
"txt_please_input_master_password": "Please input master 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_not_found": "Passkey de cuenta no encontrada",
"txt_account_passkey_prf_not_available": "Esta passkey no puede devolver una clave PRF", "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_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_add_account_passkey": "Añadir passkey de cuenta",
"txt_delete_account_passkey": "Eliminar passkey de cuenta", "txt_delete_account_passkey": "Eliminar passkey de cuenta",
"txt_direct_unlock": "Desbloqueo directo", "txt_direct_unlock": "Desbloqueo directo",
@@ -654,6 +661,17 @@ const es: Record<string, string> = {
"txt_passkey_name": "Nombre de passkey", "txt_passkey_name": "Nombre de passkey",
"txt_passkey_requires_master_password": "Passkey verificada. Introduzca su contraseña maestra para desbloquear la bóveda.", "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_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_phone": "Teléfono",
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña", "txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra", "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_not_found": "Passkey аккаунта не найдена",
"txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ", "txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ",
"txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена", "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_add_account_passkey": "Добавить passkey аккаунта",
"txt_delete_account_passkey": "Удалить passkey аккаунта", "txt_delete_account_passkey": "Удалить passkey аккаунта",
"txt_direct_unlock": "Прямая разблокировка", "txt_direct_unlock": "Прямая разблокировка",
@@ -654,6 +661,17 @@ const ru: Record<string, string> = {
"txt_passkey_name": "Название passkey", "txt_passkey_name": "Название passkey",
"txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.", "txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.",
"txt_prf_not_supported": "PRF не поддерживается", "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_phone": "Телефон",
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль", "txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
"txt_please_input_master_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_not_found": "未找到账号通行密钥",
"txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥", "txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥",
"txt_account_passkey_direct_unlock_enabled": "已开启直接解锁", "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_add_account_passkey": "添加账号通行密钥",
"txt_delete_account_passkey": "删除账号通行密钥", "txt_delete_account_passkey": "删除账号通行密钥",
"txt_direct_unlock": "直接解锁", "txt_direct_unlock": "直接解锁",
"txt_enable_passkey_direct_unlock": "开启直接解锁", "txt_enable_passkey_direct_unlock": "开启直接解锁",
"txt_login_only": "仅登录", "txt_login_only": "仅登录",
"txt_login_with_passkey": "使用 Passkey 登录", "txt_login_with_passkey": "使用通行密钥登录",
"txt_no_account_passkeys": "暂无账号通行密钥", "txt_no_account_passkeys": "暂无账号通行密钥",
"txt_passkey_name": "通行密钥名称", "txt_passkey_name": "通行密钥名称",
"txt_passkey_requires_master_password": "Passkey 已验证,请输入主密码解锁密码库。", "txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。",
"txt_prf_not_supported": "不支持 PRF", "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_phone": "电话",
"txt_please_input_email_and_password": "请输入邮箱和密码", "txt_please_input_email_and_password": "请输入邮箱和密码",
"txt_please_input_master_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_not_found": "未找到賬號通行密鑰",
"txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰", "txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰",
"txt_account_passkey_direct_unlock_enabled": "已開啟直接解鎖", "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_add_account_passkey": "添加賬號通行密鑰",
"txt_delete_account_passkey": "刪除賬號通行密鑰", "txt_delete_account_passkey": "刪除賬號通行密鑰",
"txt_direct_unlock": "直接解鎖", "txt_direct_unlock": "直接解鎖",
"txt_enable_passkey_direct_unlock": "開啟直接解鎖", "txt_enable_passkey_direct_unlock": "開啟直接解鎖",
"txt_login_only": "僅登錄", "txt_login_only": "僅登錄",
"txt_login_with_passkey": "使用 Passkey 登錄", "txt_login_with_passkey": "使用通行密鑰登錄",
"txt_no_account_passkeys": "暫無賬號通行密鑰", "txt_no_account_passkeys": "暫無賬號通行密鑰",
"txt_passkey_name": "通行密鑰名稱", "txt_passkey_name": "通行密鑰名稱",
"txt_passkey_requires_master_password": "Passkey 已驗證,請輸入主密碼解鎖密碼庫。", "txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。",
"txt_prf_not_supported": "不支持 PRF", "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_phone": "電話",
"txt_please_input_email_and_password": "請輸入郵箱和密碼", "txt_please_input_email_and_password": "請輸入郵箱和密碼",
"txt_please_input_master_password": "請輸入主密碼", "txt_please_input_master_password": "請輸入主密碼",