/** * AES-256-GCM encryption helpers for sensitive settings stored in the database. * * Encrypted values are stored with an "enc:" prefix so they can be detected * and decrypted transparently when read back from the cache. * * Key is derived once at startup from JWT_SECRET via scrypt so it never * appears in plain text in the codebase. */ const crypto = require('crypto'); const SENTINEL = 'enc:'; // Derive a stable 32-byte key from JWT_SECRET using scrypt. // The fixed salt is fine here — its purpose is to namespace this key // so it can't be confused with keys derived for other purposes. const ENCRYPTION_KEY = (() => { const secret = process.env.JWT_SECRET || 'default-unsafe-key-CHANGE-IN-PRODUCTION'; if (secret === 'default-unsafe-key-CHANGE-IN-PRODUCTION') { console.warn('[encryption] WARNING: JWT_SECRET is not set. Sensitive settings will be encrypted with an insecure fallback key. Set JWT_SECRET in production.'); } return crypto.scryptSync(secret, 'hope-events-settings-aes256gcm-v1', 32); })(); /** * Encrypt a plaintext string. Returns an "enc:::" string. * Returns the original value if it is already encrypted or falsy. * @param {string} plaintext * @returns {string} */ function encrypt(plaintext) { if (!plaintext) return plaintext; if (typeof plaintext !== 'string') plaintext = String(plaintext); if (plaintext.startsWith(SENTINEL)) return plaintext; // already encrypted const iv = crypto.randomBytes(12); // 96-bit IV for GCM const cipher = crypto.createCipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]); const tag = cipher.getAuthTag(); return `${SENTINEL}${iv.toString('hex')}:${tag.toString('hex')}:${encrypted.toString('hex')}`; } /** * Decrypt a value produced by encrypt(). Returns the plaintext. * Returns the original value unchanged if it does not start with "enc:". * @param {string} value * @returns {string} */ function decrypt(value) { if (!value || typeof value !== 'string') return value; if (!value.startsWith(SENTINEL)) return value; // not encrypted try { const inner = value.slice(SENTINEL.length); const parts = inner.split(':'); if (parts.length !== 3) throw new Error('Invalid encrypted value format'); const [ivHex, tagHex, cipherHex] = parts; const iv = Buffer.from(ivHex, 'hex'); const tag = Buffer.from(tagHex, 'hex'); const ciphertext = Buffer.from(cipherHex,'hex'); const decipher = crypto.createDecipheriv('aes-256-gcm', ENCRYPTION_KEY, iv); decipher.setAuthTag(tag); const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]); return decrypted.toString('utf8'); } catch (err) { console.error('[encryption] Failed to decrypt value:', err.message); return ''; // return empty rather than crashing } } /** * Returns true if the value looks like it was produced by encrypt(). * @param {string} value * @returns {boolean} */ function isEncrypted(value) { return typeof value === 'string' && value.startsWith(SENTINEL); } module.exports = { encrypt, decrypt, isEncrypted };