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)
107 lines
3.0 KiB
TypeScript
107 lines
3.0 KiB
TypeScript
const SESSION_KEY_STORAGE_KEY = 'vncmail:session-encryption-key';
|
|
const ALGORITHM = 'AES-GCM';
|
|
|
|
let _available: boolean | null = null;
|
|
|
|
export function isEncryptionAvailable(): boolean {
|
|
if (_available !== null) return _available;
|
|
try {
|
|
if (typeof window === 'undefined') { _available = false; return false; }
|
|
if (!window.crypto || !window.crypto.subtle) { _available = false; return false; }
|
|
_available = true;
|
|
return true;
|
|
} catch {
|
|
_available = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getOrCreateSessionKey(): Promise<CryptoKey | null> {
|
|
if (!isEncryptionAvailable()) return Promise.resolve(null);
|
|
try {
|
|
let raw = sessionStorage.getItem(SESSION_KEY_STORAGE_KEY);
|
|
if (!raw) {
|
|
const keyBytes = new Uint8Array(32);
|
|
crypto.getRandomValues(keyBytes);
|
|
raw = btoa(String.fromCharCode(...keyBytes));
|
|
sessionStorage.setItem(SESSION_KEY_STORAGE_KEY, raw);
|
|
}
|
|
const keyData = Uint8Array.from(atob(raw), (c) => c.charCodeAt(0));
|
|
return crypto.subtle.importKey('raw', keyData, { name: ALGORITHM }, false, [
|
|
'encrypt',
|
|
'decrypt',
|
|
]);
|
|
} catch {
|
|
return Promise.resolve(null);
|
|
}
|
|
}
|
|
|
|
let _cachedKey: CryptoKey | null | undefined;
|
|
|
|
async function getKey(): Promise<CryptoKey | null> {
|
|
if (_cachedKey !== undefined) return _cachedKey;
|
|
_cachedKey = await getOrCreateSessionKey();
|
|
return _cachedKey;
|
|
}
|
|
|
|
function invalidateKey(): void {
|
|
_cachedKey = undefined;
|
|
}
|
|
|
|
export async function encryptValue(plaintext: string): Promise<string> {
|
|
if (!isEncryptionAvailable()) {
|
|
console.warn('[localStorage crypto] Web Crypto unavailable, storing in plaintext');
|
|
return plaintext;
|
|
}
|
|
|
|
const key = await getKey();
|
|
if (!key) {
|
|
console.warn('[localStorage crypto] Failed to derive key, storing in plaintext');
|
|
return plaintext;
|
|
}
|
|
|
|
try {
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
const encoded = new TextEncoder().encode(plaintext);
|
|
const ciphertext = await crypto.subtle.encrypt({ name: ALGORITHM, iv }, key, encoded);
|
|
const combined = new Uint8Array(iv.length + new Uint8Array(ciphertext).length);
|
|
combined.set(iv);
|
|
combined.set(new Uint8Array(ciphertext), iv.length);
|
|
return btoa(String.fromCharCode(...combined));
|
|
} catch (err) {
|
|
console.warn('[localStorage crypto] Encryption failed:', err);
|
|
return plaintext;
|
|
}
|
|
}
|
|
|
|
export async function decryptValue(ciphertext: string): Promise<string | null> {
|
|
if (!isEncryptionAvailable()) {
|
|
return ciphertext;
|
|
}
|
|
|
|
const key = await getKey();
|
|
if (!key) {
|
|
return ciphertext;
|
|
}
|
|
|
|
try {
|
|
const combined = Uint8Array.from(atob(ciphertext), (c) => c.charCodeAt(0));
|
|
if (combined.length < 13) return null;
|
|
const iv = combined.slice(0, 12);
|
|
const data = combined.slice(12);
|
|
const decrypted = await crypto.subtle.decrypt({ name: ALGORITHM, iv }, key, data);
|
|
return new TextDecoder().decode(decrypted);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function resetSessionKey(): void {
|
|
try {
|
|
sessionStorage.removeItem(SESSION_KEY_STORAGE_KEY);
|
|
} catch {
|
|
/* noop */
|
|
}
|
|
invalidateKey();
|
|
}
|