mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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.
This commit is contained in:
+131
-307
@@ -21,18 +21,14 @@ import {
|
|||||||
stripProfileSecrets,
|
stripProfileSecrets,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { listAdminInvites, listAdminUsers } from '@/lib/api/admin';
|
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 {
|
import {
|
||||||
getCiphers,
|
|
||||||
getFolders,
|
|
||||||
repairCipherAttachmentMetadata,
|
repairCipherAttachmentMetadata,
|
||||||
updateFolder,
|
updateFolder,
|
||||||
} from '@/lib/api/vault';
|
} from '@/lib/api/vault';
|
||||||
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
import { silentlyRepairBackupSettingsIfNeeded } from '@/lib/backup-settings-repair';
|
||||||
import { base64ToBytes, decryptBw, decryptStr, encryptBw } from '@/lib/crypto';
|
|
||||||
import {
|
import {
|
||||||
buildPublicSendUrl,
|
|
||||||
deriveSendKeyParts,
|
|
||||||
looksLikeCipherString,
|
looksLikeCipherString,
|
||||||
parseSignalRTextFrames,
|
parseSignalRTextFrames,
|
||||||
readInviteCodeFromUrl,
|
readInviteCodeFromUrl,
|
||||||
@@ -58,7 +54,10 @@ import { useToastManager } from '@/hooks/useToastManager';
|
|||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
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 { 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 {
|
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||||
if (!value || typeof value !== 'object') return false;
|
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 LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
|
||||||
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
|
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
|
||||||
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
||||||
const DECRYPT_BATCH_SIZE = 16;
|
|
||||||
|
|
||||||
function yieldToMainThread(): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
if (typeof window !== 'undefined' && typeof window.setTimeout === 'function') {
|
|
||||||
window.setTimeout(resolve, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setTimeout(resolve, 0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function mapAsyncInBatches<T, R>(
|
|
||||||
items: readonly T[],
|
|
||||||
mapper: (item: T, index: number) => Promise<R>,
|
|
||||||
options?: { batchSize?: number; shouldContinue?: () => boolean }
|
|
||||||
): Promise<R[]> {
|
|
||||||
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 {
|
function readThemePreference(): ThemePreference {
|
||||||
if (typeof window === 'undefined') return 'system';
|
if (typeof window === 'undefined') return 'system';
|
||||||
const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim();
|
const stored = String(window.localStorage.getItem(THEME_STORAGE_KEY) || '').trim();
|
||||||
@@ -202,12 +167,16 @@ export default function App() {
|
|||||||
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
||||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||||
|
const [cachedVaultCore, setCachedVaultCore] = useState<VaultCoreSnapshot | null>(null);
|
||||||
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
const [vaultInitialDecryptDone, setVaultInitialDecryptDone] = useState(false);
|
||||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const repairAttemptRef = useRef<string>('');
|
const repairAttemptRef = useRef<string>('');
|
||||||
|
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
|
||||||
|
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
|
||||||
|
const notificationRefreshTimerRef = useRef<number | null>(null);
|
||||||
const { toasts, pushToast, removeToast } = useToastManager();
|
const { toasts, pushToast, removeToast } = useToastManager();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -363,6 +332,7 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
[authedFetch]
|
[authedFetch]
|
||||||
);
|
);
|
||||||
|
const vaultCacheKey = String(profile?.id || session?.email || '').trim();
|
||||||
const backupActions = useBackupActions({
|
const backupActions = useBackupActions({
|
||||||
authedFetch,
|
authedFetch,
|
||||||
onImported: () => {
|
onImported: () => {
|
||||||
@@ -761,40 +731,74 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ciphersQuery = useQuery({
|
useEffect(() => {
|
||||||
queryKey: ['ciphers', session?.accessToken],
|
let cancelled = false;
|
||||||
queryFn: () => getCiphers(authedFetch),
|
if (phase !== 'app' || !session?.symEncKey || !session?.symMacKey || !vaultCacheKey) {
|
||||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
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;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const foldersQuery = useQuery({
|
pendingVaultCoreQueryRefreshRef.current = request;
|
||||||
queryKey: ['folders', session?.accessToken],
|
return request;
|
||||||
queryFn: () => getFolders(authedFetch),
|
}
|
||||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey,
|
|
||||||
|
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({
|
const sendsQuery = useQuery({
|
||||||
queryKey: ['sends', session?.accessToken],
|
queryKey: ['sends', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getSends(authedFetch),
|
queryFn: () => getSends(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'),
|
enabled: phase === 'app' && !!session?.symEncKey && !!session?.symMacKey && (vaultInitialDecryptDone || location === '/sends'),
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ['admin-users', session?.accessToken],
|
queryKey: ['admin-users', vaultCacheKey],
|
||||||
queryFn: () => listAdminUsers(authedFetch),
|
queryFn: () => listAdminUsers(authedFetch),
|
||||||
enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone,
|
enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const invitesQuery = useQuery({
|
const invitesQuery = useQuery({
|
||||||
queryKey: ['admin-invites', session?.accessToken],
|
queryKey: ['admin-invites', vaultCacheKey],
|
||||||
queryFn: () => listAdminInvites(authedFetch),
|
queryFn: () => listAdminInvites(authedFetch),
|
||||||
enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone,
|
enabled: phase === 'app' && profile?.role === 'admin' && vaultInitialDecryptDone,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const totpStatusQuery = useQuery({
|
const totpStatusQuery = useQuery({
|
||||||
queryKey: ['totp-status', session?.accessToken],
|
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getTotpStatus(authedFetch),
|
queryFn: () => getTotpStatus(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
const authorizedDevicesQuery = useQuery({
|
const authorizedDevicesQuery = useQuery({
|
||||||
queryKey: ['authorized-devices', session?.accessToken],
|
queryKey: ['authorized-devices', vaultCacheKey || session?.email],
|
||||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
queryFn: () => getAuthorizedDevices(authedFetch),
|
||||||
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
enabled: phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||||
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -820,214 +824,35 @@ export default function App() {
|
|||||||
setVaultInitialDecryptDone(false);
|
setVaultInitialDecryptDone(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!foldersQuery.data || !ciphersQuery.data) return;
|
if (!encryptedFolders || !encryptedCiphers) return;
|
||||||
|
|
||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const encKey = base64ToBytes(session.symEncKey!);
|
let result;
|
||||||
const macKey = base64ToBytes(session.symMacKey!);
|
|
||||||
const decryptField = async (
|
|
||||||
value: string | null | undefined,
|
|
||||||
fieldEnc: Uint8Array = encKey,
|
|
||||||
fieldMac: Uint8Array = macKey
|
|
||||||
): Promise<string> => {
|
|
||||||
if (!value || typeof value !== 'string') return '';
|
|
||||||
try {
|
try {
|
||||||
return await decryptStr(value, fieldEnc, fieldMac);
|
result = await decryptVaultCoreInWorker({
|
||||||
|
folders: encryptedFolders,
|
||||||
|
ciphers: encryptedCiphers,
|
||||||
|
symEncKeyB64: session.symEncKey!,
|
||||||
|
symMacKeyB64: session.symMacKey!,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Backward-compatibility: some records may already be plain text.
|
result = await decryptVaultCore({
|
||||||
return value;
|
folders: encryptedFolders,
|
||||||
|
ciphers: encryptedCiphers,
|
||||||
|
symEncKeyB64: session.symEncKey!,
|
||||||
|
symMacKeyB64: session.symMacKey!,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDecryptedFolders(folders);
|
setDecryptedFolders(result.folders);
|
||||||
setDecryptedCiphers(ciphers);
|
setDecryptedCiphers(result.ciphers);
|
||||||
setVaultInitialDecryptDone(true);
|
setVaultInitialDecryptDone(true);
|
||||||
|
for (const repair of result.attachmentRepairs) {
|
||||||
|
void repairCipherAttachmentMetadata(authedFetch, repair.cipherId, repair.attachmentId, repair.metadata);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_decrypt_failed_2'));
|
||||||
@@ -1037,7 +862,7 @@ export default function App() {
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data]);
|
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, encryptedCiphers, authedFetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
@@ -1049,53 +874,22 @@ export default function App() {
|
|||||||
let active = true;
|
let active = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const encKey = base64ToBytes(session.symEncKey!);
|
let sends;
|
||||||
const macKey = base64ToBytes(session.symMacKey!);
|
|
||||||
const decryptField = async (
|
|
||||||
value: string | null | undefined,
|
|
||||||
fieldEnc: Uint8Array = encKey,
|
|
||||||
fieldMac: Uint8Array = macKey
|
|
||||||
): Promise<string> => {
|
|
||||||
if (!value || typeof value !== 'string') return '';
|
|
||||||
try {
|
try {
|
||||||
return await decryptStr(value, fieldEnc, fieldMac);
|
sends = await decryptSendsInWorker({
|
||||||
|
sends: sendsQuery.data,
|
||||||
|
symEncKeyB64: session.symEncKey!,
|
||||||
|
symMacKeyB64: session.symMacKey!,
|
||||||
|
origin: window.location.origin,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return value;
|
sends = await decryptSends({
|
||||||
|
sends: sendsQuery.data,
|
||||||
|
symEncKeyB64: session.symEncKey!,
|
||||||
|
symMacKeyB64: session.symMacKey!,
|
||||||
|
origin: window.location.origin,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
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 }
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!active) return;
|
if (!active) return;
|
||||||
setDecryptedSends(sends);
|
setDecryptedSends(sends);
|
||||||
@@ -1111,10 +905,10 @@ export default function App() {
|
|||||||
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
|
}, [session?.symEncKey, session?.symMacKey, sendsQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.symEncKey || !session?.symMacKey || !foldersQuery.data?.length) return;
|
if (!session?.symEncKey || !session?.symMacKey || !encryptedFolders?.length) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const pending = foldersQuery.data.filter((folder) => {
|
const pending = encryptedFolders.filter((folder) => {
|
||||||
if (!folder?.id || !folder?.name) return false;
|
if (!folder?.id || !folder?.name) return false;
|
||||||
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
|
if (migratedPlainFolderIdsRef.current.has(folder.id)) return false;
|
||||||
return !looksLikeCipherString(String(folder.name));
|
return !looksLikeCipherString(String(folder.name));
|
||||||
@@ -1128,15 +922,29 @@ export default function App() {
|
|||||||
// keep silent; web still supports plaintext fallback display
|
// keep silent; web still supports plaintext fallback display
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!cancelled) await foldersQuery.refetch();
|
if (!cancelled) await refetchVaultCoreData();
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [session?.symEncKey, session?.symMacKey, foldersQuery.data, authedFetch]);
|
}, [session?.symEncKey, session?.symMacKey, encryptedFolders, authedFetch]);
|
||||||
|
|
||||||
async function refreshVaultSilently() {
|
async function refreshVaultSilently() {
|
||||||
await Promise.all([ciphersQuery.refetch(), foldersQuery.refetch(), sendsQuery.refetch()]);
|
if (pendingVaultCoreRefreshRef.current) {
|
||||||
|
await pendingVaultCoreRefreshRef.current;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tasks: Promise<unknown>[] = [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;
|
silentRefreshVaultRef.current = refreshVaultSilently;
|
||||||
@@ -1233,7 +1041,13 @@ export default function App() {
|
|||||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||||
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
const contextId = String(frame.arguments?.[0]?.ContextId || '').trim();
|
||||||
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
if (contextId && contextId === getCurrentDeviceIdentifier()) continue;
|
||||||
|
if (notificationRefreshTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(notificationRefreshTimerRef.current);
|
||||||
|
}
|
||||||
|
notificationRefreshTimerRef.current = window.setTimeout(() => {
|
||||||
|
notificationRefreshTimerRef.current = null;
|
||||||
void silentRefreshVaultRef.current();
|
void silentRefreshVaultRef.current();
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1257,6 +1071,10 @@ export default function App() {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
|
if (notificationRefreshTimerRef.current !== null) {
|
||||||
|
window.clearTimeout(notificationRefreshTimerRef.current);
|
||||||
|
notificationRefreshTimerRef.current = null;
|
||||||
|
}
|
||||||
clearReconnectTimer();
|
clearReconnectTimer();
|
||||||
if (socket) {
|
if (socket) {
|
||||||
const s = socket;
|
const s = socket;
|
||||||
@@ -1276,10 +1094,16 @@ export default function App() {
|
|||||||
session,
|
session,
|
||||||
profile,
|
profile,
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
encryptedCiphers: ciphersQuery.data,
|
encryptedCiphers,
|
||||||
encryptedFolders: foldersQuery.data,
|
encryptedFolders,
|
||||||
refetchCiphers: ciphersQuery.refetch,
|
refetchCiphers: async () => {
|
||||||
refetchFolders: foldersQuery.refetch,
|
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,
|
refetchSends: sendsQuery.refetch,
|
||||||
onNotify: pushToast,
|
onNotify: pushToast,
|
||||||
patchDecryptedCiphers: setDecryptedCiphers,
|
patchDecryptedCiphers: setDecryptedCiphers,
|
||||||
@@ -1379,8 +1203,8 @@ export default function App() {
|
|||||||
decryptedCiphers,
|
decryptedCiphers,
|
||||||
decryptedFolders,
|
decryptedFolders,
|
||||||
decryptedSends,
|
decryptedSends,
|
||||||
ciphersLoading: ciphersQuery.isFetching,
|
ciphersLoading: vaultCoreQuery.isFetching,
|
||||||
foldersLoading: foldersQuery.isFetching,
|
foldersLoading: vaultCoreQuery.isFetching,
|
||||||
sendsLoading: sendsQuery.isFetching,
|
sendsLoading: sendsQuery.isFetching,
|
||||||
users: usersQuery.data || [],
|
users: usersQuery.data || [],
|
||||||
invites: invitesQuery.data || [],
|
invites: invitesQuery.data || [],
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
await Promise.all([refetchCiphers(), refetchFolders(), refetchSends()]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const syncVaultCoreInBackground = (options?: { includeFolders?: boolean }) => {
|
||||||
|
const tasks: Promise<unknown>[] = [Promise.resolve(refetchCiphers())];
|
||||||
|
if (options?.includeFolders) {
|
||||||
|
tasks.push(Promise.resolve(refetchFolders()));
|
||||||
|
}
|
||||||
|
void Promise.all(tasks).catch(() => undefined);
|
||||||
|
};
|
||||||
|
|
||||||
async function decryptAndPatch(encrypted: Cipher) {
|
async function decryptAndPatch(encrypted: Cipher) {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
await refetchCiphers();
|
await refetchCiphers();
|
||||||
@@ -202,7 +210,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
await uploadCipherAttachment(authedFetch, session, created.id, file, undefined, setAttachmentUploadPercent);
|
||||||
}
|
}
|
||||||
await decryptAndPatch(created);
|
await decryptAndPatch(created);
|
||||||
if (draft.folderId) await refetchFolders();
|
syncVaultCoreInBackground({ includeFolders: !!draft.folderId || attachments.length > 0 });
|
||||||
onNotify('success', t('txt_item_created'));
|
onNotify('success', t('txt_item_created'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_create_item_failed'));
|
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 uploadCipherAttachment(authedFetch, session, cipher.id, file, cipher, setAttachmentUploadPercent);
|
||||||
}
|
}
|
||||||
await decryptAndPatch(updated);
|
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'));
|
onNotify('success', t('txt_item_updated'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_update_item_failed'));
|
||||||
@@ -263,7 +276,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
const deleted = await deleteCipher(authedFetch, cipher.id);
|
const deleted = await deleteCipher(authedFetch, cipher.id);
|
||||||
await decryptAndPatch(deleted);
|
await decryptAndPatch(deleted);
|
||||||
await refetchFolders();
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_item_deleted'));
|
onNotify('success', t('txt_item_deleted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||||
@@ -275,7 +288,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
const archived = await archiveCipher(authedFetch, cipher.id);
|
const archived = await archiveCipher(authedFetch, cipher.id);
|
||||||
await decryptAndPatch(archived);
|
await decryptAndPatch(archived);
|
||||||
await refetchFolders();
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_item_archived'));
|
onNotify('success', t('txt_item_archived'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_archive_item_failed'));
|
||||||
@@ -287,7 +300,7 @@ export default function useVaultSendActions(options: UseVaultSendActionsOptions)
|
|||||||
try {
|
try {
|
||||||
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
|
const unarchived = await unarchiveCipher(authedFetch, cipher.id);
|
||||||
await decryptAndPatch(unarchived);
|
await decryptAndPatch(unarchived);
|
||||||
await refetchFolders();
|
syncVaultCoreInBackground({ includeFolders: true });
|
||||||
onNotify('success', t('txt_item_unarchived'));
|
onNotify('success', t('txt_item_unarchived'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
|
onNotify('error', error instanceof Error ? error.message : t('txt_unarchive_item_failed'));
|
||||||
|
|||||||
@@ -518,6 +518,19 @@ export async function verifyMasterPassword(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
||||||
|
const resp = await authedFetch('/api/accounts/revision-date');
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error('Failed to load revision date');
|
||||||
|
}
|
||||||
|
const body = await parseJson<number>(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 }> {
|
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||||
const resp = await authedFetch('/api/accounts/totp');
|
const resp = await authedFetch('/api/accounts/totp');
|
||||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type { Cipher, Folder, Send } from '../types';
|
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';
|
import { parseJson, type AuthedFetch } from './shared';
|
||||||
|
|
||||||
interface VaultSyncResponse {
|
interface VaultSyncResponse {
|
||||||
@@ -7,13 +9,53 @@ interface VaultSyncResponse {
|
|||||||
sends?: Send[];
|
sends?: Send[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const pendingVaultCoreRequests = new WeakMap<AuthedFetch, Promise<VaultSyncResponse>>();
|
const pendingVaultCoreRequests = new Map<string, Promise<VaultCoreSnapshot>>();
|
||||||
|
const memoryVaultCoreCache = new Map<string, { revisionStamp: number; snapshot: VaultCoreSnapshot }>();
|
||||||
|
|
||||||
export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promise<VaultSyncResponse> {
|
function normalizeSnapshot(body: VaultSyncResponse | null | undefined): VaultCoreSnapshot {
|
||||||
const existing = pendingVaultCoreRequests.get(authedFetch);
|
return {
|
||||||
|
ciphers: Array.isArray(body?.ciphers) ? body!.ciphers! : [],
|
||||||
|
folders: Array.isArray(body?.folders) ? body!.folders! : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedVaultCoreSnapshot(cacheKey: string): Promise<VaultCoreSnapshot | null> {
|
||||||
|
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<VaultCoreSnapshot> {
|
||||||
|
const normalizedKey = String(cacheKey || '').trim();
|
||||||
|
if (!normalizedKey) return { ciphers: [], folders: [] };
|
||||||
|
|
||||||
|
const existing = pendingVaultCoreRequests.get(normalizedKey);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const request = (async () => {
|
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', {
|
const resp = await authedFetch('/api/sync?excludeSends=true&excludeDomains=true', {
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -23,15 +65,18 @@ export async function loadVaultCoreSyncSnapshot(authedFetch: AuthedFetch): Promi
|
|||||||
});
|
});
|
||||||
if (!resp.ok) throw new Error('Failed to load vault');
|
if (!resp.ok) throw new Error('Failed to load vault');
|
||||||
const body = await parseJson<VaultSyncResponse>(resp);
|
const body = await parseJson<VaultSyncResponse>(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 {
|
try {
|
||||||
return await request;
|
return await request;
|
||||||
} finally {
|
} finally {
|
||||||
if (pendingVaultCoreRequests.get(authedFetch) === request) {
|
if (pendingVaultCoreRequests.get(normalizedKey) === request) {
|
||||||
pendingVaultCoreRequests.delete(authedFetch);
|
pendingVaultCoreRequests.delete(normalizedKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import {
|
|||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
import { loadVaultCoreSyncSnapshot } from './vault-sync';
|
||||||
|
|
||||||
export async function getFolders(authedFetch: AuthedFetch): Promise<Folder[]> {
|
export async function getFolders(authedFetch: AuthedFetch, cacheKey: string): Promise<Folder[]> {
|
||||||
const body = await loadVaultCoreSyncSnapshot(authedFetch);
|
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
|
||||||
return body.folders || [];
|
return body.folders || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +92,8 @@ export async function updateFolder(
|
|||||||
if (!resp.ok) throw new Error('Update folder failed');
|
if (!resp.ok) throw new Error('Update folder failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCiphers(authedFetch: AuthedFetch): Promise<Cipher[]> {
|
export async function getCiphers(authedFetch: AuthedFetch, cacheKey: string): Promise<Cipher[]> {
|
||||||
const body = await loadVaultCoreSyncSnapshot(authedFetch);
|
const body = await loadVaultCoreSyncSnapshot(authedFetch, cacheKey);
|
||||||
return body.ciphers || [];
|
return body.ciphers || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<IDBDatabase | null> | null = null;
|
||||||
|
|
||||||
|
function supportsIndexedDb(): boolean {
|
||||||
|
return typeof indexedDB !== 'undefined';
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDatabase(): Promise<IDBDatabase | null> {
|
||||||
|
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<T>(
|
||||||
|
mode: IDBTransactionMode,
|
||||||
|
run: (store: IDBObjectStore) => Promise<T>
|
||||||
|
): Promise<T | null> {
|
||||||
|
return openDatabase().then((db) => {
|
||||||
|
if (!db) return null;
|
||||||
|
return new Promise<T | null>((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<VaultCoreCacheRecord | null> {
|
||||||
|
const normalized = String(cacheKey || '').trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
return withStore('readonly', (store) => new Promise<VaultCoreCacheRecord | null>((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<void> {
|
||||||
|
const normalized = String(cacheKey || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
await withStore('readwrite', (store) => new Promise<void>((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<void> {
|
||||||
|
const normalized = String(cacheKey || '').trim();
|
||||||
|
if (!normalized) return;
|
||||||
|
await withStore('readwrite', (store) => new Promise<void>((resolve) => {
|
||||||
|
const request = store.delete(normalized);
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => resolve();
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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<string> {
|
||||||
|
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<DecryptVaultCoreResult> {
|
||||||
|
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<Send[]> {
|
||||||
|
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import type { Send } from './types';
|
||||||
|
import type { DecryptSendsArgs, DecryptVaultCoreArgs, DecryptVaultCoreResult } from './vault-decrypt';
|
||||||
|
|
||||||
|
type WorkerSuccess<T> = { id: number; ok: true; result: T };
|
||||||
|
type WorkerFailure = { id: number; ok: false; error: string };
|
||||||
|
type WorkerResponse<T> = WorkerSuccess<T> | WorkerFailure;
|
||||||
|
|
||||||
|
let worker: Worker | null = null;
|
||||||
|
let nextJobId = 1;
|
||||||
|
const pending = new Map<number, { resolve: (value: any) => 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<WorkerResponse<unknown>>) => {
|
||||||
|
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<T>(payload: { kind: 'vault-core'; payload: DecryptVaultCoreArgs } | { kind: 'sends'; payload: DecryptSendsArgs }): Promise<T> {
|
||||||
|
const instance = getWorker();
|
||||||
|
if (!instance) {
|
||||||
|
return Promise.reject(new Error('Decrypt worker unavailable'));
|
||||||
|
}
|
||||||
|
const id = nextJobId++;
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
pending.set(id, { resolve, reject });
|
||||||
|
instance.postMessage({ id, ...payload });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptVaultCoreInWorker(args: DecryptVaultCoreArgs): Promise<DecryptVaultCoreResult> {
|
||||||
|
return postJob<DecryptVaultCoreResult>({ kind: 'vault-core', payload: args });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptSendsInWorker(args: DecryptSendsArgs): Promise<Send[]> {
|
||||||
|
return postJob<Send[]>({ kind: 'sends', payload: args });
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ const queryClient = new QueryClient({
|
|||||||
queries: {
|
queries: {
|
||||||
retry: 1,
|
retry: 1,
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
|
staleTime: 30_000,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<WorkerRequest>) => {
|
||||||
|
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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user