Files
SRCmail/stores/encrypted-storage.ts
T
Bernd Rodler cfdd091d22 feat: Phase 3+4 — security hardening + polish + offline + Electron push
Phase 3 (security):
- P3.1: Feature gate server-side enforcement (403 on disabled features)
- P3.2: Unified auth error interceptor (401→logout)
- P3.3: Store-level state isolation via StoreSnapshot contract
  (added message-list-tabs + task stores to snapshot/restore cycle)
- P3.4: Push event bus extraction — email-store no longer imports
  calendar/contact/filter/file stores directly
- P1.3: Auth localStorage AES-GCM encryption via custom Zustand adapter

Phase 4 (polish):
- P4.1: Offline write queue — pending operations in localStorage,
  auto-retry on reconnect, offline-queue-indicator banner
- P4.2: Identity spoofing — fromOverrideEmail domain validation
- P4.3: WebSocket push for Electron via main-process IPC bridge
  (ws package with Authorization headers)
2026-08-07 22:10:26 +02:00

96 lines
2.7 KiB
TypeScript

import { encryptValue, decryptValue, isEncryptionAvailable } from '@/lib/auth/local-storage-crypto';
const ENCRYPTED_PREFIX = 'ENC:';
function isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTED_PREFIX);
}
function stripPrefix(value: string): string {
return value.slice(ENCRYPTED_PREFIX.length);
}
// Cache of recently decrypted values. The Zustand persist middleware calls
// getItem frequently during rehydration, and we want to avoid re-decrypting
// the same ciphertext on every read. Keyed by storage key.
const decryptedCache = new Map<string, string | null>();
function cacheKey(name: string): string {
return `vncmail:decrypted:${name}`;
}
function getCachedDecrypted(name: string): string | null | undefined {
return decryptedCache.get(cacheKey(name));
}
function setCachedDecrypted(name: string, value: string | null): void {
decryptedCache.set(cacheKey(name), value);
}
function invalidateDecryptedCache(name: string): void {
decryptedCache.delete(cacheKey(name));
}
export function createEncryptedStorage(): {
getItem: (name: string) => Promise<string | null>;
setItem: (name: string, value: string) => Promise<void>;
removeItem: (name: string) => Promise<void>;
} {
return {
getItem: async (name: string): Promise<string | null> => {
try {
const raw = localStorage.getItem(name);
if (raw === null) return null;
if (!isEncrypted(raw)) {
if (isEncryptionAvailable()) {
// Legacy plaintext value found — return as-is, but re-encrypt on
// the next write (setItem below always encrypts when available).
return raw;
}
return raw;
}
const cached = getCachedDecrypted(name);
if (cached !== undefined) return cached;
const ciphertext = stripPrefix(raw);
const decrypted = await decryptValue(ciphertext);
setCachedDecrypted(name, decrypted);
return decrypted;
} catch {
return null;
}
},
setItem: async (name: string, value: string): Promise<void> => {
try {
if (isEncryptionAvailable()) {
const ciphertext = await encryptValue(value);
localStorage.setItem(name, `${ENCRYPTED_PREFIX}${ciphertext}`);
} else {
localStorage.setItem(name, value);
}
invalidateDecryptedCache(name);
} catch {
try {
localStorage.setItem(name, value);
} catch {
/* noop */
}
}
},
removeItem: async (name: string): Promise<void> => {
try {
localStorage.removeItem(name);
} catch {
/* noop */
}
invalidateDecryptedCache(name);
},
};
}
export const encryptedStorage = createEncryptedStorage();