/** * AES-256-GCM encryption utilities using Web Crypto API. * PBKDF2 key derivation with 100K+ iterations. */ const PBKDF2_ITERATIONS = 100_000 const SALT_LENGTH = 16 const IV_LENGTH = 12 export async function generateSalt(): Promise { return crypto.getRandomValues(new Uint8Array(SALT_LENGTH)) } export async function deriveKey(password: string, salt: Uint8Array): Promise { const enc = new TextEncoder() const keyMaterial = await crypto.subtle.importKey( 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'], ) return crypto.subtle.deriveKey( { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256', }, keyMaterial, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'], ) } export interface EncryptedPayload { ciphertext: ArrayBuffer iv: Uint8Array } export async function encrypt(data: string, key: CryptoKey): Promise { const enc = new TextEncoder() const iv = crypto.getRandomValues(new Uint8Array(IV_LENGTH)) const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, key, enc.encode(data), ) return { ciphertext, iv } } export async function decrypt( ciphertext: ArrayBuffer, iv: Uint8Array, key: CryptoKey, ): Promise { const plaintext = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, key, ciphertext, ) return new TextDecoder().decode(plaintext) } /** * Convenience: encrypt a string and return a storable format. * Returns base64-encoded JSON with iv + ciphertext. */ export async function encryptToString(data: string, key: CryptoKey): Promise { const { ciphertext, iv } = await encrypt(data, key) const combined = new Uint8Array(iv.length + ciphertext.byteLength) combined.set(iv) combined.set(new Uint8Array(ciphertext), iv.length) return bufferToBase64(combined) } /** * Convenience: decrypt a base64-encoded encrypted string. */ export async function decryptFromString(encoded: string, key: CryptoKey): Promise { const combined = base64ToBuffer(encoded) const iv = combined.slice(0, IV_LENGTH) const ciphertext = combined.slice(IV_LENGTH) return decrypt(ciphertext.buffer, iv, key) } function bufferToBase64(buffer: Uint8Array): string { let binary = '' for (let i = 0; i < buffer.length; i++) { binary += String.fromCharCode(buffer[i]) } return btoa(binary) } function base64ToBuffer(base64: string): Uint8Array { const binary = atob(base64) const bytes = new Uint8Array(binary.length) for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i) } return bytes } /** * Check if encryption should be enabled. * Disabled in dev mode with VITE_DISABLE_CRYPTO=true, or when * Web Crypto API is unavailable (HTTP on non-localhost origins). */ export function isCryptoEnabled(): boolean { try { if (import.meta.env.VITE_DISABLE_CRYPTO === 'true') return false // crypto.subtle is only available in secure contexts (HTTPS or localhost) if (typeof globalThis.crypto?.subtle === 'undefined') return false return true } catch { return false } } /** * Session key management — held in memory only. */ let sessionKey: CryptoKey | null = null let sessionSalt: Uint8Array | null = null export function setSessionKey(key: CryptoKey, salt: Uint8Array): void { sessionKey = key sessionSalt = salt } export function getSessionKey(): CryptoKey | null { return sessionKey } export function getSessionSalt(): Uint8Array | null { return sessionSalt } export function clearSessionKey(): void { sessionKey = null sessionSalt = null } export function hasSessionKey(): boolean { return sessionKey !== null }