import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2, requireWebCrypto, WebCryptoUnavailableError, } from '../crypto'; import { t, translateServerError } from '../i18n'; import type { AuthorizedDevice } from '../types'; import type { AccountPasskeyCredential, Profile, SessionState, TokenError, TokenSuccess, TwoFactorPasskeySettings, YubiKeyOtpSettings, } from '../types'; import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys'; import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status'; import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; const SESSION_KEY = 'nodewarden.web.session.v4'; const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1'; const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session'; export interface PreloginResult { hash: string; masterKey: Uint8Array; kdfIterations: number; } export interface PreloginKdfConfig { kdfType: number; kdfIterations: number; kdfMemory: number | null; kdfParallelism: number | null; } interface PersistedSessionState { email: string; authMode: 'token' | 'web-cookie'; } interface RefreshFailure { ok: false; transient: boolean; error: string; retryAfterMs?: number; } interface RefreshSuccess { ok: true; token: TokenSuccess; } type RefreshResult = RefreshFailure | RefreshSuccess; const pendingRefreshes = new Map>(); function randomHex(length: number): string { const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2)))); return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length); } function getOrCreateDeviceIdentifier(): string { const current = (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); if (current) return current; const next = `${randomHex(8)}-${randomHex(4)}-${randomHex(4)}-${randomHex(4)}-${randomHex(12)}`; localStorage.setItem(DEVICE_IDENTIFIER_KEY, next); return next; } function guessDeviceName(): string { const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase(); const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim(); const browser = ua.includes('edg/') ? 'Edge' : ua.includes('chrome/') ? 'Chrome' : ua.includes('firefox/') ? 'Firefox' : ua.includes('safari/') ? 'Safari' : 'Browser'; const os = ua.includes('windows') ? 'Windows' : ua.includes('mac os') ? 'macOS' : ua.includes('linux') ? 'Linux' : ua.includes('android') ? 'Android' : ua.includes('iphone') || ua.includes('ipad') ? 'iOS' : platform || 'Unknown OS'; return `${browser} on ${os}`.slice(0, 128); } function getRememberTwoFactorToken(): string | null { const token = (localStorage.getItem(TOTP_REMEMBER_TOKEN_KEY) || '').trim(); return token || null; } function saveRememberTwoFactorToken(token: string | undefined): void { const normalized = String(token || '').trim(); if (!normalized) return; localStorage.setItem(TOTP_REMEMBER_TOKEN_KEY, normalized); } function clearRememberTwoFactorToken(): void { localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY); } function hasTwoFactorChallenge(error: TokenError): boolean { const providers = error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders; const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2; if (Array.isArray(providers)) return providers.length > 0; if (providers && typeof providers === 'object') return Object.keys(providers as Record).length > 0; if (Array.isArray(providers2)) return providers2.length > 0; if (providers2 && typeof providers2 === 'object') return Object.keys(providers2 as Record).length > 0; return providers != null || providers2 != null; } export function loadSession(): SessionState | null { try { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Partial & Partial; if (parsed.email && (parsed.accessToken || parsed.refreshToken)) { const authMode = parsed.authMode === 'web-cookie' ? 'web-cookie' : 'token'; saveSession({ email: parsed.email, authMode }); return { email: parsed.email, authMode, }; } if (parsed.authMode === 'web-cookie' && parsed.email) { return { email: parsed.email, authMode: 'web-cookie', }; } if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) { return { email: parsed.email, authMode: 'token', }; } return null; } catch { return null; } } export function saveSession(session: SessionState | null): void { if (!session) { localStorage.removeItem(SESSION_KEY); return; } const persisted: PersistedSessionState = { email: session.email, authMode: session.authMode === 'token' ? 'token' : 'web-cookie', }; localStorage.setItem(SESSION_KEY, JSON.stringify(persisted)); } export function loadProfileSnapshot(email?: string | null): Profile | null { try { const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY); if (!raw) return null; const parsed = JSON.parse(raw) as Profile; if (!parsed?.email) return null; if (email && parsed.email !== email) return null; const snapshot = stripProfileSecrets(parsed); localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot)); return snapshot; } catch { return null; } } export function saveProfileSnapshot(profile: Profile | null): void { if (!profile) return; const nextSnapshot = stripProfileSecrets(profile); try { const rawExisting = localStorage.getItem(PROFILE_SNAPSHOT_KEY); if (rawExisting) { const existing = stripProfileSecrets(JSON.parse(rawExisting) as Profile); if ( existing && existing.email === nextSnapshot?.email && existing.role === 'admin' && nextSnapshot?.role !== 'admin' ) { localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify({ ...nextSnapshot, role: 'admin', })); return; } } } catch { // Fall back to writing the normalized snapshot below. } localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(nextSnapshot)); } export function clearProfileSnapshot(): void { localStorage.removeItem(PROFILE_SNAPSHOT_KEY); } export function stripProfileSecrets(profile: Profile | null): Profile | null { if (!profile) return null; return { id: String(profile.id || ''), email: String(profile.email || ''), name: String(profile.name || ''), role: profile.role === 'admin' ? 'admin' : 'user', masterPasswordHint: profile.masterPasswordHint ?? null, publicKey: profile.publicKey ?? null, key: '', privateKey: null, }; } export function getCurrentDeviceIdentifier(): string { return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); } export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise { const pre = await fetch('/identity/accounts/prelogin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase() }), }); if (!pre.ok) throw new Error('prelogin failed'); const data = (await parseJson<{ kdfIterations?: number }>(pre)) || {}; const iterations = Number(data.kdfIterations || fallbackIterations); const masterKey = await pbkdf2(password, email.toLowerCase(), iterations, 32); const hash = await pbkdf2(masterKey, password, 1, 32); return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations }; } export async function deriveLoginHashLocally( email: string, password: string, fallbackIterations: number ): Promise { const normalizedEmail = String(email || '').trim().toLowerCase(); const iterations = Number(fallbackIterations || 600000); const masterKey = await pbkdf2(password, normalizedEmail, iterations, 32); const hash = await pbkdf2(masterKey, password, 1, 32); return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations }; } export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise { const normalized = String(email || '').trim().toLowerCase(); if (!normalized) throw new Error('Email is required'); const pre = await fetch('/identity/accounts/prelogin', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: normalized }), }); if (!pre.ok) throw new Error('prelogin failed'); const data = (await parseJson<{ kdf?: number; kdfIterations?: number; kdfMemory?: number | null; kdfParallelism?: number | null }>(pre)) || {}; return { kdfType: Number(data.kdf ?? 0) || 0, kdfIterations: Number(data.kdfIterations || fallbackIterations), kdfMemory: data.kdfMemory == null ? null : Number(data.kdfMemory), kdfParallelism: data.kdfParallelism == null ? null : Number(data.kdfParallelism), }; } export async function loginWithPassword( email: string, passwordHash: string, options?: { totpCode?: string; twoFactorProvider?: number; rememberDevice?: boolean; useRememberToken?: boolean; signal?: AbortSignal; } ): Promise { const body = new URLSearchParams(); body.set('grant_type', 'password'); body.set('username', email.toLowerCase()); body.set('password', passwordHash); body.set('scope', 'api offline_access'); body.set('deviceIdentifier', getOrCreateDeviceIdentifier()); body.set('deviceName', guessDeviceName()); body.set('deviceType', '14'); const rememberedToken = options?.useRememberToken ? getRememberTwoFactorToken() : null; if (rememberedToken) { body.set('twoFactorProvider', '5'); body.set('twoFactorToken', rememberedToken); } else if (options?.totpCode) { body.set('twoFactorProvider', String(options.twoFactorProvider ?? 0)); body.set('twoFactorToken', options.totpCode); if (options.rememberDevice) { body.set('twoFactorRemember', '1'); } } const resp = await fetch('/identity/connect/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', [WEB_SESSION_HEADER]: '1', }, body: body.toString(), signal: options?.signal, }); const json = (await parseJson(resp)) || {}; if (resp.ok) { saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken); } else if (rememberedToken && hasTwoFactorChallenge(json)) { clearRememberTwoFactorToken(); } if (!resp.ok) return json; 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(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 { 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(resp)) || {}; if (!resp.ok) return json; return json; } function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean { return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request'); } export async function refreshAccessToken(session: SessionState): Promise { const body = new URLSearchParams(); body.set('grant_type', 'refresh_token'); if (session.authMode !== 'web-cookie' && session.refreshToken) { body.set('refresh_token', session.refreshToken); } try { const resp = await fetch('/identity/connect/token', { method: 'POST', cache: 'no-store', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), }, body: body.toString(), }); if (!resp.ok) { const json = await parseJson(resp); const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0); return { ok: false, transient: !isPermanentRefreshFailure(resp.status, json?.error), error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')), ...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0 ? { retryAfterMs: retryAfterSeconds * 1000 } : {}), }; } const json = await parseJson(resp); if (!json?.access_token) { return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') }; } return { ok: true, token: json }; } catch (error) { return { ok: false, transient: true, error: error instanceof Error ? error.message : 'Network error', }; } } function refreshKey(session: SessionState): string { if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`; return `token:${session.refreshToken || ''}`; } function refreshAccessTokenOnce(session: SessionState): Promise { const key = refreshKey(session); const existing = pendingRefreshes.get(key); if (existing) return existing; const request = refreshAccessToken(session).finally(() => { if (pendingRefreshes.get(key) === request) { pendingRefreshes.delete(key); } }); pendingRefreshes.set(key, request); return request; } export async function revokeCurrentSession(session: SessionState | null): Promise { const body = new URLSearchParams(); if (session?.authMode !== 'web-cookie' && session?.refreshToken) { body.set('token', session.refreshToken); } await fetch('/identity/connect/revocation', { method: 'POST', cache: 'no-store', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}), ...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), }, body: body.toString(), }).catch(() => undefined); } export async function registerAccount(args: { email: string; name: string; password: string; masterPasswordHint?: string; inviteCode?: string; fallbackIterations: number; }): Promise<{ ok: true } | { ok: false; message: string }> { try { const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args; const webCrypto = requireWebCrypto(); const masterKey = await pbkdf2(password, email, fallbackIterations, 32); const masterHash = await pbkdf2(masterKey, password, 1, 32); const encKey = await hkdfExpand(masterKey, 'enc', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32); const sym = webCrypto.getRandomValues(new Uint8Array(64)); const encryptedVaultKey = await encryptBw(sym, encKey, macKey); const keyPair = await webCrypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-1', }, true, ['encrypt', 'decrypt'] ); const publicKey = new Uint8Array(await webCrypto.subtle.exportKey('spki', keyPair.publicKey)); const privateKey = new Uint8Array(await webCrypto.subtle.exportKey('pkcs8', keyPair.privateKey)); const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64)); const resp = await fetch('/api/accounts/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase(), name, masterPasswordHint: String(masterPasswordHint || '').trim() || undefined, masterPasswordHash: bytesToBase64(masterHash), key: encryptedVaultKey, kdf: 0, kdfIterations: fallbackIterations, inviteCode: inviteCode || undefined, keys: { publicKey: bytesToBase64(publicKey), encryptedPrivateKey, }, }), }); if (!resp.ok) { const json = await parseJson(resp); return { ok: false, message: translateServerError(json?.error_description || json?.error, t('txt_register_failed')) }; } return { ok: true }; } catch (error) { if (error instanceof WebCryptoUnavailableError) { return { ok: false, message: t('txt_web_crypto_unavailable') }; } return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') }; } } export async function getPasswordHint(email: string): Promise<{ masterPasswordHint: string | null }> { const resp = await fetch('/api/accounts/password-hint', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.trim().toLowerCase() }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_password_hint_load_failed'))); } const body = (await parseJson<{ masterPasswordHint?: string | null }>(resp)) || {}; return { masterPasswordHint: body.masterPasswordHint ?? null }; } export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) { return async function authedFetch(input: string, init: RequestInit = {}): Promise { const retryableRequest = async (headers: Headers): Promise => { const maxAttempts = 3; let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { const response = await fetch(input, { ...init, headers }); recordNodeWardenReachable(); if (response.status !== 429 && (response.status < 500 || response.status >= 600)) { return response; } lastError = new Error(`HTTP ${response.status}`); if (attempt === maxAttempts - 1) { return response; } } catch (error) { lastError = error; if (attempt === maxAttempts - 1) { recordNodeWardenUnreachable(); throw error; } } const delayMs = 250 * (2 ** attempt) + Math.floor(Math.random() * 120); await new Promise((resolve) => window.setTimeout(resolve, delayMs)); } throw lastError instanceof Error ? lastError : new Error('Request failed'); }; const session = getSession(); if (!session?.accessToken) throw new Error(t('txt_offline_vault_readonly')); const headers = new Headers(init.headers || {}); headers.set('Authorization', `Bearer ${session.accessToken}`); let resp = await retryableRequest(headers); if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp; const latest = getSession(); if (latest?.accessToken && latest.accessToken !== session.accessToken) { const latestHeaders = new Headers(init.headers || {}); latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`); resp = await retryableRequest(latestHeaders); if (resp.status !== 401) return resp; } const refreshSource = latest || session; const refreshed = await refreshAccessTokenOnce(refreshSource); if (!refreshed.ok) { if (refreshed.transient) { throw new Error(refreshed.error || t('txt_session_refresh_failed')); } setSession(null); throw new Error(t('txt_session_refresh_failed')); } const nextSession: SessionState = { ...refreshSource, accessToken: refreshed.token.access_token, refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken, authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'), }; setSession(nextSession); saveSession(nextSession); const retryHeaders = new Headers(init.headers || {}); retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`); resp = await retryableRequest(retryHeaders); return resp; }; } export async function getProfile(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/accounts/profile'); if (!resp.ok) throw new Error('Failed to load profile'); const body = await parseJson(resp); if (!body) throw new Error('Invalid profile'); return body; } export async function updateProfile( authedFetch: AuthedFetch, payload: { masterPasswordHint: string } ): Promise { const resp = await authedFetch('/api/accounts/profile', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHint: String(payload.masterPasswordHint || '').trim() || null, }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed'))); } const body = await parseJson(resp); if (!body) throw new Error('Invalid profile'); return body; } export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> { const encKey = await hkdfExpand(masterKey, 'enc', 32); const macKey = await hkdfExpand(masterKey, 'mac', 32); const keyBytes = await decryptBw(profileKey, encKey, macKey); if (!keyBytes || keyBytes.length < 64) throw new Error('Invalid profile key'); return { symEncKey: bytesToBase64(keyBytes.slice(0, 32)), symMacKey: bytesToBase64(keyBytes.slice(32, 64)), }; } export async function changeMasterPassword( authedFetch: AuthedFetch, args: { email: string; currentPassword: string; newPassword: string; currentIterations: number; profileKey: string; } ): Promise { const current = await deriveLoginHash(args.email, args.currentPassword, args.currentIterations); const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32); const oldMac = await hkdfExpand(current.masterKey, 'mac', 32); const userSym = await decryptBw(args.profileKey, oldEnc, oldMac); if (userSym.length !== 64) { throw new Error('Invalid profile key'); } const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32); const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32); const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32); const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32); const newKey = await encryptBw(userSym, nextEnc, nextMac); const newMasterPasswordHash = bytesToBase64(nextHash); const resp = await authedFetch('/api/accounts/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash: current.hash, authenticationData: { kdf: { kdfType: 0, iterations: current.kdfIterations, memory: null, parallelism: null, }, masterPasswordAuthenticationHash: newMasterPasswordHash, salt: args.email.trim().toLowerCase(), }, unlockData: { kdf: { kdfType: 0, iterations: current.kdfIterations, memory: null, parallelism: null, }, masterKeyWrappedUserKey: newKey, salt: args.email.trim().toLowerCase(), }, }), }); if (!resp.ok) throw new Error('Change master password failed'); } export async function setTotp( authedFetch: AuthedFetch, payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string } ): Promise { const resp = await authedFetch('/api/accounts/totp', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_totp_update_failed'))); } } function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings { return { enabled: !!(raw?.enabled ?? raw?.Enabled), keys: [ String(raw?.key1 ?? raw?.Key1 ?? ''), String(raw?.key2 ?? raw?.Key2 ?? ''), String(raw?.key3 ?? raw?.Key3 ?? ''), String(raw?.key4 ?? raw?.Key4 ?? ''), String(raw?.key5 ?? raw?.Key5 ?? ''), ], nfc: !!(raw?.nfc ?? raw?.Nfc), yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured), yubicoCanManage: !!(raw?.yubicoCanManage ?? raw?.YubicoCanManage), yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''), yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''), }; } export async function getYubiKeyOtpSettings( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/two-factor/get-yubikey', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed'))); } return normalizeYubiKeySettings(await parseJson(resp)); } export async function saveYubiKeyOtpSettings( authedFetch: AuthedFetch, payload: { keys: string[]; nfc: boolean; masterPasswordHash: string } ): Promise { const resp = await authedFetch('/api/two-factor/yubikey', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key1: payload.keys[0] || '', key2: payload.keys[1] || '', key3: payload.keys[2] || '', key4: payload.keys[3] || '', key5: payload.keys[4] || '', nfc: payload.nfc, masterPasswordHash: payload.masterPasswordHash, }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_update_failed'))); } return normalizeYubiKeySettings(await parseJson(resp)); } export async function saveYubiKeyOtpApiCredentials( authedFetch: AuthedFetch, payload: { masterPasswordHash: string; yubicoClientId: string; yubicoSecretKey: string } ): Promise { const resp = await authedFetch('/api/two-factor/yubikey/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_config_update_failed'))); } return normalizeYubiKeySettings(await parseJson(resp)); } export async function bootstrapYubiKeyOtpApiCredentials( authedFetch: AuthedFetch, payload: { masterPasswordHash: string; otp: string } ): Promise { const resp = await authedFetch('/api/two-factor/yubikey/bootstrap', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_auto_config_failed'))); } return normalizeYubiKeySettings(await parseJson(resp)); } export async function disableYubiKeyOtp( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/two-factor/disable', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 3, masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_yubikey_failed'))); } } function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings { const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : []; return { enabled: !!(raw?.enabled ?? raw?.Enabled), keys: keys .map((item: any) => ({ id: Number(item?.id ?? item?.Id), name: String(item?.name || item?.Name || ''), migrated: !!(item?.migrated ?? item?.Migrated), })) .filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0), }; } export async function getTwoFactorPasskeySettings( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/two-factor/get-webauthn', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed'))); } return normalizeTwoFactorPasskeySettings(await parseJson(resp)); } export async function getTwoFactorPasskeyChallenge( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed'))); } return parseJson(resp); } export async function saveTwoFactorPasskey( authedFetch: AuthedFetch, payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown } ): Promise { const resp = await authedFetch('/api/two-factor/webauthn', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed'))); } return normalizeTwoFactorPasskeySettings(await parseJson(resp)); } export async function deleteTwoFactorPasskey( authedFetch: AuthedFetch, payload: { id: number; masterPasswordHash: string } ): Promise { const resp = await authedFetch('/api/two-factor/webauthn', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed'))); } return normalizeTwoFactorPasskeySettings(await parseJson(resp)); } export async function disableTwoFactorPasskeys( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/two-factor/disable', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 7, masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed'))); } } export async function verifyMasterPassword( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/accounts/verify-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed'))); } } 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 { 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(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(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 { 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(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_save_profile_failed'))); } const body = await parseJson(resp); return normalizeAccountPasskeyCredential(body); } export async function enableAccountPasskeyDirectUnlock( authedFetch: AuthedFetch, payload: { token: string; deviceResponse: unknown; keySet: AccountPasskeyPrfKeySet; } ): Promise { 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(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 { 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(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed'))); } } export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/accounts/revision-date'); if (!resp.ok) { throw new Error('Failed to load revision date'); } const body = await parseJson(resp); const stamp = Number(body); if (!Number.isFinite(stamp) || stamp <= 0) { throw new Error('Invalid revision date'); } return stamp; } export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> { const resp = await authedFetch('/api/two-factor'); if (!resp.ok) throw new Error('Failed to load two-factor status'); const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {}; const providers = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : []; const enabledTypes = new Set( providers .map((provider: any) => Number(provider?.type ?? provider?.Type)) .filter((type) => Number.isFinite(type)) ); return { totpEnabled: enabledTypes.has(0), yubikeyEnabled: enabledTypes.has(3), passkeyEnabled: enabledTypes.has(7), }; } export async function getTotpRecoveryCode( authedFetch: AuthedFetch, masterPasswordHash: string ): Promise { const resp = await authedFetch('/api/accounts/totp/recovery-code', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_get_recovery_code_failed'))); } const body = (await parseJson<{ code?: string }>(resp)) || {}; return String(body.code || ''); } export async function recoverTwoFactor( email: string, masterPasswordHash: string, recoveryCode: string ): Promise<{ newRecoveryCode?: string }> { const resp = await fetch('/identity/accounts/recover-2fa', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: email.toLowerCase().trim(), masterPasswordHash, recoveryCode, }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_recover_2fa_failed'))); } return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {}; } export async function getAuthorizedDevices(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/devices/authorized'); if (!resp.ok) throw new Error(t('txt_load_devices_failed')); const body = await parseJson<{ object: 'list'; data: AuthorizedDevice[] }>(resp); return body?.data || []; } export async function revokeAuthorizedDeviceTrust( authedFetch: AuthedFetch, deviceIdentifier: string ): Promise { const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_revoke_device_trust_failed')); } export async function trustAuthorizedDevicePermanently( authedFetch: AuthedFetch, deviceIdentifier: string ): Promise { const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}/permanent`, { method: 'POST' }); if (!resp.ok) throw new Error(t('txt_trust_device_permanently_failed')); } export async function revokeAllAuthorizedDeviceTrust(authedFetch: AuthedFetch): Promise { const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_revoke_all_device_trust_failed')); } export async function deleteAuthorizedDevice( authedFetch: AuthedFetch, deviceIdentifier: string ): Promise { const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_remove_device_failed')); } export async function deleteAuthorizedDevices( authedFetch: AuthedFetch, devices: Array> ): Promise { const uniqueDevices = Array.from( new Map(devices.map((device) => [String(device.identifier || '').trim(), device])).values() ).filter((device) => String(device.identifier || '').trim()); await Promise.all(uniqueDevices.map((device) => ( device.hasStoredDevice === false ? revokeAuthorizedDeviceTrust(authedFetch, device.identifier) : deleteAuthorizedDevice(authedFetch, device.identifier) ))); } export async function updateAuthorizedDeviceName( authedFetch: AuthedFetch, deviceIdentifier: string, name: string ): Promise { const normalized = String(name || '').trim(); if (!normalized) throw new Error(t('txt_device_note_required')); const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: normalized }), }); if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); } export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { const resp = await authedFetch('/api/devices', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); } export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { const resp = await authedFetch('/api/accounts/api-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_get_api_key_failed'))); } const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; return String(body.apiKey || ''); } export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { const resp = await authedFetch('/api/accounts/rotate-api-key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ masterPasswordHash }), }); if (!resp.ok) { const body = await parseJson(resp); throw new Error(translateServerError(body?.error_description || body?.error, t('txt_rotate_api_key_failed'))); } const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; return String(body.apiKey || ''); }