/** * AES-256-GCM encryption/decryption for protecting cloud drive cookies stored in DB. * * Encryption key is derived from COOKIE_ENCRYPTION_KEY env var via SHA-256. * If unset, uses a built-in default key (stable across container restarts). * Production MUST set COOKIE_ENCRYPTION_KEY! */ import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; // 96-bit nonce for GCM const TAG_LENGTH = 16; // 128-bit auth tag const KEY_LENGTH = 32; // 256-bit key let ENCRYPTION_KEY: Buffer | null = null; function getKey(): Buffer { if (ENCRYPTION_KEY) return ENCRYPTION_KEY; const envKey = process.env.COOKIE_ENCRYPTION_KEY; if (envKey && envKey.length >= 32) { ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)'); } else if (envKey) { ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)'); } else { // Auto-generate a random key and persist to file const keyFile = path.join(process.env.DATA_DIR || '/app/data', 'encryption.key'); try { if (fs.existsSync(keyFile)) { const savedKey = fs.readFileSync(keyFile, 'utf8').trim(); if (savedKey.length >= 32) { ENCRYPTION_KEY = crypto.createHash('sha256').update(savedKey).digest(); console.log('[Crypto] Cookie encryption enabled (loaded from encryption.key)'); return ENCRYPTION_KEY; } } } catch (_) { /* file read failed, will generate new key */ } const autoKey = crypto.randomBytes(32).toString('hex'); try { const dir = path.dirname(keyFile); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(keyFile, autoKey); console.log('[Crypto] Auto-generated encryption key saved to encryption.key'); } catch (e: any) { console.log('[Crypto] Could not persist key, using ephemeral:', e.message); } ENCRYPTION_KEY = crypto.createHash('sha256').update(autoKey).digest(); console.log('[Crypto] Cookie encryption enabled (auto-generated key)'); } return ENCRYPTION_KEY; } /** * Encrypt plaintext. Returns base64-encoded ciphertext (includes IV + auth tag). */ export function encrypt(plaintext: string): string { if (!plaintext) return ''; const key = getKey(); const iv = crypto.randomBytes(IV_LENGTH); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); const encrypted = Buffer.concat([ cipher.update(plaintext, 'utf8'), cipher.final(), ]); const tag = cipher.getAuthTag(); // Format: iv (12) + tag (16) + ciphertext const combined = Buffer.concat([iv, tag, encrypted]); return combined.toString('base64'); } /** * Decrypt base64-encoded ciphertext. Returns original plaintext. * Returns empty string if decryption fails (corrupted data or wrong key). */ export function decrypt(encoded: string): string { if (!encoded) return ''; try { const key = getKey(); const combined = Buffer.from(encoded, 'base64'); if (combined.length < IV_LENGTH + TAG_LENGTH + 1) { console.warn('[Crypto] Ciphertext too short, returning as-is (possibly unencrypted legacy data)'); return encoded; } const iv = combined.subarray(0, IV_LENGTH); const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]); return decrypted.toString('utf8'); } catch (err: any) { if (err.message?.includes('unsupported state') || err.message?.includes('authentication')) { console.warn('[Crypto] Decryption failed (possibly legacy plaintext), returning as-is'); return encoded; } console.error('[Crypto] Decryption error:', err.message); return ''; } } /** * Check if a string appears to be encrypted (base64 with IV+tag prefix). * Used for migration: re-encrypt legacy plaintext cookies. */ export function isEncrypted(value: string): boolean { if (!value) return false; try { const combined = Buffer.from(value, 'base64'); return combined.length > IV_LENGTH + TAG_LENGTH; } catch { return false; } }