diff --git a/.env b/.env index 344fe61a4..47b120fb7 100644 --- a/.env +++ b/.env @@ -65,5 +65,10 @@ PG_STATS_MIN_AGE_MS=1000 # to parameterize the LIMIT. PG_STATS_TOP_N=10 +# WARNING: Changing this value will trigger a full database migration on the next backend restart. +# true → all encryptedFields across all models will be encrypted in-place. +# false → all encryptedFields will be decrypted back to plaintext in-place. +# Do NOT toggle this while the server is running or with an incomplete backup. +ENCRYPTION_ENABLED=true # session -SESSION_SECRET=secretString \ No newline at end of file +SESSION_SECRET=secretString diff --git a/.gitignore b/.gitignore index 897ecf8df..1b567609c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ files/*.zip # database dumps db_dumps/ +backups/ # logs (root + backend) logs/ @@ -14,6 +15,8 @@ backend/sessions backend/node_modules backend/logs backend/coverage +backend/encryption.key +backend/encryption.state # ignore build files dist/ diff --git a/Makefile b/Makefile index c61a41e53..d5a0f4170 100644 --- a/Makefile +++ b/Makefile @@ -28,7 +28,8 @@ help: @echo "make build Create a dockerized production build including frontend, backend, nlp, services" @echo "make build-clean Clean the environment of production build" @echo "make docker Start docker images" - @echo "make backup_db CONTAINER= Backup the database in the given container" + @echo "make backup CONTAINER= Create full backup (DB dump + .env + encryptionkey + files)" + @echo "make backup_db CONTAINER= Backup the database (prompts whether to decrypt before dumping)" @echo "make recover_db CONTAINER= DUMP= Recover database into container" @echo "make anonymize_dump CONTAINER= DUMP= [SEED=] [NUM=] Create anonymized dump (consent-filtered + pseudonymized)" @echo "make export_dump_files CONTAINER= DUMP= Archive document files referenced by an existing anonymized dump" @@ -37,6 +38,7 @@ help: @echo "make kill Kill all node instances (only unix)" @echo "make modules Install npm packages in all utils/modules subdirectories" @echo "make audit npm audit for frontend, backend, and utils/modules packages" + @echo "make change_encryption_key NEW_KEY=<64-char hex> Re-encrypt all user fields with a new encryption key" .PHONY: doc doc: doc_sphinx @@ -137,10 +139,28 @@ build-clean: @docker network rm ${PROJECT_NAME}_default || echo "IGNORING ERROR" .PHONY: backup_db -backup_db: - @echo "Backing up database" - mkdir -p db_dumps - @docker exec -t $${CONTAINER} pg_dumpall -c -U postgres > db_dumps/dump_`date +%d-%m-%Y"_"%H_%M_%S`.sql +backup_db: backend/node_modules/.uptodate +ifndef CONTAINER + $(error CONTAINER is not set. Usage: make backup_db CONTAINER=) +endif + @echo "Backing up database"; \ + mkdir -p db_dumps; \ + printf "Decrypt DB before backup? [y/N] "; \ + read DECRYPT_ANSWER; \ + OUTFILE="db_dumps/dump_$$(date +%d-%m-%Y_%H_%M_%S).sql"; \ + if [ "$$DECRYPT_ANSWER" = "y" ] || [ "$$DECRYPT_ANSWER" = "Y" ]; then \ + SIDECAR="$(POSTGRES_CAREDB)_backup_$$(date +%s)"; \ + echo "[backup] Cloning live DB into $$SIDECAR..."; \ + docker exec $(CONTAINER) psql -q -U postgres -c "CREATE DATABASE $$SIDECAR TEMPLATE $(POSTGRES_CAREDB)"; \ + echo "[backup] Decrypting clone (live DB stays encrypted)..."; \ + (cd backend && POSTGRES_CAREDB=$$SIDECAR npm run --silent decrypt-db); \ + echo "[backup] Dumping plaintext clone..."; \ + docker exec -t $(CONTAINER) pg_dump -c -C -U postgres $$SIDECAR > $$OUTFILE; \ + docker exec $(CONTAINER) psql -q -U postgres -c "DROP DATABASE $$SIDECAR"; \ + echo "[backup] Done - plaintext dump: $$OUTFILE"; \ + else \ + docker exec -t $(CONTAINER) pg_dumpall -c -U postgres > $$OUTFILE; \ + fi .PHONY: recover_db recover_db: @@ -153,6 +173,22 @@ else @cat "db_dumps/$${DUMP}" | docker exec -i $${CONTAINER} psql -U postgres endif +.PHONY: backup +backup: backup_db +ifndef CONTAINER + $(error CONTAINER is not set. Usage: make backup CONTAINER=) +endif + @echo "Creating full backup archive" + @mkdir -p backups + @LATEST_DUMP=$$(ls -t db_dumps/*.sql | head -n 1); \ + TIMESTAMP=$$(date +%d-%m-%Y_%H_%M_%S); \ + tar -czf "backups/backup_$$TIMESTAMP.tar.gz" \ + "$$LATEST_DUMP" \ + .env \ + backend/encryption.key \ + files; \ + echo "Backup created: backups/backup_$$TIMESTAMP.tar.gz" + # Internal target: zip document files from a live DB. Requires DB and FILEZIP to be set. .PHONY: _export_document_files _export_document_files: @@ -215,6 +251,11 @@ export_dump_files: $(MAKE) _export_document_files CONTAINER=$${CONTAINER} DB=$$SIDECAR FILEZIP=$$FILEZIP; \ docker exec $${CONTAINER} psql -q -U postgres -c "DROP DATABASE $$SIDECAR" > /dev/null +.PHONY: change_encryption_key +change_encryption_key: backend/node_modules/.uptodate + @echo "Changing encryption key..." + @cd backend && npm run --silent change-encryption-key + .PHONY: admin-password admin-password: backend/node_modules/.uptodate cd backend && ADMIN_EMAIL="$(ADMIN_EMAIL)" npm run set-admin-password diff --git a/backend/db/MetaModel.js b/backend/db/MetaModel.js index fc27d7984..671865434 100644 --- a/backend/db/MetaModel.js +++ b/backend/db/MetaModel.js @@ -219,6 +219,11 @@ module.exports = class MetaModel extends Model { } catch (err) { console.log("DB MetaModel Class " + this.constructor.name + " add error in creation: " + err.message); + if (err.name === 'SequelizeUniqueConstraintError') { + const field = Object.keys(err.fields || {})[0] || ''; + const fieldName = field.replace(/Hash$/, ''); + throw new Error(`A record with this ${fieldName} already exists.`); + } throw new Error(err.message); } } diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js new file mode 100644 index 000000000..ef083310f --- /dev/null +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -0,0 +1,167 @@ +'use strict'; + +/** + * Encrypt existing plaintext values in user fields: firstName, lastName, email, initialPassword. + * Also populates emailHash from the newly encrypted email value. + * + * Requires DB_ENCRYPTION_KEY to be set in the environment. + * Skips rows where the field already appears encrypted (safe to re-run). + */ + +const { encrypt, getKey, initializeEncryptionKey, decrypt } = require('../../utils/helper/encryption'); + +module.exports = { + async up(queryInterface) { + const isEncryptionEnabled = process.env.ENCRYPTION_ENABLED === 'true'; + if (!isEncryptionEnabled) { + return; + } + initializeEncryptionKey(); + const encryptionKey = getKey(); + if (!encryptionKey) { + throw new Error( + 'DB_ENCRYPTION_KEY must be set before running the user encryption data migration' + ); + } + + const transaction = await queryInterface.sequelize.transaction(); + try { + const users = await queryInterface.sequelize.query( + `SELECT id, "firstName", "lastName", email, "initialPassword", "twoFactorOtp", "totpSecret", "orcidId", "ldapUsername", "samlNameId", "salt" FROM "user"`, + { type: queryInterface.sequelize.QueryTypes.SELECT, transaction } + ); + + for (const user of users) { + const updates = {}; + + if (user.firstName) { + updates.firstName = encrypt(user.firstName); + } + if (user.lastName) { + updates.lastName = encrypt(user.lastName); + } + if (user.email) { + const encryptedEmail = encrypt(user.email); + updates.email = encryptedEmail; + } + if (user.initialPassword) { + updates.initialPassword = encrypt(user.initialPassword); + } + if (user.twoFactorOtp) { + updates.twoFactorOtp = encrypt(user.twoFactorOtp); + } + if (user.totpSecret) { + updates.totpSecret = encrypt(user.totpSecret); + } + if (user.orcidId) { + updates.orcidId = encrypt(user.orcidId); + } + if (user.ldapUsername) { + updates.ldapUsername = encrypt(user.ldapUsername); + } + if (user.samlNameId) { + updates.samlNameId = encrypt(user.samlNameId); + } + if (user.salt) { + updates.salt = encrypt(user.salt); + } + + if (Object.keys(updates).length > 0) { + const setClauses = Object.keys(updates) + .map(col => `"${col}" = :${col}`) + .join(', '); + + await queryInterface.sequelize.query( + `UPDATE "user" SET ${setClauses} WHERE id = :id`, + { + replacements: { ...updates, id: user.id }, + transaction, + } + ); + } + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, + + async down(queryInterface) { + const isEncryptionEnabled = process.env.ENCRYPTION_ENABLED === 'true'; + if (!isEncryptionEnabled) { + return; + } + initializeEncryptionKey(); + const encryptionKey = getKey(); + if (!encryptionKey) { + throw new Error( + 'DB_ENCRYPTION_KEY must be set before running the user encryption data migration' + ); + } + + const transaction = await queryInterface.sequelize.transaction(); + try { + const users = await queryInterface.sequelize.query( + `SELECT id, "firstName", "lastName", email, "initialPassword", "twoFactorOtp", "totpSecret", "orcidId", "ldapUsername", "samlNameId", "salt" FROM "user"`, + { type: queryInterface.sequelize.QueryTypes.SELECT, transaction } + ); + + for (const user of users) { + const updates = {}; + + if (user.firstName) { + updates.firstName = decrypt(user.firstName); + } + if (user.lastName) { + updates.lastName = decrypt(user.lastName); + } + if (user.email) { + const decryptedEmail = decrypt(user.email); + updates.email = decryptedEmail; + } + if (user.initialPassword) { + updates.initialPassword = decrypt(user.initialPassword); + } + if (user.twoFactorOtp) { + updates.twoFactorOtp = decrypt(user.twoFactorOtp); + } + if (user.totpSecret) { + updates.totpSecret = decrypt(user.totpSecret); + } + if (user.orcidId) { + updates.orcidId = decrypt(user.orcidId); + } + if (user.ldapUsername) { + updates.ldapUsername = decrypt(user.ldapUsername); + } + if (user.samlNameId) { + updates.samlNameId = decrypt(user.samlNameId); + } + if (user.salt) { + updates.salt = decrypt(user.salt); + } + + if (Object.keys(updates).length > 0) { + const setClauses = Object.keys(updates) + .map(col => `"${col}" = :${col}`) + .join(', '); + + await queryInterface.sequelize.query( + `UPDATE "user" SET ${setClauses} WHERE id = :id`, + { + replacements: { ...updates, id: user.id }, + transaction, + } + ); + } + } + + await transaction.commit(); + } catch (err) { + await transaction.rollback(); + throw err; + } + }, +}; diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 50e1ed526..af433b2e6 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -602,6 +602,8 @@ module.exports = (sequelize, DataTypes) => { sequelize, modelName: "user", tableName: "user", + //Keys that require encryption unique set to false by default + encryptedFields: ['firstName', 'lastName', { name: 'email', unique: true }, 'salt', 'initialPassword', 'twoFactorOtp', 'totpSecret', 'orcidId', 'ldapUsername', 'samlNameId'], hooks: { afterCreate: async (user, options) => { const {context, transaction} = options; diff --git a/backend/db/plugins.js b/backend/db/plugins.js index 5f153b769..ad791f194 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -1,3 +1,104 @@ +const { DataTypes } = require('sequelize'); +const { encrypt, decrypt, hashForUnique } = require('../utils/helper/encryption.js'); + +/** + * Merge a new hook function into a model's hooks options object. + * Handles the case where a hook already exists (single function or array). + * + * @param {Object} hooks - The hooks object from model options + * @param {string} hookName - e.g. 'beforeCreate' + * @param {Function} fn - The hook function to add + */ +function addHook(hooks, hookName, fn) { + const existing = hooks[hookName]; + if (!existing) { + hooks[hookName] = fn; + } else if (Array.isArray(existing)) { + existing.unshift(fn); // encryption runs before other hooks + } else { + hooks[hookName] = [fn, existing]; + } +} + +/** + * Plugin to add generic field-level encryption to any model that declares encryptedFields. + * + * Each entry in encryptedFields can be a plain string or an object: + * encryptedFields: ['firstName', { name: 'email', unique: true }] + * + * When unique: true, the plugin automatically: + * - Adds a {name}Hash column (STRING, unique) to the model attributes + * - Writes an HMAC-SHA256 of the plaintext into {name}Hash on every create/update + * - The DB migration must still add the {name}Hash column; the model definition is handled here + * + * @param {Object} options - The model options passed to Model.init() + * @param {Object} attributes - The model attributes passed to Model.init() — mutated to inject hash columns + */ +function addEncryptionHooks(options, attributes = {}) { + if (process.env.ENCRYPTION_ENABLED !== 'true') return; + const rawFields = options.encryptedFields; + if (!rawFields?.length) return; + + const parsed = rawFields.map(f => typeof f === 'string' ? { name: f, unique: false } : { name: f.name, unique: !!f.unique }); + const fieldNames = parsed.map(f => f.name); + const uniqueFields = new Set(parsed.filter(f => f.unique).map(f => f.name)); + + // Auto-inject {name}Hash into model attributes for unique encrypted fields + for (const name of uniqueFields) { + const hashField = `${name}Hash`; + if (!attributes[hashField]) { + attributes[hashField] = { type: DataTypes.STRING, unique: true }; + } + } + + if (!options.hooks) options.hooks = {}; + + // Hash plaintext then encrypt — must happen in this order + const encryptField = (instance, name) => { + const val = instance[name]; + if (val == null) return; + if (uniqueFields.has(name)) instance[`${name}Hash`] = hashForUnique(val); + instance[name] = encrypt(val); + }; + + // Encrypt on INSERT + addHook(options.hooks, 'beforeCreate', (instance) => { + for (const name of fieldNames) encryptField(instance, name); + }); + + addHook(options.hooks, 'beforeUpsert', (instance) => { + for (const name of fieldNames) encryptField(instance, name); + }); + + // Encrypt changed fields on UPDATE + addHook(options.hooks, 'beforeUpdate', (instance) => { + for (const name of fieldNames) { + if (instance.changed(name)) encryptField(instance, name); + } + }); + + // Encrypt on bulk INSERT + addHook(options.hooks, 'beforeBulkCreate', (opts) => { + const records = opts.records || opts.instances || []; + for (const instance of records) { + for (const name of fieldNames) encryptField(instance, name); + } + }); + + // Decrypt on every read (single instance or array) + addHook(options.hooks, 'afterFind', (result) => { + if (!result) return; + const rows = Array.isArray(result) ? result : [result]; + for (const row of rows) { + if (!row || typeof row !== 'object') continue; + for (const name of fieldNames) { + const val = row[name]; + if (val != null) row[name] = decrypt(val); + } + } + }); +} + /** * Plugin to add global change tracking hooks to all models. * @@ -6,6 +107,8 @@ function GlobalChangeTrackingPlugin(sequelize) { // Register global hooks for all models sequelize.addHook('beforeDefine', (attributes, options) => { + // Inject encryption hooks for models that declare encryptedFields + addEncryptionHooks(options, attributes); // Add hooks to the model const globalHooks = { @@ -81,4 +184,4 @@ function TimeoutTrackerPlugin(instance) { module.exports = { GlobalChangeTrackingPlugin, TimeoutTrackerPlugin, -}; \ No newline at end of file +}; \ No newline at end of file diff --git a/backend/package.json b/backend/package.json index 001da0fa5..05afb9d2d 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,10 @@ "test_doh": "cross-env NODE_ENV=test jest --detectOpenHandles --testTimeout=10000", "pretest": "cross-env NODE_ENV=test npm run db_migrate:reset", "set-admin-password": "node scripts/setAdminPassword.js", - "anonymize": "node scripts/anonymize.js" + "anonymize": "node scripts/anonymize.js", + "change-encryption-key": "node scripts/changeEncryptionKey.js", + "decrypt-db": "cross-env ENCRYPTION_MODE=decrypt node scripts/toggleEncryption.js", + "encrypt-db": "cross-env ENCRYPTION_MODE=encrypt node scripts/toggleEncryption.js" }, "dependencies": { "@faker-js/faker": "^10.3.0", diff --git a/backend/scripts/changeEncryptionKey.js b/backend/scripts/changeEncryptionKey.js new file mode 100644 index 000000000..c9060770a --- /dev/null +++ b/backend/scripts/changeEncryptionKey.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Re-encrypt all model tables that declare encryptedFields, using the new key. + * The current key is read from backend/encryption.key. + * + * Usage (via Makefile): + * make change_encryption_key NEW_KEY=<64-char hex> + * + * Or directly: + * cd backend && NEW_KEY= node scripts/changeEncryptionKey.js + * + * On success the new key is written to backend/encryption.key. + */ + +const fs = require('fs'); +const path = require('path'); +const readline = require('readline'); +const { getKey, generateEncryptionKey, reEncryptAllModels } = require('../utils/helper/encryption'); +const db = require('../db'); + +const KEY_FILE = path.resolve(__dirname, '../encryption.key'); + +function prompt(question) { + const rl = readline.createInterface({ input: process.stdin, output: process.stdout }); + return new Promise(resolve => rl.question(question, ans => { rl.close(); resolve(ans.trim()); })); +} + +async function main() { + let newKeyHex = process.env.NEW_KEY; + + if (!newKeyHex) { + const answer = await prompt('No NEW_KEY provided. Generate a new key automatically? [y/N] '); + if (answer.toLowerCase() !== 'y') { + console.log('Aborted.'); + process.exit(0); + } + newKeyHex = generateEncryptionKey(); + console.log(`Generated new key: ${newKeyHex}`); + console.log('Store this somewhere safe — it cannot be recovered after rotation.'); + } + + if (db?.sequelize?.options) db.sequelize.options.logging = false; + + const oldKey = getKey(); + const newKey = Buffer.from(newKeyHex, 'hex'); + + if (newKey.length !== 32) { + console.error(`ERROR: NEW_KEY must be a 64-character hex string (32 bytes, got ${newKey.length}).`); + process.exit(1); + } + + if (oldKey.equals(newKey)) { + console.log('NEW_KEY is identical to the current key — nothing to do.'); + process.exit(0); + } + + console.log('Rotating encryption key across all model tables…'); + const results = await reEncryptAllModels(db, newKey); + + for (const { model, total, updated } of results) { + console.log(` [${model}] re-encrypted ${updated}/${total} row(s)`); + } + + fs.writeFileSync(KEY_FILE, newKeyHex, { encoding: 'utf8', mode: 0o600 }); + console.log('encryption.key updated with new key.'); + + if (db.sequelize?.close) await db.sequelize.close(); +} + +main().catch((err) => { + console.error(`ERROR${err.model ? ` in model "${err.model}"` : ''}: ${err.message || err}`); + process.exit(1); +}); diff --git a/backend/scripts/toggleEncryption.js b/backend/scripts/toggleEncryption.js new file mode 100644 index 000000000..3081dac57 --- /dev/null +++ b/backend/scripts/toggleEncryption.js @@ -0,0 +1,44 @@ +'use strict'; + +/** + * Bulk encrypt or decrypt all encryptedFields across all models. + * Used by the backup targets to produce a plaintext dump, then restore encryption. + * + * Usage: + * ENCRYPTION_MODE=decrypt node scripts/toggleEncryption.js + * ENCRYPTION_MODE=encrypt node scripts/toggleEncryption.js + */ + +const { decryptAllModels, encryptAllModels } = require('../utils/helper/encryption'); +const db = require('../db'); + +async function main() { + const mode = process.env.ENCRYPTION_MODE; + if (mode !== 'encrypt' && mode !== 'decrypt') { + console.error('ERROR: ENCRYPTION_MODE must be "encrypt" or "decrypt".'); + process.exit(1); + } + + if (db?.sequelize?.options) db.sequelize.options.logging = false; + + if (mode === 'decrypt') { + console.log('[encryption] Decrypting all fields...'); + const results = await decryptAllModels(db); + for (const { model, total, updated } of results) { + console.log(` [${model}] decrypted ${updated}/${total} row(s)`); + } + } else { + console.log('[encryption] Encrypting all fields...'); + const results = await encryptAllModels(db); + for (const { model, total, updated } of results) { + console.log(` [${model}] encrypted ${updated}/${total} row(s)`); + } + } + + if (db.sequelize?.close) await db.sequelize.close(); +} + +main().catch((err) => { + console.error(`ERROR${err.model ? ` in model "${err.model}"` : ''}: ${err.message || err}`); + process.exit(1); +}); diff --git a/backend/utils/helper/encryption.js b/backend/utils/helper/encryption.js new file mode 100644 index 000000000..274490a7e --- /dev/null +++ b/backend/utils/helper/encryption.js @@ -0,0 +1,390 @@ +/** + * AES-256-GCM encryption utilities for sensitive user fields. + * + * Key source: DB_ENCRYPTION_KEY in .env + * Must be a Base64-encoded 32-byte value. + * Generate with: openssl rand -base64 32 + * + * Storage format (Base64 string stored in TEXT column): + * base64( iv[12 bytes] + authTag[16 bytes] + ciphertext ) + * + * decrypt() returns the input unchanged when the value is not encrypted, + * so it is safe to call on legacy plaintext rows during migration. + * @author karim ouf + */ + + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +const PROJECT_ROOT = path.resolve(__dirname, "../"); +const KEY_FILE = path.join(PROJECT_ROOT, "encryption.key"); +const STATE_FILE = path.join(PROJECT_ROOT, "encryption.state"); +// Minimum byte length of a valid encrypted buffer: IV + authTag + 1 byte ciphertext +const MIN_ENCRYPTED_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH + 1; + +/** + * Load and validate the encryption key from environment. + * @returns {Buffer} 32-byte key + */ +function getKey() { + const keyFilePath = KEY_FILE; + + if (!fs.existsSync(keyFilePath)) { + throw new Error( + `Encryption key file not found: ${keyFilePath}. ` + + "Start the server once to generate it." + ); + } + + const raw = fs.readFileSync(keyFilePath, "utf8").trim(); + + if (!raw) { + throw new Error("Encryption key file is empty."); + } + + const key = Buffer.from(raw, "hex"); + + if (key.length !== 32) { + throw new Error( + `Encryption key must decode to exactly 32 bytes (got ${key.length}).` + ); + } + + return key; +} +/** + * Encrypt a plaintext string with AES-256-GCM. + * Each call uses a fresh random IV so the same plaintext produces different ciphertext. + * + * @param {string|null|undefined} plaintext + * @param {Buffer|string} [key] 32-byte key — defaults to the key stored in encryption.key + * @returns {string|null} Base64-encoded encrypted string, or null for null/undefined input + */ +function encrypt(plaintext, key) { + if (plaintext === null || plaintext === undefined) return null; + + const resolvedKey = key ? parseKey(key) : getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, resolvedKey, iv, { authTagLength: AUTH_TAG_LENGTH }); + + const ciphertext = Buffer.concat([ + cipher.update(String(plaintext), 'utf8'), + cipher.final(), + ]); + const authTag = cipher.getAuthTag(); + + // Pack: iv (12) | authTag (16) | ciphertext + return Buffer.concat([iv, authTag, ciphertext]).toString('base64'); +} + +/** + * Decrypt a Base64 string produced by encrypt(). + * Returns the value as-is if it does not look like an encrypted string + * (e.g. a legacy plaintext value still in the DB). + * + * @param {string|null|undefined} value + * @returns {string|null} Decrypted plaintext, or original value if not encrypted, or null + */ +function decrypt(value) { + if (value === null || value === undefined) return null; + if (typeof value !== 'string') return value; + + let packed; + try { + packed = Buffer.from(value, 'base64'); + } catch { + return value; // not valid Base64 → plaintext + } + + if (packed.length < MIN_ENCRYPTED_LENGTH) { + return value; // too short to be an encrypted value → plaintext + } + + try { + const key = getKey(); + const iv = packed.subarray(0, IV_LENGTH); + const authTag = packed.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const ciphertext = packed.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + + const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + decipher.setAuthTag(authTag); + + return decipher.update(ciphertext, undefined, 'utf8') + decipher.final('utf8'); + } catch { + // Wrong key, corrupted data, or a plaintext string that happens to be valid Base64 + return value; + } +} + +function initializeEncryptionKey() { + const keyFile = KEY_FILE; + + if (fs.existsSync(keyFile)) { + const key = fs.readFileSync(keyFile, "utf8").trim(); + console.log("Using existing encryption key"); + return key; + } + + const key = crypto.randomBytes(32).toString("hex"); + + fs.writeFileSync(keyFile, key, { + encoding: "utf8", + mode: 0o600, // owner read/write only + }); + + console.log("Generated new encryption key"); + return key; +} + +/** + * Parse a key argument that may be a 32-byte Buffer or a 64-char hex string. + * @param {Buffer|string} keyInput + * @returns {Buffer} + */ +function parseKey(keyInput) { + if (Buffer.isBuffer(keyInput)) { + if (keyInput.length !== 32) throw new Error(`Key buffer must be 32 bytes (got ${keyInput.length}).`); + return keyInput; + } + if (typeof keyInput === 'string') { + const buf = Buffer.from(keyInput, 'hex'); + if (buf.length !== 32) throw new Error(`Key hex must decode to 32 bytes (got ${buf.length}).`); + return buf; + } + throw new Error('Key must be a Buffer or a hex string.'); +} + +/** + * HMAC-SHA256 of plaintext using the encryption key. + * Use this as the value for a {field}Hash column when the plaintext field is encrypted but must remain uniquely queryable. + * Keyed hash prevents offline enumeration (unlike plain SHA256). + */ +function hashForUnique(plaintext) { + if (plaintext === null || plaintext === undefined) return null; + return crypto.createHmac('sha256', getKey()).update(String(plaintext)).digest('hex'); +} + +/** + * Re-encrypt a single value from the current key (read from file) to newKey. + * decrypt() uses the file key, which is still the old key at rotation time. + * + * - If the value is null/undefined it is returned as-is. + * - If the value does not decrypt (not encrypted, or wrong key) it is returned unchanged. + * + * @param {string|null|undefined} encryptedValue Value currently stored in the DB + * @param {Buffer|string} newKey 32-byte key to re-encrypt with + * @returns {string|null} Value re-encrypted with newKey, or original if not encrypted + */ +function reEncryptValue(encryptedValue, newKey) { + if (encryptedValue === null || encryptedValue === undefined) return null; + if (typeof encryptedValue !== 'string') return encryptedValue; + + const newKeyBuf = parseKey(newKey); + const plaintext = decrypt(encryptedValue); + + // decrypt() returns the original value when decryption fails + if (plaintext === encryptedValue) return encryptedValue; + + return encrypt(plaintext, newKeyBuf); +} + +/** + * Iterate every model table that declares `encryptedFields`, apply `transformFn` to each + * field value, and persist the results. Each table runs in its own transaction. + * + * @param {object} db The db object exported from backend/db + * @param {Function} transformFn (value: string) => string — called for every non-null field value + * @returns {Promise>} + */ +async function _applyToAllModels(db, transformFn) { + const { sequelize, models } = db; + const results = []; + + for (const [modelName, Model] of Object.entries(models)) { + const fields = Model.options && Model.options.encryptedFields; + if (!fields || fields.length === 0) continue; + + const tableName = Model.tableName; + const pk = Model.primaryKeyAttribute || 'id'; + + const transaction = await sequelize.transaction(); + const fieldNames = fields.map(f => typeof f === 'string' ? f : f.name); + try { + const rows = await sequelize.query( + `SELECT "${pk}", ${fieldNames.map(f => `"${f}"`).join(', ')} FROM "${tableName}"`, + { type: sequelize.QueryTypes.SELECT, transaction } + ); + + let updated = 0; + for (const row of rows) { + const updates = {}; + for (const field of fieldNames) { + if (row[field] != null) { + updates[field] = transformFn(row[field]); + } + } + if (Object.keys(updates).length > 0) { + const setClauses = Object.keys(updates).map(col => `"${col}" = :${col}`).join(', '); + await sequelize.query( + `UPDATE "${tableName}" SET ${setClauses} WHERE "${pk}" = :pk`, + { replacements: { ...updates, pk: row[pk] }, transaction } + ); + updated++; + } + } + + await transaction.commit(); + results.push({ model: modelName, total: rows.length, updated }); + } catch (err) { + await transaction.rollback(); + throw Object.assign(err, { model: modelName }); + } + } + + return results; +} + +/** + * Re-encrypt all encryptedFields using the current file key as the old key and newKey as the new key. + * @param {object} db The db object exported from backend/db + * @param {Buffer|string} newKey + */ +async function reEncryptAllModels(db, newKey) { + const newKeyBuf = parseKey(newKey); + return _applyToAllModels(db, value => reEncryptValue(value, newKeyBuf)); +} + +/** + * Decrypt all encryptedFields back to plaintext. + * Call this when ENCRYPTION_ENABLED is switched from true → false. + * @param {object} db The db object exported from backend/db + */ +async function decryptAllModels(db) { + return _applyToAllModels(db, value => decrypt(value)); +} + +/** + * Encrypt all encryptedFields using the current key. + * Call this when ENCRYPTION_ENABLED is switched from false → true. + * Safe to re-run: values already encrypted are decrypted first to avoid double-encryption. + * @param {object} db The db object exported from backend/db + */ +async function encryptAllModels(db) { + return _applyToAllModels(db, value => encrypt(decrypt(value))); +} + +/** + * Detect changes to ENCRYPTION_ENABLED and automatically encrypt or decrypt all model tables. + * Compares the current env value against the last recorded state in encryption.state. + * On the very first call (no state file) the state is recorded without migrating data, + * since the migration is responsible for the initial encryption. + * + * @param {object} db The db object exported from backend/db + */ +async function syncEncryptionState(db) { + const isEnabled = process.env.ENCRYPTION_ENABLED === 'true'; + + if (!fs.existsSync(STATE_FILE)) { + fs.writeFileSync(STATE_FILE, String(isEnabled), { encoding: 'utf8', mode: 0o600 }); + return; + } + + const wasEnabled = fs.readFileSync(STATE_FILE, 'utf8').trim() === 'true'; + if (isEnabled === wasEnabled) return; + + if (isEnabled) { + console.log('[encryption] ENCRYPTION_ENABLED changed to true — encrypting all fields...'); + const results = await encryptAllModels(db); + for (const { model, total, updated } of results) { + console.log(` [${model}] encrypted ${updated}/${total} row(s)`); + } + } else { + console.log('[encryption] ENCRYPTION_ENABLED changed to false — decrypting all fields...'); + const results = await decryptAllModels(db); + for (const { model, total, updated } of results) { + console.log(` [${model}] decrypted ${updated}/${total} row(s)`); + } + } + + fs.writeFileSync(STATE_FILE, String(isEnabled), { encoding: 'utf8', mode: 0o600 }); +} + +/** + * Generate a cryptographically secure random AES-256 key. + * @returns {string} 64-character lowercase hex string (32 bytes) + */ +function generateEncryptionKey() { + return crypto.randomBytes(32).toString('hex'); +} + +/** + * For each model with uniqueEncryptedFields, ensures the {field}Hash column exists in the DB, + * back-fills HMAC hashes for any rows missing them, then adds a unique constraint. + * Safe to call on every startup — all steps are idempotent. + * + * @param {object} db The db object exported from backend/db + */ +async function syncHashColumns(db) { + if (process.env.ENCRYPTION_ENABLED !== 'true') return; + const { DataTypes } = require('sequelize'); + const { sequelize, models } = db; + const qi = sequelize.getQueryInterface(); + + for (const [, Model] of Object.entries(models)) { + const encryptedFields = Model.options?.encryptedFields || []; + if (!encryptedFields.length) continue; + const uniqueFields = encryptedFields.filter(f => typeof f !== 'string' && f.unique).map(f => f.name); + if (!uniqueFields.length) continue; + + const tableName = Model.tableName; + let tableDesc; + try { + tableDesc = await qi.describeTable(tableName); + } catch { + continue; // table doesn't exist yet (migrations haven't run) + } + + const pk = Model.primaryKeyAttribute || 'id'; + + for (const fieldName of uniqueFields) { + const hashField = `${fieldName}Hash`; + const isNew = !tableDesc[hashField]; + + if (isNew) { + await qi.addColumn(tableName, hashField, { type: DataTypes.STRING, allowNull: true, unique: true }); + } + + // Back-fill rows where the hash is missing + const rows = await sequelize.query( + `SELECT "${pk}", "${fieldName}" FROM "${tableName}" WHERE "${hashField}" IS NULL AND "${fieldName}" IS NOT NULL`, + { type: sequelize.QueryTypes.SELECT } + ); + for (const row of rows) { + const plaintext = decrypt(row[fieldName]); + await sequelize.query( + `UPDATE "${tableName}" SET "${hashField}" = :hash WHERE "${pk}" = :id`, + { replacements: { hash: hashForUnique(plaintext), id: row[pk] } } + ); + } + + if (isNew) { + try { + await qi.addConstraint(tableName, { + fields: [hashField], + type: 'unique', + name: "SequelizeUniqueConstraintError", + }); + } catch {} + } + } + } +} + +module.exports = { encrypt, decrypt, hashForUnique, syncHashColumns, initializeEncryptionKey, getKey, generateEncryptionKey, reEncryptValue, reEncryptAllModels, decryptAllModels, encryptAllModels, syncEncryptionState }; diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 1628873a5..7e2a40de8 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -20,6 +20,7 @@ const nodemailer = require('nodemailer'); const { setupDevAdmin } = require('./utils/devAdmin'); const { initializeAuth } = require("./auth"); const { parseUserAgent } = require("../utils/helper/generic"); +const { initializeEncryptionKey, syncEncryptionState, syncHashColumns } = require("../utils/helper/encryption"); /** * Defines Express Webserver of Content Server @@ -121,7 +122,7 @@ module.exports = class Server { this.#discoverComponents("./rpcs", RPC, this.addRPC.bind(this)); this.#discoverComponents("./sockets", Socket, this.addSocket.bind(this)); this.#discoverComponents("./services", Service, this.addService.bind(this)); - + initializeEncryptionKey(); // Graceful shutdown: flush all stats buffers on kill signals const handleShutdown = async (signal) => { try { @@ -478,8 +479,10 @@ module.exports = class Server { * Start the webserver * @param port */ - start(port) { + async start(port) { this.logger.debug("Start Webserver..."); + await syncEncryptionState(this.db); + await syncHashColumns(this.db); this.http = this.httpServer.listen(port, () => { this.logger.info("Server started on port " + port); }); diff --git a/docker-compose.yml b/docker-compose.yml index 8addba609..9548e4c96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,11 +25,12 @@ services: args: ENV: ${ENV} volumes: - - ./docs/build:/content-server/docs/build - - ./docs/api:/content-server/docs/api - - ./files:/content-server/files - - ./logs:/content-server/backend/logs - - ./msmtprc:/etc/msmtprc:ro + - ./docs/build:/content-server/docs/build + - ./docs/api:/content-server/docs/api + - ./files:/content-server/files + - ./logs:/content-server/backend/logs + - ./msmtprc:/etc/msmtprc:ro + - ./encryption.key:/content-server/encryption.key depends_on: - postgres ports: