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)
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user