From 72796689559a6ee842c3e889fad3644a2dc9668b Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 23 Jun 2026 17:48:41 +0800 Subject: [PATCH] fix: address security issue --- src/handlers/accounts.ts | 34 +++- src/handlers/attachments.ts | 3 +- src/handlers/backup.ts | 92 +++++++-- src/handlers/identity.ts | 4 + src/handlers/sends-public.ts | 3 +- src/router-admin-backup.ts | 2 +- src/router-public.ts | 4 +- src/services/storage-admin-repo.ts | 11 ++ src/services/storage.ts | 5 + src/types/index.ts | 2 + src/utils/content-type.ts | 38 ++++ src/utils/response.ts | 4 +- src/utils/user-verification-token.ts | 89 +++++++++ webapp/src/App.tsx | 81 ++++++-- webapp/src/components/AppMainRoutes.tsx | 18 +- webapp/src/components/BackupCenterPage.tsx | 178 ++++++++++++++++-- webapp/src/components/SettingsPage.tsx | 22 ++- webapp/src/hooks/useAccountSecurityActions.ts | 20 +- webapp/src/hooks/useBackupActions.ts | 31 +-- webapp/src/lib/api/backup.ts | 35 +++- webapp/src/lib/app-auth.ts | 27 ++- webapp/src/lib/backup-settings-repair.ts | 6 +- webapp/src/lib/demo.ts | 16 +- webapp/src/lib/types.ts | 2 + 24 files changed, 613 insertions(+), 114 deletions(-) create mode 100644 src/utils/content-type.ts create mode 100644 src/utils/user-verification-token.ts diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 953a1fb..49ef7af 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -353,9 +353,15 @@ export async function handleRegister(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); @@ -899,7 +899,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); - let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; + let body: { + enabled?: boolean; + secret?: string; + token?: string; + masterPasswordHash?: string; + userVerificationToken?: string; + }; try { body = await request.json(); } catch { @@ -908,12 +914,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st if (body.enabled === true) { const normalizedSecret = normalizeTotpSecret(body.secret || ''); + const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']); + const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']); if (!isTotpEnabled(normalizedSecret)) { return errorResponse('Invalid TOTP secret', 400); } if (!body.token) { return errorResponse('TOTP token is required', 400); } + let verifiedUser = false; + if (userVerificationToken) { + verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken); + } + if (!verifiedUser && masterPasswordHash) { + verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email); + } + if (!verifiedUser) { + return errorResponse('User verification failed.', 400); + } const verified = await verifyTotpToken(normalizedSecret, body.token); if (!verified) { return errorResponse('Invalid TOTP token', 400); diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 890ded3..259fcce 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -4,6 +4,7 @@ import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { generateUUID } from '../utils/uuid'; +import { sanitizeDownloadContentType } from '../utils/content-type'; import { createAttachmentUploadToken, createFileDownloadToken, @@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment( return new Response(object.body, { headers: { - 'Content-Type': object.contentType || 'application/octet-stream', + 'Content-Type': sanitizeDownloadContentType(object.contentType), 'Content-Length': String(object.size), 'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Cache-Control': 'private, no-cache', diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 288eb65..d734331 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -40,15 +40,51 @@ import { uploadBackupArchive, } from '../services/backup-uploader'; import { StorageService } from '../services/storage'; +import { AuthService } from '../services/auth'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { getBlobObject } from '../services/blob-store'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; +import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { unzipSync } from 'fflate'; function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; } +async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise { + const normalized = String(masterPasswordHash || '').trim(); + if (!normalized) { + return errorResponse('masterPasswordHash is required', 400); + } + const auth = new AuthService(env); + const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email); + if (!valid) { + return errorResponse('Invalid password', 400); + } + return null; +} + +async function requireBackupRepairVerification( + actorUser: User, + body: { masterPasswordHash?: string; userVerificationToken?: string }, + env: Env +): Promise { + const masterPasswordHash = String(body.masterPasswordHash || '').trim(); + if (masterPasswordHash) { + return requireBackupUserVerification(actorUser, masterPasswordHash, env); + } + + const userVerificationToken = String(body.userVerificationToken || '').trim(); + if (!userVerificationToken) { + return errorResponse('masterPasswordHash or userVerificationToken is required', 400); + } + const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair'); + if (!valid) { + return errorResponse('Invalid user verification token', 400); + } + return null; +} + async function writeAuditLog( storage: StorageService, actorUserId: string | null, @@ -787,13 +823,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); - let body: BackupSettingsInput; + let body: BackupSettingsInput & { masterPasswordHash?: string }; try { - body = await request.json(); + body = await request.json(); } catch { return errorResponse('Backup settings payload is invalid', 400); } + const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env); + if (verificationError) return verificationError; + const storage = new StorageService(env.DB); let previous; try { @@ -837,13 +876,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request, export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); - let body: BackupSettingsInput; + let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }; try { - body = await request.json(); + body = await request.json(); } catch { return errorResponse('Backup settings repair payload is invalid', 400); } + const verificationError = await requireBackupRepairVerification(actorUser, body, env); + if (verificationError) return verificationError; + const storage = new StorageService(env.DB); let previous; try { @@ -871,15 +913,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env, if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); try { - let body: { destinationId?: string } | null = null; + let body: { destinationId?: string; masterPasswordHash?: string } | null = null; try { if ((request.headers.get('Content-Type') || '').includes('application/json')) { - body = await request.json<{ destinationId?: string }>(); + body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>(); } } catch { return errorResponse('Backup run payload is invalid', 400); } + const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env); + if (verificationError) return verificationError; + const outcome = await runConfiguredBackupInDurableObject(env, { actorUserId: actorUser.id, auditMetadata: auditRequestMetadata(request), @@ -928,12 +973,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + let body: { destinationId?: string; path?: string; masterPasswordHash?: string }; + try { + body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>(); + } catch { + return errorResponse('Remote backup download payload is invalid', 400); + } + + const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env); + if (verificationError) return verificationError; + const storage = new StorageService(env.DB); try { const settings = await loadBackupSettings(storage, env, 'UTC'); - const url = new URL(request.url); - const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); - const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null); + const path = ensureRemoteRestoreCandidate(String(body.path || '')); + const destination = requireBackupDestination(settings, body.destinationId || null); const remoteFile = await downloadRemoteBackupFile(destination, path); return new Response(remoteFile.bytes, { status: 200, @@ -994,13 +1048,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); - let body: { destinationId?: string; path?: string; replaceExisting?: boolean; allowChecksumMismatch?: boolean }; + let body: { + destinationId?: string; + path?: string; + replaceExisting?: boolean; + allowChecksumMismatch?: boolean; + masterPasswordHash?: string; + }; try { body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); } catch { return errorResponse('Remote restore payload is invalid', 400); } + const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env); + if (verificationError) return verificationError; + try { const path = ensureRemoteRestoreCandidate(String(body.path || '')); const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; @@ -1028,14 +1091,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU const storage = new StorageService(env.DB); const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; - let body: { includeAttachments?: boolean } | null = null; + let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null; try { if ((request.headers.get('Content-Type') || '').includes('application/json')) { - body = await request.json<{ includeAttachments?: boolean }>(); + body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>(); } } catch { return errorResponse('Backup export payload is invalid', 400); } + const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env); + if (verificationError) return verificationError; let archive: BackupArchiveBundle; try { const progress = async (event: { @@ -1140,6 +1205,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU return errorResponse('Backup file is required', 400); } + const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env); + if (verificationError) return verificationError; + const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; let archiveBytes: Uint8Array; diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 3a15102..c26b9fa 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -21,6 +21,7 @@ import { buildAccountPasskeyTokenUserDecryptionOption, } from './account-passkeys'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; +import { createPasskeyUserVerificationToken } from '../utils/user-verification-token'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; @@ -583,6 +584,7 @@ export async function handleToken(request: Request, env: Env): Promise const accessToken = await auth.generateAccessToken(user, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); + const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair'); const accountKeys = buildAccountKeys(user); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); @@ -621,6 +623,8 @@ export async function handleToken(request: Request, env: Env): Promise ApiUseKeyConnector: false, scope: 'api offline_access', unofficialServer: true, + UserVerificationToken: userVerificationToken, + userVerificationToken, UserDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions, }; diff --git a/src/handlers/sends-public.ts b/src/handlers/sends-public.ts index 3d3b568..ae11321 100644 --- a/src/handlers/sends-public.ts +++ b/src/handlers/sends-public.ts @@ -2,6 +2,7 @@ import { Env, SendType } from '../types'; import { StorageService } from '../services/storage'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse } from '../utils/response'; +import { sanitizeDownloadContentType } from '../utils/content-type'; import { LIMITS } from '../config/limits'; import { createSendAccessToken, @@ -306,7 +307,7 @@ export async function handleDownloadSendFile( return new Response(object.body, { headers: { - 'Content-Type': object.contentType || 'application/octet-stream', + 'Content-Type': sanitizeDownloadContentType(object.contentType), 'Content-Length': String(object.size), 'Content-Disposition': contentDispositionAttachment(fileName), 'Cache-Control': 'private, no-cache', diff --git a/src/router-admin-backup.ts b/src/router-admin-backup.ts index 548b073..d0512a4 100644 --- a/src/router-admin-backup.ts +++ b/src/router-admin-backup.ts @@ -50,7 +50,7 @@ export async function handleAdminBackupRoute( return handleListAdminRemoteBackups(request, env, actorUser); } - if (path === '/api/admin/backup/remote/download' && method === 'GET') { + if (path === '/api/admin/backup/remote/download' && method === 'POST') { return handleDownloadAdminRemoteBackup(request, env, actorUser); } diff --git a/src/router-public.ts b/src/router-public.ts index d3f0674..956fd40 100644 --- a/src/router-public.ts +++ b/src/router-public.ts @@ -27,6 +27,7 @@ import { handleNotificationsNegotiate, } from './handlers/notifications'; import { handlePublicUploadSendFile } from './handlers/sends'; +import { isSafeWebsiteIconContentType } from './utils/content-type'; import { jsonResponse } from './utils/response'; import { StorageService } from './services/storage'; import type { Env } from './types'; @@ -241,6 +242,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon headers: { 'Content-Type': contentType || 'image/png', 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, + 'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox", }, }); } @@ -272,7 +274,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo if (!resp.ok) continue; const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); - if (!contentType.startsWith('image/')) continue; + if (!isSafeWebsiteIconContentType(contentType)) continue; const contentLength = getPositiveContentLength(resp.headers); if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue; diff --git a/src/services/storage-admin-repo.ts b/src/services/storage-admin-repo.ts index ddc62ff..6da1c26 100644 --- a/src/services/storage-admin-repo.ts +++ b/src/services/storage-admin-repo.ts @@ -127,6 +127,17 @@ export async function markInviteUsed(db: D1Database, code: string, userId: strin return (result.meta.changes ?? 0) > 0; } +export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise { + const now = new Date().toISOString(); + const result = await db + .prepare( + "UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by = ?" + ) + .bind(now, code, userId) + .run(); + return (result.meta.changes ?? 0) > 0; +} + export async function revokeInvite(db: D1Database, code: string): Promise { const now = new Date().toISOString(); const result = await db diff --git a/src/services/storage.ts b/src/services/storage.ts index 980fc43..70efe29 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -30,6 +30,7 @@ import { markInviteUsed as markStoredInviteUsed, pruneAuditLogs as pruneStoredAuditLogs, pruneAuditLogsToMax as pruneStoredAuditLogsToMax, + revertInviteUsed as revertStoredInviteUsed, revokeInvite as revokeStoredInvite, } from './storage-admin-repo'; import { @@ -313,6 +314,10 @@ export class StorageService { return markStoredInviteUsed(this.db, code, userId); } + async revertInviteUsed(code: string, userId: string): Promise { + return revertStoredInviteUsed(this.db, code, userId); + } + async revokeInvite(code: string): Promise { return revokeStoredInvite(this.db, code); } diff --git a/src/types/index.ts b/src/types/index.ts index 8ffe519..c7ab2e2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -466,6 +466,8 @@ export interface TokenResponse { ResetMasterPassword: boolean; scope: string; unofficialServer: boolean; + UserVerificationToken?: string; + userVerificationToken?: string; MasterPasswordPolicy?: { minComplexity: number; minLength: number; diff --git a/src/utils/content-type.ts b/src/utils/content-type.ts new file mode 100644 index 0000000..bb853a3 --- /dev/null +++ b/src/utils/content-type.ts @@ -0,0 +1,38 @@ +const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([ + 'application/xhtml+xml', + 'application/xml', + 'image/svg+xml', + 'text/html', + 'text/xml', +]); + +const SAFE_ICON_MEDIA_TYPES = new Set([ + 'image/avif', + 'image/bmp', + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/vnd.microsoft.icon', + 'image/webp', + 'image/x-icon', +]); + +function normalizeMediaType(contentType: string | null | undefined): string { + return String(contentType || '') + .split(';', 1)[0] + .trim() + .toLowerCase(); +} + +export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean { + return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType)); +} + +export function sanitizeDownloadContentType(contentType: string | null | undefined): string { + const mediaType = normalizeMediaType(contentType); + if (!mediaType) return 'application/octet-stream'; + if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) { + return 'application/octet-stream'; + } + return contentType || mediaType; +} diff --git a/src/utils/response.ts b/src/utils/response.ts index 7906c52..a068016 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -100,7 +100,9 @@ export function applyCors( headers.set('X-Frame-Options', 'DENY'); headers.set('X-Content-Type-Options', 'nosniff'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); - headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); + if (!headers.has('Content-Security-Policy')) { + headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); + } return new Response(response.body, { status: response.status, statusText: response.statusText, diff --git a/src/utils/user-verification-token.ts b/src/utils/user-verification-token.ts new file mode 100644 index 0000000..73bff05 --- /dev/null +++ b/src/utils/user-verification-token.ts @@ -0,0 +1,89 @@ +import type { Env } from '../types'; +import { base64UrlToBytes, bytesToBase64Url } from './passkey'; + +const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1'; +const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000; + +export type UserVerificationPurpose = 'backup.settings.repair'; + +interface UserVerificationTokenPayload { + typ: typeof USER_VERIFICATION_TOKEN_TYPE; + userId: string; + method: 'passkey'; + purpose: UserVerificationPurpose; + iat: number; + exp: number; +} + +function textBytes(value: string): Uint8Array { + return new TextEncoder().encode(value); +} + +async function importHmacKey(secret: string): Promise { + return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); +} + +async function hmacSha256(secret: string, data: string): Promise { + const key = await importHmacKey(secret); + return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data))); +} + +function encodeJson(value: unknown): string { + return bytesToBase64Url(textBytes(JSON.stringify(value))); +} + +function decodeJson(value: string): T | null { + try { + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T; + } catch { + return null; + } +} + +export async function createPasskeyUserVerificationToken( + env: Env, + userId: string, + purpose: UserVerificationPurpose +): Promise { + const now = Date.now(); + const payload: UserVerificationTokenPayload = { + typ: USER_VERIFICATION_TOKEN_TYPE, + userId, + method: 'passkey', + purpose, + iat: now, + exp: now + USER_VERIFICATION_TOKEN_TTL_MS, + }; + const header = { alg: 'HS256', typ: 'JWT' }; + const data = `${encodeJson(header)}.${encodeJson(payload)}`; + const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data)); + return `${data}.${signature}`; +} + +export async function verifyPasskeyUserVerificationToken( + env: Env, + token: string, + userId: string, + purpose: UserVerificationPurpose +): Promise { + try { + const parts = String(token || '').split('.'); + if (parts.length !== 3) return false; + const data = `${parts[0]}.${parts[1]}`; + const expected = await hmacSha256(env.JWT_SECRET, data); + const actual = base64UrlToBytes(parts[2]); + if (actual.length !== expected.length) return false; + + let diff = 0; + for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i]; + if (diff !== 0) return false; + + const payload = decodeJson(parts[1]); + if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false; + if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false; + if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false; + return true; + } catch { + return false; + } +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 530ea51..dca5581 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage'; import JwtWarningPage from '@/components/JwtWarningPage'; import { createAuthedFetch, + deriveLoginHash, getAuthorizedDevices, clearProfileSnapshot, getCurrentDeviceIdentifier, @@ -264,6 +265,11 @@ export default function App() { const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise>(async () => {}); const repairAttemptRef = useRef(''); + const loginScopedBackupRepairAuthRef = useRef<{ + accessToken: string; + masterPasswordHash?: string | null; + userVerificationToken?: string | null; + } | null>(null); const uriChecksumRepairAttemptRef = useRef(''); const pendingVaultCoreQueryRefreshRef = useRef | null>(null); const pendingVaultCoreRefreshRef = useRef | null>(null); @@ -506,6 +512,14 @@ export default function App() { }, [phase, session?.email, location, navigate]); async function finalizeLogin(login: CompletedLogin) { + loginScopedBackupRepairAuthRef.current = + login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken) + ? { + accessToken: login.session.accessToken, + masterPasswordHash: login.freshMasterPasswordHash || null, + userVerificationToken: login.freshUserVerificationToken || null, + } + : null; setSession(login.session); setProfile(login.profile); setUnlockPreparing(false); @@ -1085,6 +1099,15 @@ export default function App() { enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, staleTime: 30_000, }); + + async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise { + const email = String(profile?.email || session?.email || '').trim().toLowerCase(); + if (!email) 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(email, normalizedPassword, defaultKdfIterations); + return derived.hash; + } const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const pendingAuthRequestsQuery = useQuery({ queryKey: pendingAuthRequestsQueryKey, @@ -1189,13 +1212,25 @@ export default function App() { if (!isAdminProfile(profile)) return; if (repairAttemptRef.current === session.accessToken) return; + const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken + ? loginScopedBackupRepairAuthRef.current + : null; repairAttemptRef.current = session.accessToken; - void silentlyRepairBackupSettingsIfNeeded(session, profile); + void (async () => { + try { + await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth); + } finally { + if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) { + loginScopedBackupRepairAuthRef.current = null; + } + } + })(); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); useEffect(() => { if (session?.accessToken) return; repairAttemptRef.current = ''; + loginScopedBackupRepairAuthRef.current = null; uriChecksumRepairAttemptRef.current = ''; }, [session?.accessToken]); @@ -1950,8 +1985,8 @@ export default function App() { sendUploadPercent: vaultSendActions.sendUploadPercent, onChangePassword: accountSecurityActions.changePassword, onSavePasswordHint: accountSecurityActions.savePasswordHint, - onEnableTotp: async (secret: string, token: string) => { - await accountSecurityActions.enableTotp(secret, token); + onEnableTotp: async (secret: string, token: string, masterPassword: string) => { + await accountSecurityActions.enableTotp(secret, token, masterPassword); await totpStatusQuery.refetch(); }, onOpenDisableTotp: () => setDisableTotpOpen(true), @@ -1992,22 +2027,46 @@ export default function App() { onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onClearAuditLogs: () => clearAuditLogs(authedFetch), - onExportBackup: backupActions.exportBackup, - onImportBackup: backupActions.importBackup, - onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, + onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.exportBackup(hash, includeAttachments); + }, + onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.importBackup(hash, file, replaceExisting); + }, + onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting); + }, onLoadBackupSettings: () => queryClient.ensureQueryData({ queryKey: ['admin-backup-settings', vaultCacheKey], queryFn: () => backupActions.loadSettings(), staleTime: 30_000, }), - onSaveBackupSettings: backupActions.saveSettings, - onRunRemoteBackup: backupActions.runRemoteBackup, + onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.saveSettings(hash, settings); + }, + onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.runRemoteBackup(hash, destinationId); + }, onListRemoteBackups: backupActions.listRemoteBackups, - onDownloadRemoteBackup: backupActions.downloadRemoteBackup, + onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress); + }, onInspectRemoteBackup: backupActions.inspectRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup, - onRestoreRemoteBackup: backupActions.restoreRemoteBackup, - onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, + onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting); + }, + onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => { + const hash = await deriveCurrentMasterPasswordHash(masterPassword); + return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting); + }, }; const effectiveMainRoutesProps = IS_DEMO_MODE ? createDemoMainRoutesProps(mainRoutesProps, pushToast, { diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index 635f514..6d682a5 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -107,7 +107,7 @@ export interface AppMainRoutesProps { sendUploadPercent: number | null; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onSavePasswordHint: (masterPasswordHint: string) => Promise; - onEnableTotp: (secret: string, token: string) => Promise; + onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; onGetApiKey: (masterPassword: string) => Promise; @@ -142,18 +142,18 @@ export interface AppMainRoutesProps { onLoadAuditLogSettings: () => Promise; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise; onClearAuditLogs: () => Promise; - onExportBackup: (includeAttachments?: boolean) => Promise; - onImportBackup: (file: File, replaceExisting?: boolean) => Promise; - onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise; + onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise; + onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise; + onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise; onLoadBackupSettings: () => Promise; - onSaveBackupSettings: (settings: AdminBackupSettings) => Promise; - onRunRemoteBackup: (destinationId?: string | null) => Promise; + onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise; + onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; - onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; + onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; - onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; - onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; } export default function AppMainRoutes(props: AppMainRoutesProps) { diff --git a/webapp/src/components/BackupCenterPage.tsx b/webapp/src/components/BackupCenterPage.tsx index 3c9f92c..38bde25 100644 --- a/webapp/src/components/BackupCenterPage.tsx +++ b/webapp/src/components/BackupCenterPage.tsx @@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar interface BackupCenterPageProps { currentUserId: string | null; - onExport: (includeAttachments?: boolean) => Promise; - onImport: (file: File, replaceExisting?: boolean) => Promise; - onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise; + onExport: (masterPassword: string, includeAttachments?: boolean) => Promise; + onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise; + onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise; onLoadSettings: () => Promise; - onSaveSettings: (settings: AdminBackupSettings) => Promise; - onRunRemoteBackup: (destinationId?: string | null) => Promise; + onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise; + onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise; onListRemoteBackups: (destinationId: string, path: string) => Promise; - onDownloadRemoteBackup: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; + onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise; - onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; - onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; + onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; } @@ -53,6 +53,14 @@ type PendingRestoreIntegrity = | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; +type PendingBackupVerification = + | { action: 'export' } + | { action: 'saveSettings' } + | { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult } + | { action: 'runRemoteBackup' } + | { action: 'downloadRemote'; path: string } + | { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }; + interface BackupProgressPhase { titleKey: string; detailKey: string; @@ -193,6 +201,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); + const [pendingBackupVerification, setPendingBackupVerification] = useState(null); + const [backupPasswordValue, setBackupPasswordValue] = useState(''); + const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState(null); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); @@ -209,7 +220,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const selectedDestination = getDestinationById(settings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const selectedDestinationIsSaved = !!savedSelectedDestination; - const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; + const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; @@ -226,6 +237,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canBrowseSelectedDestination = !!savedSelectedDestination; + const backupPasswordPromptTitle = + pendingBackupVerification?.action === 'export' + ? t('txt_backup_export') + : pendingBackupVerification?.action === 'saveSettings' + ? t('txt_backup_save_settings') + : pendingBackupVerification?.action === 'runRemoteBackup' + ? t('txt_backup_run_manual') + : pendingBackupVerification?.action === 'downloadRemote' + ? t('txt_backup_remote_download') + : pendingBackupVerification?.action === 'restoreRemote' + ? t('txt_backup_import') + : t('txt_backup_import'); useEffect(() => { let cancelled = false; @@ -507,11 +530,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } async function handleExport() { + if (exporting) return; + setPendingBackupVerification({ action: 'export' }); + setBackupPasswordValue(''); + } + + async function executeExport(masterPassword: string) { setLocalError(''); setExporting(true); try { startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); - await props.onExport(exportIncludeAttachments); + await props.onExport(masterPassword, exportIncludeAttachments); props.onNotify('success', t('txt_backup_export_success')); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); @@ -527,6 +556,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { replaceExisting: boolean, allowChecksumMismatch: boolean = false, knownIntegrity?: BackupFileIntegrityCheckResult + ) { + if (importing) return; + if (!selectedFile) { + const message = t('txt_backup_file_required'); + setLocalError(message); + props.onNotify('error', message); + return; + } + setPendingBackupVerification({ + action: 'import', + replaceExisting, + allowChecksumMismatch, + knownIntegrity, + }); + setBackupPasswordValue(''); + } + + async function executeLocalRestore( + masterPassword: string, + replaceExisting: boolean, + allowChecksumMismatch: boolean = false, + knownIntegrity?: BackupFileIntegrityCheckResult ) { if (importing) return; if (!selectedFile) { @@ -547,8 +598,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { delayMs: replaceExisting ? 480 : 1400, }); const result = allowChecksumMismatch - ? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) - : await props.onImport(selectedFile, replaceExisting); + ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting) + : await props.onImport(masterPassword, selectedFile, replaceExisting); props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); const skippedMessage = buildSkippedImportMessage(result); if (skippedMessage) props.onNotify('warning', skippedMessage); @@ -573,12 +624,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } async function handleSaveSettings() { + if (savingSettings) return; + setPendingBackupVerification({ action: 'saveSettings' }); + setBackupPasswordValue(''); + } + + async function executeSaveSettings(masterPassword: string) { const payload = buildSettingsPayloadForSelectedDestination(); const destinationIdToInvalidate = selectedDestinationId; setSavingSettings(true); setLocalError(''); try { - const saved = await props.onSaveSettings(payload); + const saved = await props.onSaveSettings(masterPassword, payload); const nextSelected = (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) || getFirstVisibleDestinationId(saved) @@ -613,6 +670,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } async function handleRunRemoteBackup() { + if (!selectedDestination || runningRemoteBackup) return; + setPendingBackupVerification({ action: 'runRemoteBackup' }); + setBackupPasswordValue(''); + } + + async function executeRunRemoteBackup(masterPassword: string) { if (!selectedDestination) return; setRunningRemoteBackup(true); setLocalError(''); @@ -621,7 +684,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { source: 'remote', includeAttachments: !!selectedDestination.includeAttachments, }); - const result = await props.onRunRemoteBackup(selectedDestination.id); + const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id); setSavedSettings(result.settings); setSettings(result.settings); setSelectedDestinationId(selectedDestination.id); @@ -638,12 +701,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } async function handleDownloadRemote(path: string) { + setPendingBackupVerification({ action: 'downloadRemote', path }); + setBackupPasswordValue(''); + } + + async function executeDownloadRemote(masterPassword: string, path: string) { if (!savedSelectedDestination) return; setDownloadingRemotePath(path); setDownloadingRemotePercent(null); setLocalError(''); try { - await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); + await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent); } catch (error) { const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); setLocalError(message); @@ -724,6 +792,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { replaceExisting: boolean, allowChecksumMismatch: boolean = false, knownIntegrity?: BackupFileIntegrityCheckResult + ) { + if (restoringRemotePath) return; + if (!savedSelectedDestination) return; + setPendingBackupVerification({ + action: 'restoreRemote', + path, + replaceExisting, + allowChecksumMismatch, + knownIntegrity, + }); + setBackupPasswordValue(''); + } + + async function executeRemoteRestore( + masterPassword: string, + path: string, + replaceExisting: boolean, + allowChecksumMismatch: boolean = false, + knownIntegrity?: BackupFileIntegrityCheckResult ) { if (restoringRemotePath) return; if (!savedSelectedDestination) return; @@ -738,8 +825,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { delayMs: replaceExisting ? 480 : 1400, }); const result = allowChecksumMismatch - ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) - : await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); + ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting) + : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting); setConfirmRemoteReplaceOpen(false); setPendingRemoteRestorePath(''); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); @@ -762,6 +849,36 @@ export default function BackupCenterPage(props: BackupCenterPageProps) { } } + async function submitBackupPasswordPrompt(): Promise { + const request = pendingBackupVerification; + const masterPassword = backupPasswordValue; + if (!request || backupPasswordSubmitting) return; + if (!masterPassword.trim()) { + props.onNotify('error', t('txt_master_password_is_required')); + return; + } + setBackupPasswordSubmitting(true); + setPendingBackupVerification(null); + setBackupPasswordValue(''); + try { + if (request.action === 'export') { + await executeExport(masterPassword); + } else if (request.action === 'saveSettings') { + await executeSaveSettings(masterPassword); + } else if (request.action === 'import') { + await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity); + } else if (request.action === 'runRemoteBackup') { + await executeRunRemoteBackup(masterPassword); + } else if (request.action === 'downloadRemote') { + await executeDownloadRemote(masterPassword, request.path); + } else if (request.action === 'restoreRemote') { + await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity); + } + } finally { + setBackupPasswordSubmitting(false); + } + } + return (
), document.body) : null} + void submitBackupPasswordPrompt()} + onCancel={() => { + if (backupPasswordSubmitting) return; + setPendingBackupVerification(null); + setBackupPasswordValue(''); + }} + > + + + Promise; onSavePasswordHint: (masterPasswordHint: string) => Promise; - onEnableTotp: (secret: string, token: string) => Promise; + onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; onGetApiKey: (masterPassword: string) => Promise; @@ -34,6 +34,7 @@ interface SettingsPageProps { } type MasterPasswordPromptAction = + | 'enableTotp' | 'recovery' | 'apiKey' | 'rotateApiKey' @@ -141,12 +142,12 @@ export default function SettingsPage(props: SettingsPageProps) { }, [props.profile.email, secret]); async function enableTotp(): Promise { - try { - await props.onEnableTotp(secret, token); - setTotpLocked(true); - } catch { - // Keep inputs editable after a failed attempt. + if (totpLocked) return; + if (!secret.trim() || !token.trim()) { + props.onNotify?.('error', t('txt_secret_and_code_are_required')); + return; } + openMasterPasswordPrompt('enableTotp'); } async function refreshAccountPasskeys(): Promise { @@ -178,7 +179,10 @@ export default function SettingsPage(props: SettingsPageProps) { const masterPassword = masterPasswordPromptValue; setMasterPasswordPromptSubmitting(true); try { - if (masterPasswordPrompt === 'recovery') { + if (masterPasswordPrompt === 'enableTotp') { + await props.onEnableTotp(secret, token, masterPassword); + setTotpLocked(true); + } else if (masterPasswordPrompt === 'recovery') { const code = await props.onGetRecoveryCode(masterPassword); setRecoveryCode(code); props.onNotify?.('success', t('txt_recovery_code_loaded')); @@ -214,7 +218,9 @@ export default function SettingsPage(props: SettingsPageProps) { } const masterPasswordPromptTitle = - masterPasswordPrompt === 'recovery' + masterPasswordPrompt === 'enableTotp' + ? t('txt_enable_totp') + : masterPasswordPrompt === 'recovery' ? t('txt_view_recovery_code') : masterPasswordPrompt === 'rotateApiKey' ? t('txt_rotate_api_key') diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 682aa48..a76dd45 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -145,14 +145,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct } }, - async enableTotp(secret: string, token: string) { + async enableTotp(secret: string, token: string, masterPassword: string) { + if (!profile) { + const error = new Error(t('txt_profile_unavailable')); + onNotify('error', error.message); + throw error; + } if (!secret.trim() || !token.trim()) { const error = new Error(t('txt_secret_and_code_are_required')); onNotify('error', error.message); throw error; } + if (!masterPassword) { + const error = new Error(t('txt_master_password_is_required')); + onNotify('error', error.message); + throw error; + } try { - await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); + const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations); + await setTotp(authedFetch, { + enabled: true, + secret: secret.trim(), + token: token.trim(), + masterPasswordHash: derived.hash, + }); onNotify('success', t('txt_totp_enabled')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); diff --git a/webapp/src/hooks/useBackupActions.ts b/webapp/src/hooks/useBackupActions.ts index 09a85be..3322e99 100644 --- a/webapp/src/hooks/useBackupActions.ts +++ b/webapp/src/hooks/useBackupActions.ts @@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) { return useMemo( () => ({ - async exportBackup(includeAttachments: boolean = false) { + async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) { const payload = await buildCompleteAdminBackupExport( authedFetch, + masterPasswordHash, includeAttachments, async (event: BackupExportClientProgressEvent) => { dispatchBackupProgress(event); @@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) { }); }, - async importBackup(file: File, replaceExisting: boolean = false) { - const result = await importAdminBackup(authedFetch, file, replaceExisting); + async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) { + const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting); onImported?.(); return result; }, - async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) { - const result = await importAdminBackup(authedFetch, file, replaceExisting, true); + async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) { + const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true); onImported?.(); return result; }, @@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) { return getAdminBackupSettings(authedFetch); }, - async saveSettings(settings: Parameters[1]) { - return saveAdminBackupSettings(authedFetch, settings); + async saveSettings(masterPasswordHash: string, settings: Parameters[2]) { + return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings); }, - async runRemoteBackup(destinationId?: string | null) { - return runAdminBackupNow(authedFetch, destinationId); + async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) { + return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId); }, async listRemoteBackups(destinationId: string, path: string) { return listRemoteBackups(authedFetch, destinationId, path); }, - async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) { - const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress); + async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) { + const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); }, @@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) { await deleteRemoteBackup(authedFetch, destinationId, path); }, - async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { - const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting); + async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) { + const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting); onRestored?.(); return result; }, - async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) { - const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true); + async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) { + const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true); onRestored?.(); return result; }, diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 073df69..25d8c17 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse { portable: BackupSettingsPortablePayload | null; } +export interface BackupUserVerificationPayload { + masterPasswordHash?: string | null; + userVerificationToken?: string | null; +} + export interface AdminBackupRunResponse { object: 'backup-run'; result: { @@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array) export async function exportAdminBackup( authedFetch: AuthedFetch, + masterPasswordHash: string, includeAttachments: boolean = false ): Promise { const resp = await authedFetch('/api/admin/backup/export', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ includeAttachments }), + body: JSON.stringify({ includeAttachments, masterPasswordHash }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed'))); @@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob( export async function buildCompleteAdminBackupExport( authedFetch: AuthedFetch, + masterPasswordHash: string, includeAttachments: boolean = false, onProgress?: (event: BackupExportClientProgressEvent) => void | Promise ): Promise { - const payload = await exportAdminBackup(authedFetch, includeAttachments); + const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments); if (!includeAttachments) { await onProgress?.({ operation: 'backup-export', @@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise< export async function saveAdminBackupSettings( authedFetch: AuthedFetch, + masterPasswordHash: string, settings: AdminBackupSettings ): Promise { const resp = await authedFetch('/api/admin/backup/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings), + body: JSON.stringify({ ...settings, masterPasswordHash }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); const body = await parseJson(resp); @@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState( export async function repairAdminBackupSettings( authedFetch: AuthedFetch, + verification: BackupUserVerificationPayload, settings: AdminBackupSettings ): Promise { const resp = await authedFetch('/api/admin/backup/settings/repair', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(settings), + body: JSON.stringify({ ...settings, ...verification }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); const body = await parseJson(resp); @@ -320,12 +329,13 @@ export async function repairAdminBackupSettings( export async function runAdminBackupNow( authedFetch: AuthedFetch, + masterPasswordHash: string, destinationId?: string | null ): Promise { const resp = await authedFetch('/api/admin/backup/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(destinationId ? { destinationId } : {}), + body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed'))); const body = await parseJson(resp); @@ -351,14 +361,16 @@ export async function listRemoteBackups( export async function downloadRemoteBackup( authedFetch: AuthedFetch, + masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void ): Promise { - const params = new URLSearchParams(); - params.set('destinationId', destinationId); - params.set('path', path); - const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' }); + const resp = await authedFetch('/api/admin/backup/remote/download', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ destinationId, path, masterPasswordHash }), + }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed'))); const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip'); @@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity( export async function restoreRemoteBackup( authedFetch: AuthedFetch, + masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false, @@ -426,7 +439,7 @@ export async function restoreRemoteBackup( const resp = await authedFetch('/api/admin/backup/remote/restore', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }), + body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }), }); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed'))); const body = await parseJson(resp); @@ -436,12 +449,14 @@ export async function restoreRemoteBackup( export async function importAdminBackup( authedFetch: AuthedFetch, + masterPasswordHash: string, file: File, replaceExisting: boolean = false, allowChecksumMismatch: boolean = false ): Promise { const formData = new FormData(); formData.set('file', file, file.name || 'nodewarden_backup.zip'); + formData.set('masterPasswordHash', masterPasswordHash); if (replaceExisting) { formData.set('replaceExisting', '1'); } diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index 7be9f47..6e6ef8a 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -66,6 +66,12 @@ export interface CompletedLogin { session: SessionState; profile: Profile; profilePromise: Promise; + freshMasterPasswordHash?: string | null; + freshUserVerificationToken?: string | null; +} + +function readTokenUserVerificationToken(token: TokenSuccess): string | null { + return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null; } export type PasswordLoginResult = @@ -319,7 +325,8 @@ export async function completeLogin( token: TokenSuccess, email: string, masterKey: Uint8Array, - fallbackKdfIterations: number + fallbackKdfIterations: number, + freshMasterPasswordHash?: string | null ): Promise { const normalizedEmail = email.trim().toLowerCase(); const fallbackProfile = loadProfileSnapshot(normalizedEmail); @@ -348,6 +355,8 @@ export async function completeLogin( session: { ...baseSession, ...keys }, profile, profilePromise: getProfile(tempFetch), + freshMasterPasswordHash: freshMasterPasswordHash || null, + freshUserVerificationToken: readTokenUserVerificationToken(token), }; } @@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys( token: TokenSuccess, email: string, keys: { symEncKey: string; symMacKey: string }, - fallbackKdfIterations: number + fallbackKdfIterations: number, + freshMasterPasswordHash?: string | null ): Promise { const normalizedEmail = email.trim().toLowerCase(); const fallbackProfile = loadProfileSnapshot(normalizedEmail); @@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys( session: { ...baseSession, ...keys }, profile, profilePromise: getProfile(tempFetch), + freshMasterPasswordHash: freshMasterPasswordHash || null, + freshUserVerificationToken: readTokenUserVerificationToken(token), }; } @@ -400,7 +412,7 @@ export async function performPasswordLogin( if ('access_token' in token && token.access_token) { return { kind: 'success', - login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), + login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash), }; } @@ -476,7 +488,7 @@ export async function completePasskeyPasswordLogin( password: string ): Promise { const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations); - return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations); + return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash); } export async function performTotpLogin( @@ -489,7 +501,7 @@ export async function performTotpLogin( rememberDevice, }); if ('access_token' in token && token.access_token) { - return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations); + return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash); } const tokenError = token as { error_description?: string; error?: string }; throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); @@ -508,7 +520,7 @@ export async function performRecoverTwoFactorLogin( if ('access_token' in token && token.access_token) { return { - login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), + login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash), newRecoveryCode: recovered.newRecoveryCode || null, }; } @@ -557,6 +569,7 @@ export async function performUnlock( session: offline.session, profile: offline.profile, profilePromise: Promise.resolve(offline.profile), + freshMasterPasswordHash: null, }, }; } catch { @@ -589,7 +602,7 @@ export async function performUnlock( if ('access_token' in token && token.access_token) { return { kind: 'success', - login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), + login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash), }; } diff --git a/webapp/src/lib/backup-settings-repair.ts b/webapp/src/lib/backup-settings-repair.ts index 7e44978..6c360d4 100644 --- a/webapp/src/lib/backup-settings-repair.ts +++ b/webapp/src/lib/backup-settings-repair.ts @@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types'; export async function silentlyRepairBackupSettingsIfNeeded( activeSession: SessionState, - activeProfile: Profile + activeProfile: Profile, + verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null ): Promise { if (activeProfile.role !== 'admin') return; if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; @@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded( try { const state = await getAdminBackupSettingsRepairState(tempFetch); if (!state.needsRepair || !state.portable) return; + if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return; const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); - await repairAdminBackupSettings(tempFetch, repairedSettings); + await repairAdminBackupSettings(tempFetch, verification, repairedSettings); } catch (error) { console.error('Backup settings auto-repair failed:', error); } diff --git a/webapp/src/lib/demo.ts b/webapp/src/lib/demo.ts index 956deab..ae0b4c3 100644 --- a/webapp/src/lib/demo.ts +++ b/webapp/src/lib/demo.ts @@ -1156,32 +1156,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti notify('success', t('txt_logs_cleared')); return 0; }, - onExportBackup: async () => { + onExportBackup: async (_masterPassword: string) => { notify('success', t('txt_backup_export_success')); }, - onImportBackup: async () => { + onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => { resetDemoVaultState(state); notify('success', t('txt_backup_import_success_relogin')); return createDemoImportBackupResult(); }, - onImportBackupAllowingChecksumMismatch: async () => { + onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => { resetDemoVaultState(state); notify('success', t('txt_backup_import_success_relogin')); return createDemoImportBackupResult(); }, onLoadBackupSettings: async () => state.backupSettings, - onSaveBackupSettings: async (settings) => { + onSaveBackupSettings: async (_masterPassword: string, settings) => { const next = cloneJson(settings); state.setBackupSettings(next); notify('success', t('txt_backup_settings_saved')); return next; }, - onRunRemoteBackup: async (destinationId?: string | null) => { + onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => { notify('success', t('txt_backup_remote_run_success')); return createDemoBackupRun(state.backupSettings, destinationId); }, onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path), - onDownloadRemoteBackup: async () => { + onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => { notify('success', t('txt_demo_download_prepared')); }, onInspectRemoteBackup: async (_destinationId: string, path: string) => ({ @@ -1199,13 +1199,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti onDeleteRemoteBackup: async () => { notify('success', t('txt_backup_remote_delete_success')); }, - onRestoreRemoteBackup: async (_destinationId, path) => { + onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => { await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); resetDemoVaultState(state); notify('success', t('txt_backup_remote_restore_completed_verified')); return createDemoImportBackupResult(); }, - onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => { + onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => { await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); resetDemoVaultState(state); notify('success', t('txt_backup_remote_restore_completed_verified')); diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index d536a98..c19413a 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -314,6 +314,8 @@ export interface TokenSuccess { ResetMasterPassword?: boolean; scope?: string; unofficialServer?: boolean; + UserVerificationToken?: string; + userVerificationToken?: string; UserDecryptionOptions?: unknown; userDecryptionOptions?: unknown; VaultKeys?: {