mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: implement account passkey functionality
- Added functions for managing account passkeys including creation, listing, updating, and deletion. - Introduced login methods using account passkeys with options for direct unlock and login-only modes. - Enhanced error handling and response parsing for passkey-related API calls. - Updated UI styles for account passkey management components. - Added new translations for account passkey features in multiple languages. - Modified network status handling to improve service reachability checks.
This commit is contained in:
+67
-1
@@ -37,13 +37,16 @@ import {
|
||||
bootstrapAppSession,
|
||||
type CompletedLogin,
|
||||
readInitialAppBootstrapState,
|
||||
completePasskeyPasswordLogin,
|
||||
performPasswordLogin,
|
||||
performPasskeyLogin,
|
||||
performRecoverTwoFactorLogin,
|
||||
performRegistration,
|
||||
performTotpLogin,
|
||||
hydrateLockedSession,
|
||||
performUnlock,
|
||||
type JwtUnsafeReason,
|
||||
type PendingPasskeyPassword,
|
||||
type PendingTotp,
|
||||
} from '@/lib/app-auth';
|
||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||
@@ -170,7 +173,7 @@ export default function App() {
|
||||
[initialBootstrap]
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'passkey' | 'register' | 'unlock' | null>(null);
|
||||
const [location, navigate] = useLocation();
|
||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||
@@ -201,6 +204,8 @@ export default function App() {
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
|
||||
const [pendingPasskeyPassword, setPendingPasskeyPassword] = useState<PendingPasskeyPassword | null>(null);
|
||||
const [passkeyPassword, setPasskeyPassword] = useState('');
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
const [totpSubmitting, setTotpSubmitting] = useState(false);
|
||||
@@ -480,7 +485,9 @@ export default function App() {
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setPendingPasskeyPassword(null);
|
||||
setTotpCode('');
|
||||
setPasskeyPassword('');
|
||||
setUnlockPassword('');
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||
@@ -535,6 +542,51 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (pendingAuthAction) return;
|
||||
if (IS_DEMO_MODE) {
|
||||
pushToast('warning', t('txt_demo_readonly_message'));
|
||||
return;
|
||||
}
|
||||
setPendingAuthAction('passkey');
|
||||
try {
|
||||
const result = await performPasskeyLogin(defaultKdfIterations);
|
||||
if (result.kind === 'success') {
|
||||
await finalizeLogin(result.login);
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'password') {
|
||||
setPendingPasskeyPassword(result.pendingPasskeyPassword);
|
||||
setLoginValues({ email: result.pendingPasskeyPassword.email, password: '' });
|
||||
setPasskeyPassword('');
|
||||
pushToast('warning', t('txt_passkey_requires_master_password'));
|
||||
return;
|
||||
}
|
||||
pushToast('error', result.message || t('txt_login_failed'));
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
|
||||
} finally {
|
||||
setPendingAuthAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyPasswordLogin() {
|
||||
if (pendingAuthAction || !pendingPasskeyPassword) return;
|
||||
if (!passkeyPassword) {
|
||||
pushToast('error', t('txt_please_input_master_password'));
|
||||
return;
|
||||
}
|
||||
setPendingAuthAction('login');
|
||||
try {
|
||||
const login = await completePasskeyPasswordLogin(pendingPasskeyPassword, passkeyPassword);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect'));
|
||||
} finally {
|
||||
setPendingAuthAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
@@ -1354,6 +1406,7 @@ export default function App() {
|
||||
const accountSecurityActions = useAccountSecurityActions({
|
||||
authedFetch,
|
||||
profile,
|
||||
session,
|
||||
defaultKdfIterations,
|
||||
disableTotpPassword,
|
||||
clearDisableTotpDialog: () => {
|
||||
@@ -1540,6 +1593,10 @@ export default function App() {
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
onListAccountPasskeys: accountSecurityActions.listAccountPasskeys,
|
||||
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
@@ -1650,18 +1707,25 @@ export default function App() {
|
||||
unlockReady={!!session?.email}
|
||||
unlockPreparing={unlockPreparing}
|
||||
loginValues={loginValues}
|
||||
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
|
||||
passkeyPassword={passkeyPassword}
|
||||
registerValues={registerValues}
|
||||
registrationInviteRequired={registrationInviteRequired}
|
||||
unlockPassword={unlockPassword}
|
||||
emailForLock={profile?.email || session?.email || ''}
|
||||
loginHintLoading={loginHintState.loading}
|
||||
onChangeLogin={setLoginValues}
|
||||
onChangePasskeyPassword={setPasskeyPassword}
|
||||
onChangeRegister={setRegisterValues}
|
||||
onChangeUnlock={setUnlockPassword}
|
||||
onSubmitLogin={() => void handleLogin()}
|
||||
onSubmitPasskey={() => void handlePasskeyLogin()}
|
||||
onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()}
|
||||
onSubmitRegister={() => void handleRegister()}
|
||||
onSubmitUnlock={() => void handleUnlock()}
|
||||
onGotoLogin={() => {
|
||||
setPendingPasskeyPassword(null);
|
||||
setPasskeyPassword('');
|
||||
setPhase('login');
|
||||
navigate('/login');
|
||||
}}
|
||||
@@ -1673,6 +1737,8 @@ export default function App() {
|
||||
if (inviteCodeFromUrl) {
|
||||
setRegisterValues((prev) => ({ ...prev, inviteCode: inviteCodeFromUrl }));
|
||||
}
|
||||
setPendingPasskeyPassword(null);
|
||||
setPasskeyPassword('');
|
||||
setPhase('register');
|
||||
navigate('/register');
|
||||
}}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -112,6 +112,10 @@ export interface AppMainRoutesProps {
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||
@@ -261,6 +265,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
onListAccountPasskeys={props.onListAccountPasskeys}
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'preact/hooks';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import { ArrowLeft, Eye, EyeOff, KeyRound, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import NetworkStatusBadge from '@/components/NetworkStatusBadge';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -23,19 +23,24 @@ interface AuthViewsProps {
|
||||
relaxedLoginInput?: boolean;
|
||||
authPlaceholder?: string;
|
||||
unlockPlaceholder?: string;
|
||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
loginValues: LoginValues;
|
||||
pendingPasskeyPasswordEmail?: string | null;
|
||||
passkeyPassword: string;
|
||||
registerValues: RegisterValues;
|
||||
registrationInviteRequired?: boolean;
|
||||
unlockPassword: string;
|
||||
emailForLock: string;
|
||||
loginHintLoading: boolean;
|
||||
onChangeLogin: (next: LoginValues) => void;
|
||||
onChangePasskeyPassword: (password: string) => void;
|
||||
onChangeRegister: (next: RegisterValues) => void;
|
||||
onChangeUnlock: (password: string) => void;
|
||||
onSubmitLogin: () => void;
|
||||
onSubmitPasskey: () => void;
|
||||
onSubmitPasskeyPassword: () => void;
|
||||
onSubmitRegister: () => void;
|
||||
onSubmitUnlock: () => void;
|
||||
onGotoLogin: () => void;
|
||||
@@ -77,8 +82,10 @@ function PasswordField(props: {
|
||||
|
||||
export default function AuthViews(props: AuthViewsProps) {
|
||||
const loginBusy = props.pendingAction === 'login';
|
||||
const passkeyBusy = props.pendingAction === 'passkey';
|
||||
const registerBusy = props.pendingAction === 'register';
|
||||
const unlockBusy = props.pendingAction === 'unlock';
|
||||
const passkeyPasswordPending = !!props.pendingPasskeyPasswordEmail;
|
||||
const showInviteCodeField = props.registrationInviteRequired !== false || !!props.registerValues.inviteCode.trim();
|
||||
|
||||
if (props.mode === 'locked') {
|
||||
@@ -221,9 +228,37 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (passkeyPasswordPending) {
|
||||
props.onSubmitPasskeyPassword();
|
||||
return;
|
||||
}
|
||||
props.onSubmitLogin();
|
||||
}}
|
||||
>
|
||||
{passkeyPasswordPending ? (
|
||||
<>
|
||||
<p className="muted standalone-muted">{props.pendingPasskeyPasswordEmail}</p>
|
||||
<input type="text" value={props.pendingPasskeyPasswordEmail || ''} autoComplete="username" readOnly hidden tabIndex={-1} aria-hidden="true" />
|
||||
<PasswordField
|
||||
label={t('txt_master_password')}
|
||||
value={props.passkeyPassword}
|
||||
autoFocus
|
||||
autoComplete="current-password"
|
||||
placeholder={props.authPlaceholder}
|
||||
onInput={props.onChangePasskeyPassword}
|
||||
/>
|
||||
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{loginBusy ? t('txt_unlocking') : t('txt_unlock')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={loginBusy}>
|
||||
<ArrowLeft size={16} className="btn-icon" />
|
||||
{t('txt_back_to_login')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<label className="field">
|
||||
<span>{t('txt_email')}</span>
|
||||
<input
|
||||
@@ -261,10 +296,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy || passkeyBusy}>
|
||||
<KeyRound size={16} className="btn-icon" />
|
||||
{passkeyBusy ? t('txt_logging_in') : t('txt_login_with_passkey')}
|
||||
</button>
|
||||
<div className="or">{t('txt_or')}</div>
|
||||
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
|
||||
<UserPlus size={16} className="btn-icon" />
|
||||
{t('txt_create_account')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</form>
|
||||
</StandalonePageFrame>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import qrcode from 'qrcode-generator';
|
||||
import type { Profile } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
@@ -18,11 +18,23 @@ interface SettingsPageProps {
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
onListAccountPasskeys: () => Promise<AccountPasskeyCredential[]>;
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
}
|
||||
|
||||
type MasterPasswordPromptAction =
|
||||
| 'recovery'
|
||||
| 'apiKey'
|
||||
| 'rotateApiKey'
|
||||
| 'createPasskey'
|
||||
| 'enablePasskeyDirectUnlock'
|
||||
| 'deletePasskey';
|
||||
|
||||
const LOCK_TIMEOUT_OPTIONS = [
|
||||
{ value: 1, labelKey: 'txt_timeout_1_minute' },
|
||||
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
|
||||
@@ -74,9 +86,14 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [accountPasskeys, setAccountPasskeys] = useState<AccountPasskeyCredential[]>([]);
|
||||
const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false);
|
||||
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
|
||||
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
|
||||
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
|
||||
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
|
||||
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null);
|
||||
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
|
||||
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
||||
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
|
||||
@@ -97,6 +114,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setPasswordHint(props.profile.masterPasswordHint || '');
|
||||
}, [props.profile.masterPasswordHint]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAccountPasskeys();
|
||||
}, [props.profile.id]);
|
||||
|
||||
const qrDataUrl = useMemo(() => {
|
||||
const qr = qrcode(0, 'M');
|
||||
qr.addData(buildOtpUri(props.profile.email, secret));
|
||||
@@ -115,14 +136,27 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
|
||||
async function refreshAccountPasskeys(): Promise<void> {
|
||||
setAccountPasskeysLoading(true);
|
||||
try {
|
||||
setAccountPasskeys(await props.onListAccountPasskeys());
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed'));
|
||||
} finally {
|
||||
setAccountPasskeysLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openMasterPasswordPrompt(action: MasterPasswordPromptAction, credentialId?: string): void {
|
||||
setMasterPasswordPrompt(action);
|
||||
setAccountPasskeyPromptId(credentialId || null);
|
||||
setMasterPasswordPromptValue('');
|
||||
}
|
||||
|
||||
function closeMasterPasswordPrompt(): void {
|
||||
if (masterPasswordPromptSubmitting) return;
|
||||
setMasterPasswordPrompt(null);
|
||||
setAccountPasskeyPromptId(null);
|
||||
setMasterPasswordPromptValue('');
|
||||
}
|
||||
|
||||
@@ -139,13 +173,25 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const key = await props.onGetApiKey(masterPassword);
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
} else {
|
||||
} else if (masterPasswordPrompt === 'rotateApiKey') {
|
||||
const key = await props.onRotateApiKey(masterPassword);
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||
} else if (masterPasswordPrompt === 'createPasskey') {
|
||||
await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
|
||||
await refreshAccountPasskeys();
|
||||
} else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') {
|
||||
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
|
||||
await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword);
|
||||
await refreshAccountPasskeys();
|
||||
} else if (masterPasswordPrompt === 'deletePasskey') {
|
||||
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
|
||||
await props.onDeleteAccountPasskey(accountPasskeyPromptId, masterPassword);
|
||||
await refreshAccountPasskeys();
|
||||
}
|
||||
setMasterPasswordPrompt(null);
|
||||
setAccountPasskeyPromptId(null);
|
||||
setMasterPasswordPromptValue('');
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
|
||||
@@ -159,7 +205,19 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
? t('txt_view_recovery_code')
|
||||
: masterPasswordPrompt === 'rotateApiKey'
|
||||
? t('txt_rotate_api_key')
|
||||
: t('txt_view_api_key');
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
? t('txt_add_account_passkey')
|
||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||
? t('txt_enable_passkey_direct_unlock')
|
||||
: masterPasswordPrompt === 'deletePasskey'
|
||||
? t('txt_delete_account_passkey')
|
||||
: t('txt_view_api_key');
|
||||
|
||||
function accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
|
||||
if (credential.prfStatus === 0) return t('txt_direct_unlock');
|
||||
if (credential.prfStatus === 1) return t('txt_login_only');
|
||||
return t('txt_prf_not_supported');
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined): string {
|
||||
if (!value) return t('txt_dash');
|
||||
@@ -345,6 +403,107 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module account-passkeys-module">
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_account_passkeys')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={accountPasskeysLoading}
|
||||
title={t('txt_refresh')}
|
||||
aria-label={t('txt_refresh')}
|
||||
onClick={() => void refreshAccountPasskeys()}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_passkey_name')}</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={128}
|
||||
value={accountPasskeyName}
|
||||
placeholder={t('txt_account_passkey_name_placeholder')}
|
||||
onInput={(e) => setAccountPasskeyName((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="field account-passkey-mode-field">
|
||||
<span>{t('txt_account_passkey_mode')}</span>
|
||||
<label className="account-passkey-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accountPasskeyDirectUnlock}
|
||||
onInput={(e) => setAccountPasskeyDirectUnlock((e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>{t('txt_account_passkey_direct_unlock_mode')}</span>
|
||||
</label>
|
||||
<div className="field-help">
|
||||
{accountPasskeyDirectUnlock ? t('txt_account_passkey_direct_unlock_help') : t('txt_account_passkey_login_only_help')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('createPasskey')}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_add_account_passkey')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="account-passkeys-list">
|
||||
{accountPasskeysLoading ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<RefreshCw size={20} />
|
||||
<span>{t('txt_loading')}</span>
|
||||
</div>
|
||||
) : accountPasskeys.length === 0 ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<KeyRound size={20} />
|
||||
<span>{t('txt_no_account_passkeys')}</span>
|
||||
</div>
|
||||
) : (
|
||||
accountPasskeys.map((credential) => (
|
||||
<div key={credential.id} className="account-passkey-row">
|
||||
<div className="account-passkey-main">
|
||||
<strong>{credential.name || t('txt_account_passkey')}</strong>
|
||||
<small>{t('txt_created_value', { value: formatDateTime(credential.creationDate) })}</small>
|
||||
</div>
|
||||
<span className={`account-passkey-status account-passkey-status-${credential.prfStatus}`}>
|
||||
{accountPasskeyStatusText(credential)}
|
||||
</span>
|
||||
<div className="actions account-passkey-actions">
|
||||
{credential.prfStatus === 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('enablePasskeyDirectUnlock', credential.id)}
|
||||
>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{t('txt_enable_passkey_direct_unlock')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('deletePasskey', credential.id)}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="settings-module sensitive-actions-module">
|
||||
<div className="sensitive-actions-grid">
|
||||
<div className="sensitive-action">
|
||||
|
||||
@@ -4,27 +4,40 @@ import {
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deriveLoginHash,
|
||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getAccountPasskeyAttestationOptions,
|
||||
getAccountPasskeyUpdateAssertionOptions,
|
||||
getTotpRecoveryCode,
|
||||
listAccountPasskeys,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveAccountPasskey,
|
||||
setTotp,
|
||||
trustAuthorizedDevicePermanently,
|
||||
updateAuthorizedDeviceName,
|
||||
updateProfile,
|
||||
} from '@/lib/api/auth';
|
||||
import {
|
||||
assertAccountPasskey,
|
||||
buildAccountPasskeyPrfKeySet,
|
||||
buildAccountPasskeyPrfKeySetFromPrfKey,
|
||||
createAccountPasskeyCredential,
|
||||
} from '@/lib/account-passkeys';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import type { AuthorizedDevice, Profile } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
interface UseAccountSecurityActionsOptions {
|
||||
authedFetch: AuthedFetch;
|
||||
profile: Profile | null;
|
||||
session: SessionState | null;
|
||||
defaultKdfIterations: number;
|
||||
disableTotpPassword: string;
|
||||
clearDisableTotpDialog: () => void;
|
||||
@@ -40,6 +53,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
const {
|
||||
authedFetch,
|
||||
profile,
|
||||
session,
|
||||
defaultKdfIterations,
|
||||
disableTotpPassword,
|
||||
clearDisableTotpDialog,
|
||||
@@ -170,6 +184,68 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
return key;
|
||||
},
|
||||
|
||||
async listAccountPasskeys(): Promise<AccountPasskeyCredential[]> {
|
||||
return listAccountPasskeys(authedFetch);
|
||||
},
|
||||
|
||||
async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise<AccountPasskeyCredential> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalizedPassword = String(masterPassword || '');
|
||||
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
|
||||
const normalizedName = String(name || '').trim() || t('txt_account_passkey');
|
||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||
const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash);
|
||||
const pending = await createAccountPasskeyCredential(options);
|
||||
let keySet = null;
|
||||
if (directUnlock) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
keySet = await buildAccountPasskeyPrfKeySet(pending, {
|
||||
symEncKey: session.symEncKey,
|
||||
symMacKey: session.symMacKey,
|
||||
});
|
||||
}
|
||||
const credential = await saveAccountPasskey(authedFetch, {
|
||||
name: normalizedName,
|
||||
token: pending.token,
|
||||
deviceResponse: pending.request,
|
||||
supportsPrf: pending.supportsPrf,
|
||||
keySet,
|
||||
});
|
||||
onNotify('success', t('txt_account_passkey_saved'));
|
||||
return credential;
|
||||
},
|
||||
|
||||
async enableAccountPasskeyDirectUnlock(id: string, masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable'));
|
||||
if (!String(id || '').trim()) throw new Error(t('txt_account_passkey_not_found'));
|
||||
const normalizedPassword = String(masterPassword || '');
|
||||
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||
const options = await getAccountPasskeyUpdateAssertionOptions(authedFetch, derived.hash, id);
|
||||
const assertion = await assertAccountPasskey(options);
|
||||
if (!assertion.prfKey) throw new Error(t('txt_account_passkey_prf_not_available'));
|
||||
const keySet = await buildAccountPasskeyPrfKeySetFromPrfKey(assertion.prfKey, {
|
||||
symEncKey: session.symEncKey,
|
||||
symMacKey: session.symMacKey,
|
||||
});
|
||||
await enableAccountPasskeyDirectUnlockApi(authedFetch, {
|
||||
token: assertion.token,
|
||||
deviceResponse: assertion.deviceResponse,
|
||||
keySet,
|
||||
});
|
||||
onNotify('success', t('txt_account_passkey_direct_unlock_enabled'));
|
||||
},
|
||||
|
||||
async deleteAccountPasskey(id: string, masterPassword: string): Promise<void> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalizedPassword = String(masterPassword || '');
|
||||
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
|
||||
const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations);
|
||||
await deleteAccountPasskeyApi(authedFetch, id, derived.hash);
|
||||
onNotify('success', t('txt_account_passkey_deleted'));
|
||||
},
|
||||
|
||||
async refreshAuthorizedDevices() {
|
||||
await refetchAuthorizedDevices();
|
||||
},
|
||||
@@ -304,6 +380,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onProfileUpdated,
|
||||
onSetConfirm,
|
||||
profile,
|
||||
session?.symEncKey,
|
||||
session?.symMacKey,
|
||||
refetchAuthorizedDevices,
|
||||
refetchTotpStatus,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto';
|
||||
import type { AccountPasskeyPrfOption } from './types';
|
||||
|
||||
const LOGIN_WITH_PRF_SALT = 'passwordless-login';
|
||||
|
||||
export interface AccountPasskeyAssertion {
|
||||
token: string;
|
||||
deviceResponse: Record<string, unknown>;
|
||||
prfKey?: Uint8Array;
|
||||
}
|
||||
|
||||
export interface PendingAccountPasskeyCredential {
|
||||
token: string;
|
||||
createOptions: PublicKeyCredentialCreationOptions;
|
||||
deviceResponse: PublicKeyCredential;
|
||||
request: Record<string, unknown>;
|
||||
supportsPrf: boolean;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyPrfKeySet {
|
||||
encryptedUserKey: string;
|
||||
encryptedPublicKey: string;
|
||||
encryptedPrivateKey: string;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: Uint8Array): string {
|
||||
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const normalized = String(value || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
return base64ToBytes(padded);
|
||||
}
|
||||
|
||||
function toArrayBuffer(bytes: Uint8Array): ArrayBuffer {
|
||||
return toBufferSource(bytes);
|
||||
}
|
||||
|
||||
function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions {
|
||||
if (!options || typeof options !== 'object') throw new Error('Invalid passkey creation options');
|
||||
return {
|
||||
...options,
|
||||
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
|
||||
user: {
|
||||
...options.user,
|
||||
id: toArrayBuffer(base64UrlToBytes(options.user?.id)),
|
||||
},
|
||||
excludeCredentials: Array.isArray(options.excludeCredentials)
|
||||
? options.excludeCredentials.map((credential: any) => ({
|
||||
...credential,
|
||||
id: toArrayBuffer(base64UrlToBytes(credential.id)),
|
||||
}))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions {
|
||||
if (!options || typeof options !== 'object') throw new Error('Invalid passkey assertion options');
|
||||
return {
|
||||
...options,
|
||||
challenge: toArrayBuffer(base64UrlToBytes(options.challenge)),
|
||||
allowCredentials: Array.isArray(options.allowCredentials)
|
||||
? options.allowCredentials.map((credential: any) => ({
|
||||
...credential,
|
||||
id: toArrayBuffer(base64UrlToBytes(credential.id)),
|
||||
}))
|
||||
: options.allowCredentials,
|
||||
};
|
||||
}
|
||||
|
||||
async function getLoginWithPrfSalt(): Promise<Uint8Array> {
|
||||
const hash = await crypto.subtle.digest('SHA-256', toBufferSource(new TextEncoder().encode(LOGIN_WITH_PRF_SALT)));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrfExtension(
|
||||
salt: Uint8Array,
|
||||
credentialIds: Array<string | null | undefined> = []
|
||||
): Record<string, unknown> {
|
||||
const evalInput = { first: salt };
|
||||
const evalByCredential = credentialIds
|
||||
.filter((id): id is string => !!id)
|
||||
.reduce<Record<string, typeof evalInput>>((out, id) => {
|
||||
out[id] = evalInput;
|
||||
return out;
|
||||
}, {});
|
||||
return {
|
||||
prf: {
|
||||
eval: evalInput,
|
||||
...(Object.keys(evalByCredential).length ? { evalByCredential } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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> {
|
||||
const prf = new Uint8Array(prfOutput);
|
||||
const enc = await hkdfExpand(prf, 'enc', 32);
|
||||
const mac = await hkdfExpand(prf, 'mac', 32);
|
||||
const out = new Uint8Array(64);
|
||||
out.set(enc, 0);
|
||||
out.set(mac, 32);
|
||||
return out;
|
||||
}
|
||||
|
||||
function publicKeyCredentialBase(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64Url(new Uint8Array(credential.rawId)),
|
||||
type: credential.type,
|
||||
extensions: {},
|
||||
};
|
||||
}
|
||||
|
||||
function assertionRequest(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
if (!(credential.response instanceof AuthenticatorAssertionResponse)) {
|
||||
throw new Error('Invalid passkey assertion response');
|
||||
}
|
||||
return {
|
||||
...publicKeyCredentialBase(credential),
|
||||
response: {
|
||||
authenticatorData: bytesToBase64Url(new Uint8Array(credential.response.authenticatorData)),
|
||||
signature: bytesToBase64Url(new Uint8Array(credential.response.signature)),
|
||||
clientDataJSON: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
|
||||
userHandle: credential.response.userHandle
|
||||
? bytesToBase64Url(new Uint8Array(credential.response.userHandle))
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function attestationRequest(credential: PublicKeyCredential): Record<string, unknown> {
|
||||
if (!(credential.response instanceof AuthenticatorAttestationResponse)) {
|
||||
throw new Error('Invalid passkey registration response');
|
||||
}
|
||||
const transports = typeof credential.response.getTransports === 'function'
|
||||
? credential.response.getTransports()
|
||||
: undefined;
|
||||
return {
|
||||
...publicKeyCredentialBase(credential),
|
||||
response: {
|
||||
attestationObject: bytesToBase64Url(new Uint8Array(credential.response.attestationObject)),
|
||||
clientDataJson: bytesToBase64Url(new Uint8Array(credential.response.clientDataJSON)),
|
||||
transports,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
const nativeOptions = cloneRequestOptions(response.options);
|
||||
(nativeOptions as any).extensions = {
|
||||
...((nativeOptions as any).extensions || {}),
|
||||
...buildPrfExtension(await getLoginWithPrfSalt(), prfCredentialIdsFromAllowCredentials(nativeOptions)),
|
||||
};
|
||||
const credential = await navigator.credentials.get({ publicKey: nativeOptions });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('No passkey was selected');
|
||||
}
|
||||
const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first;
|
||||
return {
|
||||
token: response.token,
|
||||
deviceResponse: assertionRequest(credential),
|
||||
prfKey: prfResult ? await prfOutputToKey(prfResult) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
const nativeOptions = cloneCreationOptions(response.options);
|
||||
(nativeOptions as any).extensions = {
|
||||
...((nativeOptions as any).extensions || {}),
|
||||
prf: {},
|
||||
};
|
||||
const credential = await navigator.credentials.create({ publicKey: nativeOptions });
|
||||
if (!(credential instanceof PublicKeyCredential)) {
|
||||
throw new Error('No passkey was created');
|
||||
}
|
||||
const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled;
|
||||
return {
|
||||
token: response.token,
|
||||
createOptions: nativeOptions,
|
||||
deviceResponse: credential,
|
||||
request: attestationRequest(credential),
|
||||
supportsPrf,
|
||||
};
|
||||
}
|
||||
|
||||
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');
|
||||
return base64ToBytes(payload);
|
||||
}
|
||||
|
||||
export async function buildAccountPasskeyPrfKeySet(
|
||||
pending: PendingAccountPasskeyCredential,
|
||||
userKey: { symEncKey: string; symMacKey: string }
|
||||
): Promise<AccountPasskeyPrfKeySet> {
|
||||
const rawId = new Uint8Array(pending.deviceResponse.rawId);
|
||||
const credentialId = bytesToBase64Url(rawId);
|
||||
const assertionOptions: PublicKeyCredentialRequestOptions = {
|
||||
challenge: pending.createOptions?.challenge!,
|
||||
rpId: pending.createOptions?.rp?.id,
|
||||
allowCredentials: [{ id: toArrayBuffer(rawId), type: 'public-key' }],
|
||||
timeout: pending.createOptions?.timeout,
|
||||
userVerification: pending.createOptions?.authenticatorSelection?.userVerification,
|
||||
};
|
||||
(assertionOptions as any).extensions = {
|
||||
...buildPrfExtension(await getLoginWithPrfSalt(), [credentialId]),
|
||||
};
|
||||
const assertion = await navigator.credentials.get({ publicKey: assertionOptions });
|
||||
if (!(assertion instanceof PublicKeyCredential)) {
|
||||
throw new Error('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');
|
||||
}
|
||||
return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey);
|
||||
}
|
||||
|
||||
export async function buildAccountPasskeyPrfKeySetFromPrfKey(
|
||||
prfKey: Uint8Array,
|
||||
userKey: { symEncKey: string; symMacKey: string }
|
||||
): Promise<AccountPasskeyPrfKeySet> {
|
||||
const userKeyBytes = new Uint8Array(64);
|
||||
userKeyBytes.set(base64ToBytes(userKey.symEncKey), 0);
|
||||
userKeyBytes.set(base64ToBytes(userKey.symMacKey), 32);
|
||||
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]),
|
||||
hash: 'SHA-1',
|
||||
},
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', pair.publicKey));
|
||||
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', pair.privateKey));
|
||||
const encryptedUserKeyBytes = new Uint8Array(await crypto.subtle.encrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
pair.publicKey,
|
||||
toBufferSource(userKeyBytes)
|
||||
));
|
||||
|
||||
return {
|
||||
encryptedUserKey: `4.${bytesToBase64(encryptedUserKeyBytes)}`,
|
||||
encryptedPublicKey: await encryptBw(publicKey, userKeyBytes.slice(0, 32), userKeyBytes.slice(32, 64)),
|
||||
encryptedPrivateKey: await encryptBw(privateKey, prfKey.slice(0, 32), prfKey.slice(32, 64)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function unlockVaultKeyWithAccountPasskeyPrf(
|
||||
prfKey: Uint8Array,
|
||||
option: AccountPasskeyPrfOption
|
||||
): Promise<{ symEncKey: string; symMacKey: string }> {
|
||||
const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || '';
|
||||
const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || '';
|
||||
if (!encryptedPrivateKey || !encryptedUserKey) {
|
||||
throw new Error('Passkey cannot unlock this vault');
|
||||
}
|
||||
const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64));
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
toBufferSource(privateKeyBytes),
|
||||
{ name: 'RSA-OAEP', hash: 'SHA-1' },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
const userKeyBytes = new Uint8Array(await crypto.subtle.decrypt(
|
||||
{ name: 'RSA-OAEP' },
|
||||
privateKey,
|
||||
toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey))
|
||||
));
|
||||
if (userKeyBytes.length < 64) throw new Error('Invalid passkey vault key');
|
||||
return {
|
||||
symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)),
|
||||
symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)),
|
||||
};
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../cryp
|
||||
import { t, translateServerError } from '../i18n';
|
||||
import type { AuthorizedDevice } from '../types';
|
||||
import type {
|
||||
AccountPasskeyCredential,
|
||||
Profile,
|
||||
SessionState,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
@@ -281,6 +283,40 @@ export async function loginWithPassword(
|
||||
return json;
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyAssertionOptions(): Promise<{ options: unknown; token: string }> {
|
||||
const resp = await fetch('/identity/accounts/webauthn/assertion-options');
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(json?.error_description || json?.error, t('txt_login_failed')));
|
||||
}
|
||||
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
|
||||
return { options: body.options, token: body.token };
|
||||
}
|
||||
|
||||
export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskeyAssertion): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
body.set('grant_type', 'webauthn');
|
||||
body.set('token', assertion.token);
|
||||
body.set('deviceResponse', JSON.stringify(assertion.deviceResponse));
|
||||
body.set('scope', 'api offline_access');
|
||||
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
|
||||
body.set('deviceName', guessDeviceName());
|
||||
body.set('deviceType', '14');
|
||||
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (!resp.ok) return json;
|
||||
return json;
|
||||
}
|
||||
|
||||
function isTransientRefreshStatus(status: number): boolean {
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
}
|
||||
@@ -605,6 +641,135 @@ export async function verifyMasterPassword(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAccountPasskeyCredential(raw: any): AccountPasskeyCredential {
|
||||
return {
|
||||
id: String(raw?.id || raw?.Id || ''),
|
||||
name: String(raw?.name || raw?.Name || ''),
|
||||
prfStatus: Number(raw?.prfStatus ?? raw?.PrfStatus ?? 2) as 0 | 1 | 2,
|
||||
encryptedPublicKey: raw?.encryptedPublicKey ?? raw?.EncryptedPublicKey ?? null,
|
||||
encryptedUserKey: raw?.encryptedUserKey ?? raw?.EncryptedUserKey ?? null,
|
||||
creationDate: raw?.creationDate ?? raw?.CreationDate,
|
||||
revisionDate: raw?.revisionDate ?? raw?.RevisionDate,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskeyCredential[]> {
|
||||
const resp = await authedFetch('/api/webauthn');
|
||||
if (!resp.ok) throw new Error('Failed to load account passkeys');
|
||||
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
|
||||
const rows = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
|
||||
return rows.map(normalizeAccountPasskeyCredential).filter((item) => item.id);
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyAttestationOptions(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<{ options: unknown; token: string }> {
|
||||
const resp = await authedFetch('/api/webauthn/attestation-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||
if (!body.options || !body.token) throw new Error('Invalid passkey creation options');
|
||||
return { options: body.options, token: body.token };
|
||||
}
|
||||
|
||||
export async function getAccountPasskeyUpdateAssertionOptions(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string,
|
||||
credentialId?: string
|
||||
): Promise<{ options: unknown; token: string }> {
|
||||
const resp = await authedFetch('/api/webauthn/assertion-options', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash, credentialId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
||||
}
|
||||
const body = (await parseJson<{ options?: unknown; token?: string }>(resp)) || {};
|
||||
if (!body.options || !body.token) throw new Error('Invalid passkey assertion options');
|
||||
return { options: body.options, token: body.token };
|
||||
}
|
||||
|
||||
export async function saveAccountPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: {
|
||||
name: string;
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
supportsPrf: boolean;
|
||||
keySet?: AccountPasskeyPrfKeySet | null;
|
||||
}
|
||||
): Promise<AccountPasskeyCredential> {
|
||||
const resp = await authedFetch('/api/webauthn', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: payload.name,
|
||||
token: payload.token,
|
||||
deviceResponse: payload.deviceResponse,
|
||||
supportsPrf: payload.supportsPrf,
|
||||
encryptedUserKey: payload.keySet?.encryptedUserKey,
|
||||
encryptedPublicKey: payload.keySet?.encryptedPublicKey,
|
||||
encryptedPrivateKey: payload.keySet?.encryptedPrivateKey,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||
}
|
||||
const body = await parseJson<unknown>(resp);
|
||||
return normalizeAccountPasskeyCredential(body);
|
||||
}
|
||||
|
||||
export async function enableAccountPasskeyDirectUnlock(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: {
|
||||
token: string;
|
||||
deviceResponse: unknown;
|
||||
keySet: AccountPasskeyPrfKeySet;
|
||||
}
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch('/api/webauthn', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: payload.token,
|
||||
deviceResponse: payload.deviceResponse,
|
||||
encryptedUserKey: payload.keySet.encryptedUserKey,
|
||||
encryptedPublicKey: payload.keySet.encryptedPublicKey,
|
||||
encryptedPrivateKey: payload.keySet.encryptedPrivateKey,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskey(
|
||||
authedFetch: AuthedFetch,
|
||||
id: string,
|
||||
masterPasswordHash: string
|
||||
): Promise<void> {
|
||||
const resp = await authedFetch(`/api/webauthn/${encodeURIComponent(id)}/delete`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
||||
const resp = await authedFetch('/api/accounts/revision-date');
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -96,6 +96,7 @@ export interface AdminBackupImportCounts {
|
||||
users: number;
|
||||
domainSettings?: number;
|
||||
userRevisions: number;
|
||||
webauthnCredentials?: number;
|
||||
folders: number;
|
||||
ciphers: number;
|
||||
attachments: number;
|
||||
|
||||
+107
-19
@@ -1,15 +1,21 @@
|
||||
import {
|
||||
createAuthedFetch,
|
||||
deriveLoginHashLocally,
|
||||
getAccountPasskeyAssertionOptions,
|
||||
getProfile,
|
||||
loadProfileSnapshot,
|
||||
loadSession,
|
||||
loginWithAccountPasskeyAssertion,
|
||||
loginWithPassword,
|
||||
refreshAccessToken,
|
||||
recoverTwoFactor,
|
||||
registerAccount,
|
||||
unlockVaultKey,
|
||||
} from '@/lib/api/auth';
|
||||
import {
|
||||
assertAccountPasskey,
|
||||
unlockVaultKeyWithAccountPasskeyPrf,
|
||||
} from '@/lib/account-passkeys';
|
||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||
import { t, translateServerError } from '@/lib/i18n';
|
||||
import {
|
||||
@@ -21,7 +27,7 @@ import {
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import { probeNodeWardenService } from '@/lib/network-status';
|
||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
email: string;
|
||||
@@ -30,6 +36,12 @@ export interface PendingTotp {
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export interface PendingPasskeyPassword {
|
||||
token: TokenSuccess;
|
||||
email: string;
|
||||
kdfIterations: number;
|
||||
}
|
||||
|
||||
export type JwtUnsafeReason = 'missing' | 'default' | 'too_short';
|
||||
|
||||
export interface BootstrapAppResult {
|
||||
@@ -61,6 +73,11 @@ export type PasswordLoginResult =
|
||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export type PasskeyLoginResult =
|
||||
| { kind: 'success'; login: CompletedLogin }
|
||||
| { kind: 'password'; pendingPasskeyPassword: PendingPasskeyPassword }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export interface RecoverTwoFactorResult {
|
||||
login: CompletedLogin | null;
|
||||
newRecoveryCode: string | null;
|
||||
@@ -92,6 +109,7 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
|
||||
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) return session;
|
||||
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||
}
|
||||
|
||||
@@ -107,16 +125,6 @@ function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
|
||||
if (typeof AbortController === 'undefined') return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return {
|
||||
controller,
|
||||
cancel: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
function readWindowBootstrap(): WebBootstrapResponse {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
||||
@@ -270,8 +278,10 @@ export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
const serviceReachable = await probeNodeWardenService();
|
||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||
let serviceReachable = true;
|
||||
if (hasOfflineUnlock) {
|
||||
serviceReachable = await probeNodeWardenService();
|
||||
if (!serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
@@ -282,7 +292,7 @@ export async function hydrateLockedSession(
|
||||
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
if (hasOfflineUnlock && !serviceReachable) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
@@ -345,6 +355,36 @@ export async function completeLogin(
|
||||
};
|
||||
}
|
||||
|
||||
function readPasskeyPrfOption(token: TokenSuccess): AccountPasskeyPrfOption | null {
|
||||
const options = (token.UserDecryptionOptions || token.userDecryptionOptions || null) as any;
|
||||
return options?.WebAuthnPrfOption || options?.webAuthnPrfOption || null;
|
||||
}
|
||||
|
||||
async function completeLoginWithVaultKeys(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
keys: { symEncKey: string; symMacKey: string }
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
const baseSession: SessionState = {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
email: normalizedEmail,
|
||||
authMode: token.web_session ? 'web-cookie' : 'token',
|
||||
};
|
||||
const tempFetch = createAuthedFetch(
|
||||
() => baseSession,
|
||||
() => {}
|
||||
);
|
||||
const profile = buildTransientProfile(token, normalizedEmail, fallbackProfile);
|
||||
return {
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
profilePromise: getProfile(tempFetch),
|
||||
};
|
||||
}
|
||||
|
||||
export async function performPasswordLogin(
|
||||
email: string,
|
||||
password: string,
|
||||
@@ -380,6 +420,58 @@ export async function performPasswordLogin(
|
||||
};
|
||||
}
|
||||
|
||||
export async function performPasskeyLogin(fallbackIterations: number): Promise<PasskeyLoginResult> {
|
||||
try {
|
||||
const options = await getAccountPasskeyAssertionOptions();
|
||||
const assertion = await assertAccountPasskey(options);
|
||||
const token = await loginWithAccountPasskeyAssertion(assertion);
|
||||
|
||||
if (!('access_token' in token) || !token.access_token) {
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
return {
|
||||
kind: 'error',
|
||||
message: translateServerError(tokenError.error_description || tokenError.error, t('txt_login_failed')),
|
||||
};
|
||||
}
|
||||
|
||||
const email = (decodeAccessTokenClaims(token.access_token).email || '').trim().toLowerCase();
|
||||
if (!email) {
|
||||
return { kind: 'error', message: t('txt_login_failed') };
|
||||
}
|
||||
|
||||
const prfOption = readPasskeyPrfOption(token);
|
||||
if (prfOption && assertion.prfKey) {
|
||||
const keys = await unlockVaultKeyWithAccountPasskeyPrf(assertion.prfKey, prfOption);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLoginWithVaultKeys(token, email, keys),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'password',
|
||||
pendingPasskeyPassword: {
|
||||
token,
|
||||
email,
|
||||
kdfIterations: kdfIterationsFromLogin(token, fallbackIterations),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_login_failed'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function completePasskeyPasswordLogin(
|
||||
pending: PendingPasskeyPassword,
|
||||
password: string
|
||||
): Promise<CompletedLogin> {
|
||||
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
|
||||
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations);
|
||||
}
|
||||
|
||||
export async function performTotpLogin(
|
||||
pendingTotp: PendingTotp,
|
||||
totpCode: string,
|
||||
@@ -479,22 +571,18 @@ export async function performUnlock(
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
signal: abortable?.controller.signal,
|
||||
});
|
||||
} catch {
|
||||
if (hasOfflineUnlock) {
|
||||
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||
return unlockOffline();
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
} finally {
|
||||
abortable?.cancel();
|
||||
}
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
|
||||
@@ -631,6 +631,29 @@ const en: Record<string, string> = {
|
||||
"txt_passkey": "Passkey",
|
||||
"txt_passkeys": "Passkeys",
|
||||
"txt_passkey_created_at_value": "Created on {value}",
|
||||
"txt_account_passkey": "Account passkey",
|
||||
"txt_account_passkeys": "Account passkeys",
|
||||
"txt_account_passkey_mode": "Unlock mode",
|
||||
"txt_account_passkey_direct_unlock_mode": "Direct vault unlock",
|
||||
"txt_account_passkey_direct_unlock_help": "Unlocks the vault with this passkey when PRF is available.",
|
||||
"txt_account_passkey_login_only_help": "Verifies the account with passkey, then asks for master password.",
|
||||
"txt_account_passkey_name_placeholder": "This device",
|
||||
"txt_account_passkey_saved": "Account passkey saved",
|
||||
"txt_account_passkey_deleted": "Account passkey deleted",
|
||||
"txt_account_passkeys_load_failed": "Failed to load account passkeys",
|
||||
"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_add_account_passkey": "Add account passkey",
|
||||
"txt_delete_account_passkey": "Delete account passkey",
|
||||
"txt_direct_unlock": "Direct unlock",
|
||||
"txt_enable_passkey_direct_unlock": "Enable direct unlock",
|
||||
"txt_login_only": "Login only",
|
||||
"txt_login_with_passkey": "Log in with passkey",
|
||||
"txt_no_account_passkeys": "No account passkeys",
|
||||
"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_phone": "Phone",
|
||||
"txt_please_input_email_and_password": "Please input email and password",
|
||||
"txt_please_input_master_password": "Please input master password",
|
||||
|
||||
@@ -631,6 +631,29 @@ const es: Record<string, string> = {
|
||||
"txt_passkey": "Clave de acceso",
|
||||
"txt_passkeys": "Claves de acceso",
|
||||
"txt_passkey_created_at_value": "Creado el {value}",
|
||||
"txt_account_passkey": "Passkey de cuenta",
|
||||
"txt_account_passkeys": "Passkeys de cuenta",
|
||||
"txt_account_passkey_mode": "Modo de desbloqueo",
|
||||
"txt_account_passkey_direct_unlock_mode": "Desbloqueo directo",
|
||||
"txt_account_passkey_direct_unlock_help": "Desbloquea la bóveda con esta passkey cuando PRF está disponible.",
|
||||
"txt_account_passkey_login_only_help": "Verifica la cuenta con passkey y luego pide la contraseña maestra.",
|
||||
"txt_account_passkey_name_placeholder": "Este dispositivo",
|
||||
"txt_account_passkey_saved": "Passkey de cuenta guardada",
|
||||
"txt_account_passkey_deleted": "Passkey de cuenta eliminada",
|
||||
"txt_account_passkeys_load_failed": "Error al cargar passkeys de cuenta",
|
||||
"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_add_account_passkey": "Añadir passkey de cuenta",
|
||||
"txt_delete_account_passkey": "Eliminar passkey de cuenta",
|
||||
"txt_direct_unlock": "Desbloqueo directo",
|
||||
"txt_enable_passkey_direct_unlock": "Activar desbloqueo directo",
|
||||
"txt_login_only": "Solo inicio de sesión",
|
||||
"txt_login_with_passkey": "Iniciar sesión con passkey",
|
||||
"txt_no_account_passkeys": "Sin passkeys de cuenta",
|
||||
"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_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",
|
||||
|
||||
@@ -631,6 +631,29 @@ const ru: Record<string, string> = {
|
||||
"txt_passkey": "Ключ доступа",
|
||||
"txt_passkeys": "Ключи доступа",
|
||||
"txt_passkey_created_at_value": "Создано {value}",
|
||||
"txt_account_passkey": "Passkey аккаунта",
|
||||
"txt_account_passkeys": "Passkeys аккаунта",
|
||||
"txt_account_passkey_mode": "Режим разблокировки",
|
||||
"txt_account_passkey_direct_unlock_mode": "Прямая разблокировка",
|
||||
"txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этой passkey, когда доступен PRF.",
|
||||
"txt_account_passkey_login_only_help": "Проверяет аккаунт passkey, затем запрашивает мастер-пароль.",
|
||||
"txt_account_passkey_name_placeholder": "Это устройство",
|
||||
"txt_account_passkey_saved": "Passkey аккаунта сохранена",
|
||||
"txt_account_passkey_deleted": "Passkey аккаунта удалена",
|
||||
"txt_account_passkeys_load_failed": "Не удалось загрузить passkeys аккаунта",
|
||||
"txt_account_passkey_not_found": "Passkey аккаунта не найдена",
|
||||
"txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ",
|
||||
"txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена",
|
||||
"txt_add_account_passkey": "Добавить passkey аккаунта",
|
||||
"txt_delete_account_passkey": "Удалить passkey аккаунта",
|
||||
"txt_direct_unlock": "Прямая разблокировка",
|
||||
"txt_enable_passkey_direct_unlock": "Включить прямую разблокировку",
|
||||
"txt_login_only": "Только вход",
|
||||
"txt_login_with_passkey": "Войти с passkey",
|
||||
"txt_no_account_passkeys": "Нет passkeys аккаунта",
|
||||
"txt_passkey_name": "Название passkey",
|
||||
"txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.",
|
||||
"txt_prf_not_supported": "PRF не поддерживается",
|
||||
"txt_phone": "Телефон",
|
||||
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
|
||||
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
|
||||
|
||||
@@ -631,6 +631,29 @@ const zhCN: Record<string, string> = {
|
||||
"txt_passkey": "通行密钥",
|
||||
"txt_passkeys": "通行密钥",
|
||||
"txt_passkey_created_at_value": "创建于 {value}",
|
||||
"txt_account_passkey": "账号通行密钥",
|
||||
"txt_account_passkeys": "账号通行密钥",
|
||||
"txt_account_passkey_mode": "解锁模式",
|
||||
"txt_account_passkey_direct_unlock_mode": "直接解锁密码库",
|
||||
"txt_account_passkey_direct_unlock_help": "支持 PRF 时,用这把通行密钥直接解锁密码库。",
|
||||
"txt_account_passkey_login_only_help": "先用通行密钥验证账号,再输入主密码解锁。",
|
||||
"txt_account_passkey_name_placeholder": "这台设备",
|
||||
"txt_account_passkey_saved": "账号通行密钥已保存",
|
||||
"txt_account_passkey_deleted": "账号通行密钥已删除",
|
||||
"txt_account_passkeys_load_failed": "加载账号通行密钥失败",
|
||||
"txt_account_passkey_not_found": "未找到账号通行密钥",
|
||||
"txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥",
|
||||
"txt_account_passkey_direct_unlock_enabled": "已开启直接解锁",
|
||||
"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_no_account_passkeys": "暂无账号通行密钥",
|
||||
"txt_passkey_name": "通行密钥名称",
|
||||
"txt_passkey_requires_master_password": "Passkey 已验证,请输入主密码解锁密码库。",
|
||||
"txt_prf_not_supported": "不支持 PRF",
|
||||
"txt_phone": "电话",
|
||||
"txt_please_input_email_and_password": "请输入邮箱和密码",
|
||||
"txt_please_input_master_password": "请输入主密码",
|
||||
|
||||
@@ -631,6 +631,29 @@ const zhTW: Record<string, string> = {
|
||||
"txt_passkey": "通行密鑰",
|
||||
"txt_passkeys": "通行密鑰",
|
||||
"txt_passkey_created_at_value": "創建於 {value}",
|
||||
"txt_account_passkey": "賬號通行密鑰",
|
||||
"txt_account_passkeys": "賬號通行密鑰",
|
||||
"txt_account_passkey_mode": "解鎖模式",
|
||||
"txt_account_passkey_direct_unlock_mode": "直接解鎖密碼庫",
|
||||
"txt_account_passkey_direct_unlock_help": "支持 PRF 時,用這把通行密鑰直接解鎖密碼庫。",
|
||||
"txt_account_passkey_login_only_help": "先用通行密鑰驗證賬號,再輸入主密碼解鎖。",
|
||||
"txt_account_passkey_name_placeholder": "這台設備",
|
||||
"txt_account_passkey_saved": "賬號通行密鑰已保存",
|
||||
"txt_account_passkey_deleted": "賬號通行密鑰已刪除",
|
||||
"txt_account_passkeys_load_failed": "加載賬號通行密鑰失敗",
|
||||
"txt_account_passkey_not_found": "未找到賬號通行密鑰",
|
||||
"txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰",
|
||||
"txt_account_passkey_direct_unlock_enabled": "已開啟直接解鎖",
|
||||
"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_no_account_passkeys": "暫無賬號通行密鑰",
|
||||
"txt_passkey_name": "通行密鑰名稱",
|
||||
"txt_passkey_requires_master_password": "Passkey 已驗證,請輸入主密碼解鎖密碼庫。",
|
||||
"txt_prf_not_supported": "不支持 PRF",
|
||||
"txt_phone": "電話",
|
||||
"txt_please_input_email_and_password": "請輸入郵箱和密碼",
|
||||
"txt_please_input_master_password": "請輸入主密碼",
|
||||
|
||||
@@ -6,14 +6,14 @@ const listeners = new Set<(status: NetworkStatus) => void>();
|
||||
let currentStatus: NetworkStatus = getInitialNetworkStatus();
|
||||
let pendingProbe: Promise<boolean> | null = null;
|
||||
let lastProbeAt = 0;
|
||||
let lastProbeResult = false;
|
||||
let lastProbeResult = currentStatus === 'online';
|
||||
|
||||
export function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
export function getInitialNetworkStatus(): NetworkStatus {
|
||||
return 'offline';
|
||||
return browserReportsOffline() ? 'offline' : 'online';
|
||||
}
|
||||
|
||||
export function getCurrentNetworkStatus(): NetworkStatus {
|
||||
@@ -51,7 +51,7 @@ export async function probeNodeWardenService(): Promise<boolean> {
|
||||
: 0;
|
||||
|
||||
pendingProbe = (async () => {
|
||||
const response = await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
|
||||
await fetch(`/api/web-bootstrap?statusProbe=${Date.now()}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
@@ -61,7 +61,9 @@ export async function probeNodeWardenService(): Promise<boolean> {
|
||||
},
|
||||
signal: controller?.signal,
|
||||
});
|
||||
return response.ok;
|
||||
// Any same-origin HTTP response proves the server is reachable. A 4xx/5xx
|
||||
// response may be an application problem, but it is not offline mode.
|
||||
return true;
|
||||
})()
|
||||
.catch(() => false)
|
||||
.then((result) => {
|
||||
|
||||
@@ -43,7 +43,7 @@ function parseRecord(raw: string | null): OfflineUnlockRecord | null {
|
||||
name: email,
|
||||
key: '',
|
||||
privateKey: null,
|
||||
role: 'user',
|
||||
role: 'user' as const,
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
|
||||
@@ -328,6 +328,37 @@ export interface TokenError {
|
||||
TwoFactorProviders?: unknown;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
prfStatus: 0 | 1 | 2;
|
||||
encryptedPublicKey?: string | null;
|
||||
encryptedUserKey?: string | null;
|
||||
creationDate?: string;
|
||||
revisionDate?: string;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyAssertionOptionsResponse {
|
||||
options: PublicKeyCredentialRequestOptions;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyCreationOptionsResponse {
|
||||
options: PublicKeyCredentialCreationOptions;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface AccountPasskeyPrfOption {
|
||||
EncryptedPrivateKey?: string;
|
||||
EncryptedUserKey?: string;
|
||||
CredentialId?: string;
|
||||
Transports?: string[];
|
||||
encryptedPrivateKey?: string;
|
||||
encryptedUserKey?: string;
|
||||
credentialId?: string;
|
||||
transports?: string[];
|
||||
}
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning';
|
||||
|
||||
@@ -1062,6 +1062,68 @@
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.account-passkey-mode-field {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.account-passkey-toggle {
|
||||
@apply flex min-h-[44px] items-center gap-2 rounded-lg border px-3 text-sm font-extrabold;
|
||||
border-color: var(--line);
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--panel-2));
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.account-passkey-toggle input {
|
||||
@apply h-4 w-4 shrink-0;
|
||||
accent-color: var(--primary);
|
||||
}
|
||||
|
||||
.account-passkeys-list {
|
||||
@apply mt-3 grid gap-2;
|
||||
}
|
||||
|
||||
.account-passkey-row {
|
||||
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
|
||||
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||
border-color: var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.account-passkey-main {
|
||||
@apply grid min-w-0 gap-1;
|
||||
}
|
||||
|
||||
.account-passkey-main strong,
|
||||
.account-passkey-main small {
|
||||
@apply min-w-0 overflow-hidden text-ellipsis whitespace-nowrap;
|
||||
}
|
||||
|
||||
.account-passkey-main small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-passkey-status {
|
||||
@apply inline-flex min-h-7 shrink-0 items-center rounded-full px-2.5 text-xs font-extrabold;
|
||||
border: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.account-passkey-status-0 {
|
||||
border-color: color-mix(in srgb, var(--success) 28%, var(--line));
|
||||
background: color-mix(in srgb, var(--success) 9%, var(--panel));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.account-passkey-status-1 {
|
||||
border-color: color-mix(in srgb, var(--primary) 30%, var(--line));
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
|
||||
color: var(--primary-strong);
|
||||
}
|
||||
|
||||
.account-passkey-actions {
|
||||
@apply justify-end;
|
||||
}
|
||||
|
||||
.settings-module-placeholder {
|
||||
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -933,6 +933,21 @@
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.account-passkey-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.account-passkey-status {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.account-passkey-actions,
|
||||
.account-passkey-actions .btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-module .totp-grid {
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
|
||||
Reference in New Issue
Block a user