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)
96 lines
2.7 KiB
TypeScript
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();
|