/** * AES-256-GCM 加解密工具 * * 用于保护数据库中存储的网盘 Cookie。 * 加密密钥从环境变量 COOKIE_ENCRYPTION_KEY 读取, * 未设置时自动生成随机密钥(仅当前进程有效,重启后旧数据不可解密)。 * * 生产环境必须设置 COOKIE_ENCRYPTION_KEY! */ import * as crypto from 'crypto'; 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) { // Use SHA-256 to derive a consistent 32-byte key from any length input ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY)'); } else if (envKey) { // Short key: still use SHA-256 ENCRYPTION_KEY = crypto.createHash('sha256').update(envKey).digest(); console.log('[Crypto] Cookie encryption enabled (key from COOKIE_ENCRYPTION_KEY, SHA-256 derived)'); } else { // Default stable key (not ephemeral) — data survives container restart ENCRYPTION_KEY = crypto.createHash('sha256').update('cloudsearch-cookie-key-v1').digest(); console.log('[Crypto] Cookie encryption enabled (built-in default key — set COOKIE_ENCRYPTION_KEY in .env for extra security)'); } 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)'); // Legacy data: stored as plaintext before encryption was added 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 it looks like base64 but decryption fails, it might be legacy plaintext // stored before encryption was enabled. Try returning as-is. 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; } }