import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto'; 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'; 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; } 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); } 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.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', }; } if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null; return { accessToken: parsed.accessToken, refreshToken: parsed.refreshToken, email: parsed.email, authMode: 'token', }; } 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; 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', '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) { 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 isTransientRefreshStatus(status: number): boolean { return status === 0 || status === 429 || status >= 500; } 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', 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); return { ok: false, transient: isTransientRefreshStatus(resp.status), error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')), }; } const json = await parseJson(resp); if (!json?.access_token) { return { ok: false, transient: false, error: t('txt_session_refresh_failed') }; } 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', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(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 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 = crypto.getRandomValues(new Uint8Array(64)); const encryptedVaultKey = await encryptBw(sym, encKey, macKey); const keyPair = 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', keyPair.publicKey)); const privateKey = new Uint8Array(await crypto.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) { 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 }); 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) { 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}`); headers.set('X-NodeWarden-Web', '1'); 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}`); latestHeaders.set('X-NodeWarden-Web', '1'); 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}`); retryHeaders.set('X-NodeWarden-Web', '1'); 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); 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.slice(0, 64), nextEnc, nextMac); const resp = await authedFetch('/api/accounts/password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ currentPasswordHash: current.hash, newMasterPasswordHash: bytesToBase64(nextHash), newKey, kdf: 0, kdfIterations: current.kdfIterations, }), }); 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'))); } } 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 getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> { const resp = await authedFetch('/api/accounts/totp'); if (!resp.ok) throw new Error('Failed to load TOTP status'); const body = (await parseJson<{ enabled?: boolean }>(resp)) || {}; return { enabled: !!body.enabled }; } 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 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): Promise { const resp = await authedFetch('/api/devices', { method: 'DELETE' }); 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 || ''); }