Files
hope-events/backend/src/utils/encryption.js
T
joshua 3d381944d2 Initial commit
Next.js + Express event management app for Hope Family Church.
2026-07-23 15:26:47 +02:00

79 lines
3.1 KiB
JavaScript

/**
* 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:<iv>:<authTag>:<ciphertext>" 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 };