From 1b0386bf78010605abeedb67d628f093ef4afaa6 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Tue, 28 Apr 2026 22:10:34 +0800 Subject: [PATCH] feat: implement vault synchronization and decryption improvements - Added background synchronization for vault core data, including optional folder updates. - Introduced a new API endpoint to retrieve the vault revision date. - Enhanced vault synchronization logic to utilize a caching mechanism for improved performance. - Created a new vault cache module to handle IndexedDB storage for vault core snapshots. - Implemented a worker for asynchronous decryption of vault data, improving UI responsiveness. - Updated main application settings to adjust query stale time for better data freshness. - Refactored vault-related API functions to support cache keys for more efficient data retrieval. --- webapp/src/App.tsx | 454 +++++++-------------- webapp/src/hooks/useVaultSendActions.ts | 23 +- webapp/src/lib/api/auth.ts | 13 + webapp/src/lib/api/vault-sync.ts | 59 ++- webapp/src/lib/api/vault.ts | 8 +- webapp/src/lib/vault-cache.ts | 108 +++++ webapp/src/lib/vault-decrypt.ts | 288 +++++++++++++ webapp/src/lib/vault-worker.ts | 55 +++ webapp/src/main.tsx | 1 + webapp/src/workers/vault-decrypt.worker.ts | 24 ++ 10 files changed, 702 insertions(+), 331 deletions(-) create mode 100644 webapp/src/lib/vault-cache.ts create mode 100644 webapp/src/lib/vault-decrypt.ts create mode 100644 webapp/src/lib/vault-worker.ts create mode 100644 webapp/src/workers/vault-decrypt.worker.ts diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index adfe1e1..e94a0be 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -21,18 +21,14 @@ import { stripProfileSecrets, } from '@/lib/api/auth'; import { listAdminInvites, listAdminUsers } from '@/lib/api/admin'; -import { buildSendShareKey, getSends } from '@/lib/api/send'; +import { getSends } from '@/lib/api/send'; +import { getCachedVaultCoreSnapshot, loadVaultCoreSyncSnapshot } from '@/lib/api/vault-sync'; import { - getCiphers, - getFolders, repairCipherAttachmentMetadata, updateFolder, } from '@/lib/api/vault'; import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair'; -import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto'; import { - buildPublicSendUrl, - deriveSendKeyParts, looksLikeCipherString, parseSignalRTextFrames, readInviteCodeFromUrl, @@ -58,7 +54,10 @@ import { useToastManager } from '@/hooks/useToastManager'; import { t } from '@/lib/i18n'; import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify'; import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress'; +import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt'; +import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker'; import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types'; +import type { VaultCoreSnapshot } from '@/lib/vault-cache'; function isBackupProgressDetail(value: unknown): value is BackupProgressDetail { if (!value || typeof value !== 'object') return false; @@ -90,40 +89,6 @@ type SessionTimeoutAction = 'lock' | 'logout'; const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1'; const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1'; const LOCK_TIMEOUT_VALUES = new Set([0, 1, 5, 15, 30]); -const DECRYPT_BATCH_SIZE = 16; - -function yieldToMainThread(): Promise { - return new Promise((resolve) => { - if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') { - window.setTimeout(resolve, 0); - return; - } - setTimeout(resolve, 0); - }); -} - -async function mapAsyncInBatches( - items: readonly T[], - mapper: (item: T, index: number) => Promise, - options?: { batchSize?: number; shouldContinue?: () => boolean } -): Promise { - const batchSize = Math.max(1, options?.batchSize || DECRYPT_BATCH_SIZE); - const result: R[] = new Array(items.length); - for (let start = 0; start < items.length; start += batchSize) { - if (options?.shouldContinue && !options.shouldContinue()) break; - const end = Math.min(items.length, start + batchSize); - const chunk = items.slice(start, end); - const mapped = await Promise.all(chunk.map((item, offset) => mapper(item, start + offset))); - for (let i = 0; i < mapped.length; i += 1) { - result[start + i] = mapped[i]; - } - if (end < items.length) { - await yieldToMainThread(); - } - } - return result; -} - function readThemePreference(): ThemePreference { if (typeof window === 'undefined') return 'system'; const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim(); @@ -202,12 +167,16 @@ export default function App() { const [decryptedFolders, setDecryptedFolders] = useState([]); const [decryptedCiphers, setDecryptedCiphers] = useState([]); const [decryptedSends, setDecryptedSends] = useState([]); + const [cachedVaultCore, setCachedVaultCore] = useState(null); const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false); const sessionRef = useRef(initialBootstrap.session); const migratedPlainFolderIdsRef = useRef>(new Set()); const silentRefreshVaultRef = useRef<() => Promise>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise>(async () => {}); const repairAttemptRef = useRef(''); + const pendingVaultCoreQueryRefreshRef = useRef | null>(null); + const pendingVaultCoreRefreshRef = useRef | null>(null); + const notificationRefreshTimerRef = useRef(null); const { toasts, pushToast, removeToast } = useToastManager(); useEffect(() => { @@ -363,6 +332,7 @@ export default function App() { }, [authedFetch] ); + const vaultCacheKey = String(profile?.id || session?.email || '').trim(); const backupActions = useBackupActions({ authedFetch, onImported: () => { @@ -761,40 +731,74 @@ export default function App() { ); } - const ciphersQuery = useQuery({ - queryKey: ['ciphers', session?.accessToken], - queryFn: () => getCiphers(authedFetch), - enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, - }); - const foldersQuery = useQuery({ - queryKey: ['folders', session?.accessToken], - queryFn: () => getFolders(authedFetch), - enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey, + useEffect(() => { + let cancelled = false; + if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) { + setCachedVaultCore(null); + return; + } + void (async () => { + const snapshot = await getCachedVaultCoreSnapshot(vaultCacheKey); + if (!cancelled) { + setCachedVaultCore(snapshot); + } + })(); + return () => { + cancelled = true; + }; + }, [phase, session?.symEncKey, session?.symMacKey, vaultCacheKey]); + + async function refetchVaultCoreData() { + if (pendingVaultCoreQueryRefreshRef.current) { + return pendingVaultCoreQueryRefreshRef.current; + } + const request = vaultCoreQuery.refetch().finally(() => { + if (pendingVaultCoreQueryRefreshRef.current === request) { + pendingVaultCoreQueryRefreshRef.current = null; + } + }); + pendingVaultCoreQueryRefreshRef.current = request; + return request; + } + + const vaultCoreQuery = useQuery({ + queryKey: ['vault-core', vaultCacheKey], + queryFn: () => loadVaultCoreSyncSnapshot(authedFetch, vaultCacheKey), + enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && !!vaultCacheKey, + staleTime: 30_000, }); + const encryptedVaultCore = vaultCoreQuery.data || cachedVaultCore; + const encryptedFolders = encryptedVaultCore?.folders; + const encryptedCiphers = encryptedVaultCore?.ciphers; const sendsQuery = useQuery({ - queryKey: ['sends', session?.accessToken], + queryKey: ['sends', vaultCacheKey || session?.email], queryFn: () => getSends(authedFetch), enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'), + staleTime: 30_000, }); const usersQuery = useQuery({ - queryKey: ['admin-users', session?.accessToken], + queryKey: ['admin-users', vaultCacheKey], queryFn: () => listAdminUsers(authedFetch), enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone, + staleTime: 30_000, }); const invitesQuery = useQuery({ - queryKey: ['admin-invites', session?.accessToken], + queryKey: ['admin-invites', vaultCacheKey], queryFn: () => listAdminInvites(authedFetch), enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone, + staleTime: 30_000, }); const totpStatusQuery = useQuery({ - queryKey: ['totp-status', session?.accessToken], + queryKey: ['totp-status', vaultCacheKey || session?.email], queryFn: () => getTotpStatus(authedFetch), enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, + staleTime: 30_000, }); const authorizedDevicesQuery = useQuery({ - queryKey: ['authorized-devices', session?.accessToken], + queryKey: ['authorized-devices', vaultCacheKey || session?.email], queryFn: () => getAuthorizedDevices(authedFetch), enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, + staleTime: 30_000, }); useEffect(() => { @@ -820,214 +824,35 @@ export default function App() { setVaultInitialDecryptDone(false); return; } - if (!foldersQuery.data || !ciphersQuery.data) return; + if (!encryptedFolders || !encryptedCiphers) return; let active = true; (async () => { try { - const encKey = base64ToBytes(session.symEncKey!); - const macKey = base64ToBytes(session.symMacKey!); - const decryptField = async ( - value: string | null | undefined, - fieldEnc: Uint8Array = encKey, - fieldMac: Uint8Array = macKey - ): Promise => { - if (!value || typeof value !== 'string') return ''; - try { - return await decryptStr(value, fieldEnc, fieldMac); - } catch { - // Backward-compatibility: some records may already be plain text. - return value; - } - }; - const sameBytes = (a: Uint8Array, b: Uint8Array) => { - if (a.byteLength !== b.byteLength) return false; - for (let i = 0; i < a.byteLength; i += 1) { - if (a[i] !== b[i]) return false; - } - return true; - }; - const decryptFieldWithSource = async ( - value: string | null | undefined, - itemEnc: Uint8Array, - itemMac: Uint8Array, - canFallbackToUserKey: boolean - ): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> => { - const raw = String(value || '').trim(); - if (!raw) return { text: '', source: 'plain' }; - try { - return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' }; - } catch { - // 继续尝试旧 user key 数据。 - } - if (canFallbackToUserKey) { - try { - return { text: await decryptStr(raw, encKey, macKey), source: 'user' }; - } catch { - // 保留原文。 - } - } - return { text: raw, source: 'plain' }; - }; - - const folders = await mapAsyncInBatches( - foldersQuery.data, - async (folder) => ({ - ...folder, - decName: await decryptField(folder.name, encKey, macKey), - }), - { shouldContinue: () => active } - ); - - const ciphers = await mapAsyncInBatches( - ciphersQuery.data, - async (cipher) => { - let itemEnc = encKey; - let itemMac = macKey; - if (cipher.key) { - try { - const itemKey = await decryptBw(cipher.key, encKey, macKey); - itemEnc = itemKey.slice(0, 32); - itemMac = itemKey.slice(32, 64); - } catch { - // keep user key when item key decrypt fails - } - } - const itemUsesUserKey = sameBytes(itemEnc, encKey) && sameBytes(itemMac, macKey); - - const nextCipher: Cipher = { - ...cipher, - decName: await decryptField(cipher.name || '', itemEnc, itemMac), - decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac), - }; - if (cipher.login) { - nextCipher.login = { - ...cipher.login, - decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), - decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), - decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), - uris: await Promise.all( - (cipher.login.uris || []).map(async (u) => ({ - ...u, - decUri: await decryptField(u.uri || '', itemEnc, itemMac), - })) - ), - }; - } - if (Array.isArray(cipher.passwordHistory)) { - nextCipher.passwordHistory = await Promise.all( - cipher.passwordHistory.map(async (entry) => ({ - ...entry, - decPassword: await decryptField(entry?.password || '', itemEnc, itemMac), - })) - ); - } - if (cipher.card) { - nextCipher.card = { - ...cipher.card, - decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac), - decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac), - decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac), - decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac), - decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac), - decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac), - }; - } - if (cipher.identity) { - nextCipher.identity = { - ...cipher.identity, - decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac), - decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac), - decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac), - decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac), - decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac), - decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac), - decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac), - decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac), - decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac), - decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac), - decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac), - decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac), - decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac), - decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac), - decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac), - decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac), - decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac), - decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac), - }; - } - if (cipher.sshKey) { - const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || ''; - nextCipher.sshKey = { - ...cipher.sshKey, - decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), - decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), - keyFingerprint: encryptedFingerprint || null, - fingerprint: encryptedFingerprint || null, - decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac), - }; - } - if (cipher.fields) { - nextCipher.fields = await Promise.all( - cipher.fields.map(async (field) => ({ - ...field, - decName: await decryptField(field.name || '', itemEnc, itemMac), - decValue: await decryptField(field.value || '', itemEnc, itemMac), - })) - ); - } - if (Array.isArray(cipher.attachments)) { - nextCipher.attachments = await Promise.all( - cipher.attachments.map(async (attachment) => { - const attachmentId = String(attachment?.id || '').trim(); - const fileNameResult = await decryptFieldWithSource(attachment.fileName || '', itemEnc, itemMac, !itemUsesUserKey); - const metadata: { fileName?: string; key?: string | null } = {}; - - if (attachmentId && fileNameResult.source === 'user') { - metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac); - } - - const attachmentKey = String(attachment?.key || '').trim(); - if ( - attachmentId && - attachmentKey && - looksLikeCipherString(attachmentKey) && - !itemUsesUserKey - ) { - try { - await decryptBw(attachmentKey, itemEnc, itemMac); - } catch { - try { - const rawAttachmentKey = await decryptBw(attachmentKey, encKey, macKey); - if (rawAttachmentKey.length >= 64) { - metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac); - } - } catch { - // 文件下载时会继续尝试旧格式。 - } - } - } - - if (attachmentId && Object.keys(metadata).length > 0) { - void repairCipherAttachmentMetadata(authedFetch, cipher.id, attachmentId, metadata); - } - - return { - ...attachment, - decFileName: fileNameResult.text, - }; - }) - ); - } - return nextCipher; - }, - { shouldContinue: () => active } - ); + let result; + try { + result = await decryptVaultCoreInWorker({ + folders: encryptedFolders, + ciphers: encryptedCiphers, + symEncKeyB64: session.symEncKey!, + symMacKeyB64: session.symMacKey!, + }); + } catch { + result = await decryptVaultCore({ + folders: encryptedFolders, + ciphers: encryptedCiphers, + symEncKeyB64: session.symEncKey!, + symMacKeyB64: session.symMacKey!, + }); + } if (!active) return; - setDecryptedFolders(folders); - setDecryptedCiphers(ciphers); + setDecryptedFolders(result.folders); + setDecryptedCiphers(result.ciphers); setVaultInitialDecryptDone(true); + for (const repair of result.attachmentRepairs) { + void repairCipherAttachmentMetadata(authedFetch, repair.cipherId, repair.attachmentId, repair.metadata); + } } catch (error) { if (!active) return; pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2')); @@ -1037,7 +862,7 @@ export default function App() { return () => { active = false; }; - }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]); + }, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers, authedFetch]); useEffect(() => { if (!session?.symEncKey || !session?.symMacKey) { @@ -1049,53 +874,22 @@ export default function App() { let active = true; (async () => { try { - const encKey = base64ToBytes(session.symEncKey!); - const macKey = base64ToBytes(session.symMacKey!); - const decryptField = async ( - value: string | null | undefined, - fieldEnc: Uint8Array = encKey, - fieldMac: Uint8Array = macKey - ): Promise => { - if (!value || typeof value !== 'string') return ''; - try { - return await decryptStr(value, fieldEnc, fieldMac); - } catch { - return value; - } - }; - const sends = await mapAsyncInBatches( - sendsQuery.data, - async (send) => { - const nextSend: Send = { ...send }; - try { - if (send.key) { - const sendKeyRaw = await decryptBw(send.key, encKey, macKey); - const derived = await deriveSendKeyParts(sendKeyRaw); - nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); - nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); - nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); - if (send.file?.fileName) { - const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); - nextSend.file = { - ...(send.file || {}), - fileName: decFileName || send.file.fileName, - }; - } - const shareKey = await buildSendShareKey(send.key, session.symEncKey!, session.symMacKey!); - nextSend.decShareKey = shareKey; - nextSend.shareUrl = buildPublicSendUrl(window.location.origin, send.accessId, shareKey); - } else { - nextSend.decName = ''; - nextSend.decNotes = ''; - nextSend.decText = ''; - } - } catch { - nextSend.decName = t('txt_decrypt_failed'); - } - return nextSend; - }, - { shouldContinue: () => active } - ); + let sends; + try { + sends = await decryptSendsInWorker({ + sends: sendsQuery.data, + symEncKeyB64: session.symEncKey!, + symMacKeyB64: session.symMacKey!, + origin: window.location.origin, + }); + } catch { + sends = await decryptSends({ + sends: sendsQuery.data, + symEncKeyB64: session.symEncKey!, + symMacKeyB64: session.symMacKey!, + origin: window.location.origin, + }); + } if (!active) return; setDecryptedSends(sends); @@ -1111,10 +905,10 @@ export default function App() { }, [session?.symEncKey, session?.symMacKey, sendsQuery.data]); useEffect(() => { - if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return; + if (!session?.symEncKey || !session?.symMacKey || !encryptedFolders?.length) return; let cancelled = false; (async () => { - const pending = foldersQuery.data.filter((folder) => { + const pending = encryptedFolders.filter((folder) => { if (!folder?.id || !folder?.name) return false; if (migratedPlainFolderIdsRef.current.has(folder.id)) return false; return !looksLikeCipherString(String(folder.name)); @@ -1128,15 +922,29 @@ export default function App() { // keep silent; web still supports plaintext fallback display } } - if (!cancelled) await foldersQuery.refetch(); + if (!cancelled) await refetchVaultCoreData(); })(); return () => { cancelled = true; }; - }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]); + }, [session?.symEncKey, session?.symMacKey, encryptedFolders, authedFetch]); async function refreshVaultSilently() { - await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]); + if (pendingVaultCoreRefreshRef.current) { + await pendingVaultCoreRefreshRef.current; + return; + } + const tasks: Promise[] = [refetchVaultCoreData()]; + if (location === '/sends') { + tasks.push(sendsQuery.refetch()); + } + const request = Promise.all(tasks).finally(() => { + if (pendingVaultCoreRefreshRef.current === request) { + pendingVaultCoreRefreshRef.current = null; + } + }); + pendingVaultCoreRefreshRef.current = request; + await request; } silentRefreshVaultRef.current = refreshVaultSilently; @@ -1233,7 +1041,13 @@ export default function App() { if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; const contextId = String(frame.arguments?.[0]?.ContextId || '').trim(); if (contextId && contextId === getCurrentDeviceIdentifier()) continue; - void silentRefreshVaultRef.current(); + if (notificationRefreshTimerRef.current !== null) { + window.clearTimeout(notificationRefreshTimerRef.current); + } + notificationRefreshTimerRef.current = window.setTimeout(() => { + notificationRefreshTimerRef.current = null; + void silentRefreshVaultRef.current(); + }, 250); } }); @@ -1257,6 +1071,10 @@ export default function App() { return () => { disposed = true; + if (notificationRefreshTimerRef.current !== null) { + window.clearTimeout(notificationRefreshTimerRef.current); + notificationRefreshTimerRef.current = null; + } clearReconnectTimer(); if (socket) { const s = socket; @@ -1276,10 +1094,16 @@ export default function App() { session, profile, defaultKdfIterations, - encryptedCiphers: ciphersQuery.data, - encryptedFolders: foldersQuery.data, - refetchCiphers: ciphersQuery.refetch, - refetchFolders: foldersQuery.refetch, + encryptedCiphers, + encryptedFolders, + refetchCiphers: async () => { + const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; + return { data: result.data?.ciphers }; + }, + refetchFolders: async () => { + const result = await refetchVaultCoreData() as { data?: VaultCoreSnapshot }; + return { data: result.data?.folders }; + }, refetchSends: sendsQuery.refetch, onNotify: pushToast, patchDecryptedCiphers: setDecryptedCiphers, @@ -1379,8 +1203,8 @@ export default function App() { decryptedCiphers, decryptedFolders, decryptedSends, - ciphersLoading: ciphersQuery.isFetching, - foldersLoading: foldersQuery.isFetching, + ciphersLoading: vaultCoreQuery.isFetching, + foldersLoading: vaultCoreQuery.isFetching, sendsLoading: sendsQuery.isFetching, users: usersQuery.data || [], invites: invitesQuery.data || [], diff --git a/webapp/src/hooks/useVaultSendActions.ts b/webapp/src/hooks/useVaultSendActions.ts index ca3b338..b149737 100644 --- a/webapp/src/hooks/useVaultSendActions.ts +++ b/webapp/src/hooks/useVaultSendActions.ts @@ -111,6 +111,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]); }; + const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => { + const tasks: Promise[] = [Promise.resolve(refetchCiphers())]; + if (options?.includeFolders) { + tasks.push(Promise.resolve(refetchFolders())); + } + void Promise.all(tasks).catch(() => undefined); + }; + async function decryptAndPatch(encrypted: Cipher) { if (!session?.symEncKey || !session?.symMacKey) { await refetchCiphers(); @@ -202,7 +210,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent); } await decryptAndPatch(created); - if (draft.folderId) await refetchFolders(); + syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 }); onNotify('success', t('txt_item_created')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed')); @@ -230,7 +238,12 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) await uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent); } await decryptAndPatch(updated); - if (draft.folderId !== (cipher.folderId || '')) await refetchFolders(); + syncVaultCoreInBackground({ + includeFolders: + draft.folderId !== (cipher.folderId || '') + || addFiles.length > 0 + || removeAttachmentIds.length > 0, + }); onNotify('success', t('txt_item_updated')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed')); @@ -263,7 +276,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const deleted = await deleteCipher(authedFetch, cipher.id); await decryptAndPatch(deleted); - await refetchFolders(); + syncVaultCoreInBackground({ includeFolders: true }); onNotify('success', t('txt_item_deleted')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed')); @@ -275,7 +288,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const archived = await archiveCipher(authedFetch, cipher.id); await decryptAndPatch(archived); - await refetchFolders(); + syncVaultCoreInBackground({ includeFolders: true }); onNotify('success', t('txt_item_archived')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed')); @@ -287,7 +300,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions) try { const unarchived = await unarchiveCipher(authedFetch, cipher.id); await decryptAndPatch(unarchived); - await refetchFolders(); + syncVaultCoreInBackground({ includeFolders: true }); onNotify('success', t('txt_item_unarchived')); } catch (error) { onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed')); diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index ed91719..544947a 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -518,6 +518,19 @@ export async function verifyMasterPassword( } } +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'); diff --git a/webapp/src/lib/api/vault-sync.ts b/webapp/src/lib/api/vault-sync.ts index 9254cc2..0ffda4e 100644 --- a/webapp/src/lib/api/vault-sync.ts +++ b/webapp/src/lib/api/vault-sync.ts @@ -1,4 +1,6 @@ import type { Cipher, Folder, Send } from '../types'; +import { getVaultRevisionDate } from './auth'; +import { loadCachedVaultCoreSnapshot, saveCachedVaultCoreSnapshot, type VaultCoreSnapshot } from '../vault-cache'; import { parseJson, type AuthedFetch } from './shared'; interface VaultSyncResponse { @@ -7,13 +9,53 @@ interface VaultSyncResponse { sends?: Send[]; } -const pendingVaultCoreRequests = new WeakMap>(); +const pendingVaultCoreRequests = new Map>(); +const memoryVaultCoreCache = new Map(); -export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promise { - const existing = pendingVaultCoreRequests.get(authedFetch); +function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCoreSnapshot { + return { + ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [], + folders: Array.isArray(body?.folders) ? body!.folders! : [], + }; +} + +export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise { + const normalizedKey = String(cacheKey || '').trim(); + if (!normalizedKey) return null; + const memory = memoryVaultCoreCache.get(normalizedKey); + if (memory) return memory.snapshot; + const cached = await loadCachedVaultCoreSnapshot(normalizedKey); + if (!cached?.snapshot) return null; + memoryVaultCoreCache.set(normalizedKey, { + revisionStamp: cached.revisionStamp, + snapshot: cached.snapshot, + }); + return cached.snapshot; +} + +export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch, cacheKey: string): Promise { + const normalizedKey = String(cacheKey || '').trim(); + if (!normalizedKey) return { ciphers: [], folders: [] }; + + const existing = pendingVaultCoreRequests.get(normalizedKey); if (existing) return existing; const request = (async () => { + const revisionStamp = await getVaultRevisionDate(authedFetch); + const memory = memoryVaultCoreCache.get(normalizedKey); + if (memory?.revisionStamp === revisionStamp) { + return memory.snapshot; + } + + const cached = await loadCachedVaultCoreSnapshot(normalizedKey); + if (cached?.revisionStamp === revisionStamp && cached.snapshot) { + memoryVaultCoreCache.set(normalizedKey, { + revisionStamp, + snapshot: cached.snapshot, + }); + return cached.snapshot; + } + const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', { cache: 'no-store', headers: { @@ -23,15 +65,18 @@ export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promi }); if (!resp.ok) throw new Error('Failed to load vault'); const body = await parseJson(resp); - return body || {}; + const snapshot = normalizeSnapshot(body); + memoryVaultCoreCache.set(normalizedKey, { revisionStamp, snapshot }); + void saveCachedVaultCoreSnapshot(normalizedKey, revisionStamp, snapshot); + return snapshot; })(); - pendingVaultCoreRequests.set(authedFetch, request); + pendingVaultCoreRequests.set(normalizedKey, request); try { return await request; } finally { - if (pendingVaultCoreRequests.get(authedFetch) === request) { - pendingVaultCoreRequests.delete(authedFetch); + if (pendingVaultCoreRequests.get(normalizedKey) === request) { + pendingVaultCoreRequests.delete(normalizedKey); } } } diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index d97c7a0..259b47e 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -19,8 +19,8 @@ import { import { readResponseBytesWithProgress } from '../download'; import { loadVaultCoreSyncSnapshot } from './vault-sync'; -export async function getFolders(authedFetch: AuthedFetch): Promise { - const body = await loadVaultCoreSyncSnapshot(authedFetch); +export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise { + const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); return body.folders || []; } @@ -92,8 +92,8 @@ export async function updateFolder( if (!resp.ok) throw new Error('Update folder failed'); } -export async function getCiphers(authedFetch: AuthedFetch): Promise { - const body = await loadVaultCoreSyncSnapshot(authedFetch); +export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise { + const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey); return body.ciphers || []; } diff --git a/webapp/src/lib/vault-cache.ts b/webapp/src/lib/vault-cache.ts new file mode 100644 index 0000000..802b2d2 --- /dev/null +++ b/webapp/src/lib/vault-cache.ts @@ -0,0 +1,108 @@ +import type { Cipher, Folder } from './types'; + +export interface VaultCoreSnapshot { + ciphers: Cipher[]; + folders: Folder[]; +} + +interface VaultCoreCacheRecord { + cacheKey: string; + revisionStamp: number; + savedAt: number; + snapshot: VaultCoreSnapshot; +} + +const DB_NAME = 'nodewarden-web-cache'; +const DB_VERSION = 1; +const VAULT_CORE_STORE = 'vault-core'; + +let dbPromise: Promise | null = null; + +function supportsIndexedDb(): boolean { + return typeof indexedDB !== 'undefined'; +} + +function openDatabase(): Promise { + if (!supportsIndexedDb()) return Promise.resolve(null); + if (dbPromise) return dbPromise; + dbPromise = new Promise((resolve) => { + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); + request.onupgradeneeded = () => { + const db = request.result; + if (!db.objectStoreNames.contains(VAULT_CORE_STORE)) { + db.createObjectStore(VAULT_CORE_STORE, { keyPath: 'cacheKey' }); + } + }; + request.onsuccess = () => resolve(request.result); + request.onerror = () => resolve(null); + request.onblocked = () => resolve(null); + } catch { + resolve(null); + } + }); + return dbPromise; +} + +function withStore( + mode: IDBTransactionMode, + run: (store: IDBObjectStore) => Promise +): Promise { + return openDatabase().then((db) => { + if (!db) return null; + return new Promise((resolve) => { + try { + const tx = db.transaction(VAULT_CORE_STORE, mode); + const store = tx.objectStore(VAULT_CORE_STORE); + void run(store).then(resolve).catch(() => resolve(null)); + tx.onerror = () => resolve(null); + tx.onabort = () => resolve(null); + } catch { + resolve(null); + } + }); + }); +} + +export async function loadCachedVaultCoreSnapshot(cacheKey: string): Promise { + const normalized = String(cacheKey || '').trim(); + if (!normalized) return null; + return withStore('readonly', (store) => new Promise((resolve) => { + const request = store.get(normalized); + request.onsuccess = () => { + const record = request.result as VaultCoreCacheRecord | undefined; + resolve(record || null); + }; + request.onerror = () => resolve(null); + })); +} + +export async function saveCachedVaultCoreSnapshot( + cacheKey: string, + revisionStamp: number, + snapshot: VaultCoreSnapshot +): Promise { + const normalized = String(cacheKey || '').trim(); + if (!normalized) return; + await withStore('readwrite', (store) => new Promise((resolve) => { + const record: VaultCoreCacheRecord = { + cacheKey: normalized, + revisionStamp, + savedAt: Date.now(), + snapshot, + }; + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + })); +} + +export async function clearCachedVaultCoreSnapshot(cacheKey: string): Promise { + const normalized = String(cacheKey || '').trim(); + if (!normalized) return; + await withStore('readwrite', (store) => new Promise((resolve) => { + const request = store.delete(normalized); + request.onsuccess = () => resolve(); + request.onerror = () => resolve(); + })); +} diff --git a/webapp/src/lib/vault-decrypt.ts b/webapp/src/lib/vault-decrypt.ts new file mode 100644 index 0000000..35a1578 --- /dev/null +++ b/webapp/src/lib/vault-decrypt.ts @@ -0,0 +1,288 @@ +import { base64ToBytes, decryptBw, decryptStr, encryptBw } from './crypto'; +import { deriveSendKeyParts } from './app-support'; +import type { Cipher, Folder, Send } from './types'; + +export interface AttachmentRepairTask { + cipherId: string; + attachmentId: string; + metadata: { fileName?: string; key?: string | null }; +} + +export interface DecryptVaultCoreArgs { + folders: Folder[]; + ciphers: Cipher[]; + symEncKeyB64: string; + symMacKeyB64: string; +} + +export interface DecryptVaultCoreResult { + folders: Folder[]; + ciphers: Cipher[]; + attachmentRepairs: AttachmentRepairTask[]; +} + +export interface DecryptSendsArgs { + sends: Send[]; + symEncKeyB64: string; + symMacKeyB64: string; + origin: string; +} + +function looksLikeCipherString(value: string): boolean { + return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim()); +} + +function sameBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.byteLength !== b.byteLength) return false; + for (let i = 0; i < a.byteLength; i += 1) { + if (a[i] !== b[i]) return false; + } + return true; +} + +async function decryptField( + value: string | null | undefined, + enc: Uint8Array, + mac: Uint8Array +): Promise { + if (!value || typeof value !== 'string') return ''; + try { + return await decryptStr(value, enc, mac); + } catch { + return value; + } +} + +async function decryptFieldWithSource( + value: string | null | undefined, + itemEnc: Uint8Array, + itemMac: Uint8Array, + userEnc: Uint8Array, + userMac: Uint8Array, + canFallbackToUserKey: boolean +): Promise<{ text: string; source: 'item' | 'user' | 'plain' }> { + const raw = String(value || '').trim(); + if (!raw) return { text: '', source: 'plain' }; + try { + return { text: await decryptStr(raw, itemEnc, itemMac), source: 'item' }; + } catch { + // Try legacy user-key fallback below. + } + if (canFallbackToUserKey) { + try { + return { text: await decryptStr(raw, userEnc, userMac), source: 'user' }; + } catch { + // Keep plain fallback. + } + } + return { text: raw, source: 'plain' }; +} + +export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise { + const userEnc = base64ToBytes(args.symEncKeyB64); + const userMac = base64ToBytes(args.symMacKeyB64); + const attachmentRepairs: AttachmentRepairTask[] = []; + + const folders = await Promise.all( + args.folders.map(async (folder) => ({ + ...folder, + decName: await decryptField(folder.name, userEnc, userMac), + })) + ); + + const ciphers = await Promise.all( + args.ciphers.map(async (cipher) => { + let itemEnc = userEnc; + let itemMac = userMac; + if (cipher.key) { + try { + const itemKey = await decryptBw(cipher.key, userEnc, userMac); + itemEnc = itemKey.slice(0, 32); + itemMac = itemKey.slice(32, 64); + } catch { + // Keep user key fallback. + } + } + const itemUsesUserKey = sameBytes(itemEnc, userEnc) && sameBytes(itemMac, userMac); + const nextCipher: Cipher = { + ...cipher, + decName: await decryptField(cipher.name || '', itemEnc, itemMac), + decNotes: await decryptField(cipher.notes || '', itemEnc, itemMac), + }; + + if (cipher.login) { + nextCipher.login = { + ...cipher.login, + decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac), + decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac), + decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac), + uris: await Promise.all( + (cipher.login.uris || []).map(async (uri) => ({ + ...uri, + decUri: await decryptField(uri.uri || '', itemEnc, itemMac), + })) + ), + }; + } + + if (Array.isArray(cipher.passwordHistory)) { + nextCipher.passwordHistory = await Promise.all( + cipher.passwordHistory.map(async (entry) => ({ + ...entry, + decPassword: await decryptField(entry?.password || '', itemEnc, itemMac), + })) + ); + } + + if (cipher.card) { + nextCipher.card = { + ...cipher.card, + decCardholderName: await decryptField(cipher.card.cardholderName || '', itemEnc, itemMac), + decNumber: await decryptField(cipher.card.number || '', itemEnc, itemMac), + decBrand: await decryptField(cipher.card.brand || '', itemEnc, itemMac), + decExpMonth: await decryptField(cipher.card.expMonth || '', itemEnc, itemMac), + decExpYear: await decryptField(cipher.card.expYear || '', itemEnc, itemMac), + decCode: await decryptField(cipher.card.code || '', itemEnc, itemMac), + }; + } + + if (cipher.identity) { + nextCipher.identity = { + ...cipher.identity, + decTitle: await decryptField(cipher.identity.title || '', itemEnc, itemMac), + decFirstName: await decryptField(cipher.identity.firstName || '', itemEnc, itemMac), + decMiddleName: await decryptField(cipher.identity.middleName || '', itemEnc, itemMac), + decLastName: await decryptField(cipher.identity.lastName || '', itemEnc, itemMac), + decUsername: await decryptField(cipher.identity.username || '', itemEnc, itemMac), + decCompany: await decryptField(cipher.identity.company || '', itemEnc, itemMac), + decSsn: await decryptField(cipher.identity.ssn || '', itemEnc, itemMac), + decPassportNumber: await decryptField(cipher.identity.passportNumber || '', itemEnc, itemMac), + decLicenseNumber: await decryptField(cipher.identity.licenseNumber || '', itemEnc, itemMac), + decEmail: await decryptField(cipher.identity.email || '', itemEnc, itemMac), + decPhone: await decryptField(cipher.identity.phone || '', itemEnc, itemMac), + decAddress1: await decryptField(cipher.identity.address1 || '', itemEnc, itemMac), + decAddress2: await decryptField(cipher.identity.address2 || '', itemEnc, itemMac), + decAddress3: await decryptField(cipher.identity.address3 || '', itemEnc, itemMac), + decCity: await decryptField(cipher.identity.city || '', itemEnc, itemMac), + decState: await decryptField(cipher.identity.state || '', itemEnc, itemMac), + decPostalCode: await decryptField(cipher.identity.postalCode || '', itemEnc, itemMac), + decCountry: await decryptField(cipher.identity.country || '', itemEnc, itemMac), + }; + } + + if (cipher.sshKey) { + const encryptedFingerprint = cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint || ''; + nextCipher.sshKey = { + ...cipher.sshKey, + decPrivateKey: await decryptField(cipher.sshKey.privateKey || '', itemEnc, itemMac), + decPublicKey: await decryptField(cipher.sshKey.publicKey || '', itemEnc, itemMac), + keyFingerprint: encryptedFingerprint || null, + fingerprint: encryptedFingerprint || null, + decFingerprint: await decryptField(encryptedFingerprint, itemEnc, itemMac), + }; + } + + if (cipher.fields) { + nextCipher.fields = await Promise.all( + cipher.fields.map(async (field) => ({ + ...field, + decName: await decryptField(field.name || '', itemEnc, itemMac), + decValue: await decryptField(field.value || '', itemEnc, itemMac), + })) + ); + } + + if (Array.isArray(cipher.attachments)) { + nextCipher.attachments = await Promise.all( + cipher.attachments.map(async (attachment) => { + const attachmentId = String(attachment?.id || '').trim(); + const fileNameResult = await decryptFieldWithSource( + attachment.fileName || '', + itemEnc, + itemMac, + userEnc, + userMac, + !itemUsesUserKey + ); + const metadata: { fileName?: string; key?: string | null } = {}; + + if (attachmentId && fileNameResult.source === 'user') { + metadata.fileName = await encryptBw(new TextEncoder().encode(fileNameResult.text), itemEnc, itemMac); + } + + const attachmentKey = String(attachment?.key || '').trim(); + if (attachmentId && attachmentKey && looksLikeCipherString(attachmentKey) && !itemUsesUserKey) { + try { + await decryptBw(attachmentKey, itemEnc, itemMac); + } catch { + try { + const rawAttachmentKey = await decryptBw(attachmentKey, userEnc, userMac); + if (rawAttachmentKey.length >= 64) { + metadata.key = await encryptBw(rawAttachmentKey, itemEnc, itemMac); + } + } catch { + // Download path still supports legacy format. + } + } + } + + if (attachmentId && Object.keys(metadata).length > 0) { + attachmentRepairs.push({ + cipherId: cipher.id, + attachmentId, + metadata, + }); + } + + return { + ...attachment, + decFileName: fileNameResult.text, + }; + }) + ); + } + + return nextCipher; + }) + ); + + return { folders, ciphers, attachmentRepairs }; +} + +export async function decryptSends(args: DecryptSendsArgs): Promise { + const userEnc = base64ToBytes(args.symEncKeyB64); + const userMac = base64ToBytes(args.symMacKeyB64); + return Promise.all( + args.sends.map(async (send) => { + const nextSend: Send = { ...send }; + try { + if (send.key) { + const sendKeyRaw = await decryptBw(send.key, userEnc, userMac); + const derived = await deriveSendKeyParts(sendKeyRaw); + nextSend.decName = await decryptField(send.name || '', derived.enc, derived.mac); + nextSend.decNotes = await decryptField(send.notes || '', derived.enc, derived.mac); + nextSend.decText = await decryptField(send.text?.text || '', derived.enc, derived.mac); + if (send.file?.fileName) { + const decFileName = await decryptField(send.file.fileName, derived.enc, derived.mac); + nextSend.file = { + ...(send.file || {}), + fileName: decFileName || send.file.fileName, + }; + } + nextSend.decShareKey = btoa(String.fromCharCode(...sendKeyRaw)) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/g, ''); + nextSend.shareUrl = `${args.origin}/#/send/${send.accessId}/${nextSend.decShareKey}`; + } else { + nextSend.decName = ''; + nextSend.decNotes = ''; + nextSend.decText = ''; + } + } catch { + nextSend.decName = 'Decrypt failed'; + } + return nextSend; + }) + ); +} diff --git a/webapp/src/lib/vault-worker.ts b/webapp/src/lib/vault-worker.ts new file mode 100644 index 0000000..eba458e --- /dev/null +++ b/webapp/src/lib/vault-worker.ts @@ -0,0 +1,55 @@ +import type { Send } from './types'; +import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt'; + +type WorkerSuccess = { id: number; ok: true; result: T }; +type WorkerFailure = { id: number; ok: false; error: string }; +type WorkerResponse = WorkerSuccess | WorkerFailure; + +let worker: Worker | null = null; +let nextJobId = 1; +const pending = new Map void; reject: (error: Error) => void }>(); + +function getWorker(): Worker | null { + if (typeof Worker === 'undefined') return null; + if (worker) return worker; + worker = new Worker(new URL('../workers/vault-decrypt.worker.ts', import.meta.url), { type: 'module' }); + worker.addEventListener('message', (event: MessageEvent>) => { + const message = event.data; + const job = pending.get(message.id); + if (!job) return; + pending.delete(message.id); + if (message.ok) { + job.resolve(message.result); + return; + } + job.reject(new Error(message.error || 'Decrypt failed')); + }); + worker.addEventListener('error', () => { + for (const [, job] of pending) { + job.reject(new Error('Decrypt worker failed')); + } + pending.clear(); + worker = null; + }); + return worker; +} + +function postJob(payload: { kind: 'vault-core'; payload: DecryptVaultCoreArgs } | { kind: 'sends'; payload: DecryptSendsArgs }): Promise { + const instance = getWorker(); + if (!instance) { + return Promise.reject(new Error('Decrypt worker unavailable')); + } + const id = nextJobId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + instance.postMessage({ id, ...payload }); + }); +} + +export function decryptVaultCoreInWorker(args: DecryptVaultCoreArgs): Promise { + return postJob({ kind: 'vault-core', payload: args }); +} + +export function decryptSendsInWorker(args: DecryptSendsArgs): Promise { + return postJob({ kind: 'sends', payload: args }); +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index b496154..939e1d0 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -9,6 +9,7 @@ const queryClient = new QueryClient({ queries: { retry: 1, refetchOnWindowFocus: false, + staleTime: 30_000, }, }, }); diff --git a/webapp/src/workers/vault-decrypt.worker.ts b/webapp/src/workers/vault-decrypt.worker.ts new file mode 100644 index 0000000..9370d77 --- /dev/null +++ b/webapp/src/workers/vault-decrypt.worker.ts @@ -0,0 +1,24 @@ +import { decryptSends, decryptVaultCore, type DecryptSendsArgs, type DecryptVaultCoreArgs } from '@/lib/vault-decrypt'; + +type WorkerRequest = + | { id: number; kind: 'vault-core'; payload: DecryptVaultCoreArgs } + | { id: number; kind: 'sends'; payload: DecryptSendsArgs }; + +self.onmessage = async (event: MessageEvent) => { + const request = event.data; + try { + if (request.kind === 'vault-core') { + const result = await decryptVaultCore(request.payload); + self.postMessage({ id: request.id, ok: true, result }); + return; + } + const result = await decryptSends(request.payload); + self.postMessage({ id: request.id, ok: true, result }); + } catch (error) { + self.postMessage({ + id: request.id, + ok: false, + error: error instanceof Error ? error.message : 'Decrypt failed', + }); + } +};