3d381944d2
Next.js + Express event management app for Hope Family Church.
79 lines
3.1 KiB
JavaScript
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 }; |