From c49c087b5df5d8aa0800c6577889a2f3386d4f87 Mon Sep 17 00:00:00 2001 From: junaidferoz <60928280+junaidferoz@users.noreply.github.com> Date: Mon, 18 May 2026 14:37:26 +0200 Subject: [PATCH 01/30] feat: add pgcrypto user table encryption migration Add DB-only migration to encrypt user table fields with pgcrypto and wire encryption helpers in db/index.js. --- backend/db/index.js | 11 + ...21500-transform-user-encryption-db-only.js | 317 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 backend/db/migrations/20260505121500-transform-user-encryption-db-only.js diff --git a/backend/db/index.js b/backend/db/index.js index 444219742..7dfa428d2 100644 --- a/backend/db/index.js +++ b/backend/db/index.js @@ -19,6 +19,17 @@ const config = { ...loadedConfig, hooks: { ...(loadedConfig.hooks || {}), // Preserve existing hooks if we should ever add any in the config + afterConnect: async (connection) => { + if (loadedConfig.hooks && typeof loadedConfig.hooks.afterConnect === "function") { + await loadedConfig.hooks.afterConnect(connection); + } + const encryptionKey = process.env.DB_ENCRYPTION_KEY; + if (!encryptionKey) { + return; + } + const escapedKey = encryptionKey.replace(/'/g, "''"); + await connection.query(`SET app.encryption_key = '${escapedKey}'`); + }, afterInit: TimeoutTrackerPlugin, } }; diff --git a/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js b/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js new file mode 100644 index 000000000..d61fe8a92 --- /dev/null +++ b/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js @@ -0,0 +1,317 @@ +'use strict'; + +/** + * DB-only encryption for selected user fields while keeping the app contract stable. + * The app continues to read/write table name "user" and the same column names. + */ +module.exports = { + async up(queryInterface) { + const encryptionKey = process.env.DB_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error('DB_ENCRYPTION_KEY must be set before running user encryption migration'); + } + + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query( + `SELECT set_config('app.encryption_key', :encryptionKey, false);`, + { + replacements: { encryptionKey }, + transaction, + } + ); + + await queryInterface.sequelize.query( + `CREATE EXTENSION IF NOT EXISTS pgcrypto;`, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + CREATE OR REPLACE FUNCTION public.encrypt_text(p_value text) + RETURNS bytea + LANGUAGE plpgsql + AS $$ + DECLARE + v_key text; + BEGIN + IF p_value IS NULL THEN + RETURN NULL; + END IF; + + v_key := current_setting('app.encryption_key', true); + IF v_key IS NULL OR v_key = '' THEN + RAISE EXCEPTION 'app.encryption_key is not set'; + END IF; + + RETURN pgp_sym_encrypt(p_value, v_key); + END; + $$; + `, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + CREATE OR REPLACE FUNCTION public.decrypt_text(p_value bytea) + RETURNS text + LANGUAGE plpgsql + AS $$ + DECLARE + v_key text; + BEGIN + IF p_value IS NULL THEN + RETURN NULL; + END IF; + + v_key := current_setting('app.encryption_key', true); + IF v_key IS NULL OR v_key = '' THEN + RAISE EXCEPTION 'app.encryption_key is not set'; + END IF; + + RETURN pgp_sym_decrypt(p_value, v_key); + END; + $$; + `, + { transaction } + ); + + await queryInterface.sequelize.query( + `ALTER TABLE public."user" RENAME TO user_secure;`, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + ALTER TABLE public.user_secure + ALTER COLUMN "firstName" TYPE bytea USING public.encrypt_text("firstName"), + ALTER COLUMN "lastName" TYPE bytea USING public.encrypt_text("lastName"), + ALTER COLUMN email TYPE bytea USING public.encrypt_text(email), + ALTER COLUMN "initialPassword" TYPE bytea USING public.encrypt_text("initialPassword"); + `, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + CREATE OR REPLACE VIEW public."user" AS + SELECT + id, + public.decrypt_text("firstName") AS "firstName", + public.decrypt_text("lastName") AS "lastName", + "userName", + public.decrypt_text(email) AS email, + "passwordHash", + "acceptTerms", + "acceptStats", + salt, + "lastLoginAt", + deleted, + "createdAt", + "updatedAt", + "deletedAt", + "acceptedAt", + "acceptDataSharing", + "rolesUpdatedAt", + "extId", + public.decrypt_text("initialPassword") AS "initialPassword", + "emailVerified", + "emailVerificationToken", + "resetToken", + "lastPasswordResetEmailSent", + "lastVerificationEmailSent", + "twoFactorOtp", + "twoFactorOtpExpiresAt", + "twoFactorMethods", + "totpSecret", + "orcidId", + "ldapUsername", + "samlNameId" + FROM public.user_secure; + `, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + CREATE OR REPLACE FUNCTION public.user_view_iud() + RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + v_id integer; + BEGIN + IF TG_OP = 'INSERT' THEN + IF EXISTS ( + SELECT 1 + FROM public.user_secure u + WHERE public.decrypt_text(u.email) = NEW.email + ) THEN + RAISE unique_violation USING MESSAGE = 'duplicate key value violates unique constraint "user_email_key"'; + END IF; + + INSERT INTO public.user_secure ( + id, "firstName", "lastName", "userName", email, + "passwordHash", "acceptTerms", "acceptStats", salt, "lastLoginAt", + deleted, "createdAt", "updatedAt", "deletedAt", "acceptedAt", + "acceptDataSharing", "rolesUpdatedAt", "extId", "initialPassword", + "emailVerified", "emailVerificationToken", "resetToken", + "lastPasswordResetEmailSent", "lastVerificationEmailSent", + "twoFactorOtp", "twoFactorOtpExpiresAt", "twoFactorMethods", + "totpSecret", "orcidId", "ldapUsername", "samlNameId" + ) + VALUES ( + COALESCE(NEW.id, nextval(pg_get_serial_sequence('public.user_secure', 'id'))), + public.encrypt_text(NEW."firstName"), + public.encrypt_text(NEW."lastName"), + NEW."userName", + public.encrypt_text(NEW.email), + NEW."passwordHash", + NEW."acceptTerms", + NEW."acceptStats", + NEW.salt, + NEW."lastLoginAt", + NEW.deleted, + NEW."createdAt", + NEW."updatedAt", + NEW."deletedAt", + NEW."acceptedAt", + NEW."acceptDataSharing", + NEW."rolesUpdatedAt", + NEW."extId", + public.encrypt_text(NEW."initialPassword"), + NEW."emailVerified", + NEW."emailVerificationToken", + NEW."resetToken", + NEW."lastPasswordResetEmailSent", + NEW."lastVerificationEmailSent", + NEW."twoFactorOtp", + NEW."twoFactorOtpExpiresAt", + NEW."twoFactorMethods", + NEW."totpSecret", + NEW."orcidId", + NEW."ldapUsername", + NEW."samlNameId" + ) + RETURNING id INTO v_id; + + NEW.id := v_id; + RETURN NEW; + END IF; + + IF TG_OP = 'UPDATE' THEN + IF EXISTS ( + SELECT 1 + FROM public.user_secure u + WHERE u.id <> OLD.id + AND public.decrypt_text(u.email) = NEW.email + ) THEN + RAISE unique_violation USING MESSAGE = 'duplicate key value violates unique constraint "user_email_key"'; + END IF; + + UPDATE public.user_secure + SET + "firstName" = public.encrypt_text(NEW."firstName"), + "lastName" = public.encrypt_text(NEW."lastName"), + "userName" = NEW."userName", + email = public.encrypt_text(NEW.email), + "passwordHash" = NEW."passwordHash", + "acceptTerms" = NEW."acceptTerms", + "acceptStats" = NEW."acceptStats", + salt = NEW.salt, + "lastLoginAt" = NEW."lastLoginAt", + deleted = NEW.deleted, + "createdAt" = NEW."createdAt", + "updatedAt" = NEW."updatedAt", + "deletedAt" = NEW."deletedAt", + "acceptedAt" = NEW."acceptedAt", + "acceptDataSharing" = NEW."acceptDataSharing", + "rolesUpdatedAt" = NEW."rolesUpdatedAt", + "extId" = NEW."extId", + "initialPassword" = public.encrypt_text(NEW."initialPassword"), + "emailVerified" = NEW."emailVerified", + "emailVerificationToken" = NEW."emailVerificationToken", + "resetToken" = NEW."resetToken", + "lastPasswordResetEmailSent" = NEW."lastPasswordResetEmailSent", + "lastVerificationEmailSent" = NEW."lastVerificationEmailSent", + "twoFactorOtp" = NEW."twoFactorOtp", + "twoFactorOtpExpiresAt" = NEW."twoFactorOtpExpiresAt", + "twoFactorMethods" = NEW."twoFactorMethods", + "totpSecret" = NEW."totpSecret", + "orcidId" = NEW."orcidId", + "ldapUsername" = NEW."ldapUsername", + "samlNameId" = NEW."samlNameId" + WHERE id = OLD.id; + + RETURN NEW; + END IF; + + IF TG_OP = 'DELETE' THEN + DELETE FROM public.user_secure WHERE id = OLD.id; + RETURN OLD; + END IF; + + RETURN NULL; + END; + $$; + `, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + CREATE TRIGGER user_view_iud_trigger + INSTEAD OF INSERT OR UPDATE OR DELETE ON public."user" + FOR EACH ROW + EXECUTE FUNCTION public.user_view_iud(); + `, + { transaction } + ); + }); + }, + + async down(queryInterface) { + const encryptionKey = process.env.DB_ENCRYPTION_KEY; + if (!encryptionKey) { + throw new Error('DB_ENCRYPTION_KEY must be set before reverting user encryption migration'); + } + + await queryInterface.sequelize.transaction(async (transaction) => { + await queryInterface.sequelize.query( + `SELECT set_config('app.encryption_key', :encryptionKey, false);`, + { + replacements: { encryptionKey }, + transaction, + } + ); + + await queryInterface.sequelize.query( + `DROP TRIGGER IF EXISTS user_view_iud_trigger ON public."user";`, + { transaction } + ); + await queryInterface.sequelize.query( + `DROP FUNCTION IF EXISTS public.user_view_iud();`, + { transaction } + ); + await queryInterface.sequelize.query( + `DROP VIEW IF EXISTS public."user";`, + { transaction } + ); + + await queryInterface.sequelize.query( + ` + ALTER TABLE public.user_secure + ALTER COLUMN "firstName" TYPE text USING public.decrypt_text("firstName"), + ALTER COLUMN "lastName" TYPE text USING public.decrypt_text("lastName"), + ALTER COLUMN email TYPE text USING public.decrypt_text(email), + ALTER COLUMN "initialPassword" TYPE text USING public.decrypt_text("initialPassword"); + `, + { transaction } + ); + + await queryInterface.sequelize.query( + `ALTER TABLE public.user_secure RENAME TO "user";`, + { transaction } + ); + }); + } +}; From 7ea39b55b70367bafb1967894ec617d6474965ae Mon Sep 17 00:00:00 2001 From: junaidferoz <60928280+junaidferoz@users.noreply.github.com> Date: Mon, 18 May 2026 16:04:06 +0200 Subject: [PATCH 02/30] fix(db): COALESCE user view insert booleans and require DB_ENCRYPTION_KEY --- backend/db/index.js | 4 ++-- .../20260505121500-transform-user-encryption-db-only.js | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/backend/db/index.js b/backend/db/index.js index 7dfa428d2..1fb9261fb 100644 --- a/backend/db/index.js +++ b/backend/db/index.js @@ -1,7 +1,7 @@ /** * Declare all necessary dependencies to work with the database models * - * @author Nils Dycke, Dennis Zyska + * @author Nils Dycke, Dennis Zyska, Junaid Feroz */ 'use strict'; @@ -25,7 +25,7 @@ const config = { } const encryptionKey = process.env.DB_ENCRYPTION_KEY; if (!encryptionKey) { - return; + throw new Error('DB_ENCRYPTION_KEY must be set before establishing database connections'); } const escapedKey = encryptionKey.replace(/'/g, "''"); await connection.query(`SET app.encryption_key = '${escapedKey}'`); diff --git a/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js b/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js index d61fe8a92..d66c1f65d 100644 --- a/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js +++ b/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js @@ -170,16 +170,16 @@ module.exports = { NEW."acceptStats", NEW.salt, NEW."lastLoginAt", - NEW.deleted, + COALESCE(NEW.deleted, false), NEW."createdAt", NEW."updatedAt", NEW."deletedAt", NEW."acceptedAt", - NEW."acceptDataSharing", + COALESCE(NEW."acceptDataSharing", false), NEW."rolesUpdatedAt", NEW."extId", public.encrypt_text(NEW."initialPassword"), - NEW."emailVerified", + COALESCE(NEW."emailVerified", false), NEW."emailVerificationToken", NEW."resetToken", NEW."lastPasswordResetEmailSent", From 1d7c03e16c3697e064579f8193d617c92006c298 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:43:20 +0200 Subject: [PATCH 03/30] fix: remove old implementation of encryption --- backend/db/index.js | 11 - ...21500-transform-user-encryption-db-only.js | 317 ------------------ 2 files changed, 328 deletions(-) delete mode 100644 backend/db/migrations/20260505121500-transform-user-encryption-db-only.js diff --git a/backend/db/index.js b/backend/db/index.js index 1fb9261fb..53d8463b2 100644 --- a/backend/db/index.js +++ b/backend/db/index.js @@ -19,17 +19,6 @@ const config = { ...loadedConfig, hooks: { ...(loadedConfig.hooks || {}), // Preserve existing hooks if we should ever add any in the config - afterConnect: async (connection) => { - if (loadedConfig.hooks && typeof loadedConfig.hooks.afterConnect === "function") { - await loadedConfig.hooks.afterConnect(connection); - } - const encryptionKey = process.env.DB_ENCRYPTION_KEY; - if (!encryptionKey) { - throw new Error('DB_ENCRYPTION_KEY must be set before establishing database connections'); - } - const escapedKey = encryptionKey.replace(/'/g, "''"); - await connection.query(`SET app.encryption_key = '${escapedKey}'`); - }, afterInit: TimeoutTrackerPlugin, } }; diff --git a/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js b/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js deleted file mode 100644 index d66c1f65d..000000000 --- a/backend/db/migrations/20260505121500-transform-user-encryption-db-only.js +++ /dev/null @@ -1,317 +0,0 @@ -'use strict'; - -/** - * DB-only encryption for selected user fields while keeping the app contract stable. - * The app continues to read/write table name "user" and the same column names. - */ -module.exports = { - async up(queryInterface) { - const encryptionKey = process.env.DB_ENCRYPTION_KEY; - if (!encryptionKey) { - throw new Error('DB_ENCRYPTION_KEY must be set before running user encryption migration'); - } - - await queryInterface.sequelize.transaction(async (transaction) => { - await queryInterface.sequelize.query( - `SELECT set_config('app.encryption_key', :encryptionKey, false);`, - { - replacements: { encryptionKey }, - transaction, - } - ); - - await queryInterface.sequelize.query( - `CREATE EXTENSION IF NOT EXISTS pgcrypto;`, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - CREATE OR REPLACE FUNCTION public.encrypt_text(p_value text) - RETURNS bytea - LANGUAGE plpgsql - AS $$ - DECLARE - v_key text; - BEGIN - IF p_value IS NULL THEN - RETURN NULL; - END IF; - - v_key := current_setting('app.encryption_key', true); - IF v_key IS NULL OR v_key = '' THEN - RAISE EXCEPTION 'app.encryption_key is not set'; - END IF; - - RETURN pgp_sym_encrypt(p_value, v_key); - END; - $$; - `, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - CREATE OR REPLACE FUNCTION public.decrypt_text(p_value bytea) - RETURNS text - LANGUAGE plpgsql - AS $$ - DECLARE - v_key text; - BEGIN - IF p_value IS NULL THEN - RETURN NULL; - END IF; - - v_key := current_setting('app.encryption_key', true); - IF v_key IS NULL OR v_key = '' THEN - RAISE EXCEPTION 'app.encryption_key is not set'; - END IF; - - RETURN pgp_sym_decrypt(p_value, v_key); - END; - $$; - `, - { transaction } - ); - - await queryInterface.sequelize.query( - `ALTER TABLE public."user" RENAME TO user_secure;`, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - ALTER TABLE public.user_secure - ALTER COLUMN "firstName" TYPE bytea USING public.encrypt_text("firstName"), - ALTER COLUMN "lastName" TYPE bytea USING public.encrypt_text("lastName"), - ALTER COLUMN email TYPE bytea USING public.encrypt_text(email), - ALTER COLUMN "initialPassword" TYPE bytea USING public.encrypt_text("initialPassword"); - `, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - CREATE OR REPLACE VIEW public."user" AS - SELECT - id, - public.decrypt_text("firstName") AS "firstName", - public.decrypt_text("lastName") AS "lastName", - "userName", - public.decrypt_text(email) AS email, - "passwordHash", - "acceptTerms", - "acceptStats", - salt, - "lastLoginAt", - deleted, - "createdAt", - "updatedAt", - "deletedAt", - "acceptedAt", - "acceptDataSharing", - "rolesUpdatedAt", - "extId", - public.decrypt_text("initialPassword") AS "initialPassword", - "emailVerified", - "emailVerificationToken", - "resetToken", - "lastPasswordResetEmailSent", - "lastVerificationEmailSent", - "twoFactorOtp", - "twoFactorOtpExpiresAt", - "twoFactorMethods", - "totpSecret", - "orcidId", - "ldapUsername", - "samlNameId" - FROM public.user_secure; - `, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - CREATE OR REPLACE FUNCTION public.user_view_iud() - RETURNS trigger - LANGUAGE plpgsql - AS $$ - DECLARE - v_id integer; - BEGIN - IF TG_OP = 'INSERT' THEN - IF EXISTS ( - SELECT 1 - FROM public.user_secure u - WHERE public.decrypt_text(u.email) = NEW.email - ) THEN - RAISE unique_violation USING MESSAGE = 'duplicate key value violates unique constraint "user_email_key"'; - END IF; - - INSERT INTO public.user_secure ( - id, "firstName", "lastName", "userName", email, - "passwordHash", "acceptTerms", "acceptStats", salt, "lastLoginAt", - deleted, "createdAt", "updatedAt", "deletedAt", "acceptedAt", - "acceptDataSharing", "rolesUpdatedAt", "extId", "initialPassword", - "emailVerified", "emailVerificationToken", "resetToken", - "lastPasswordResetEmailSent", "lastVerificationEmailSent", - "twoFactorOtp", "twoFactorOtpExpiresAt", "twoFactorMethods", - "totpSecret", "orcidId", "ldapUsername", "samlNameId" - ) - VALUES ( - COALESCE(NEW.id, nextval(pg_get_serial_sequence('public.user_secure', 'id'))), - public.encrypt_text(NEW."firstName"), - public.encrypt_text(NEW."lastName"), - NEW."userName", - public.encrypt_text(NEW.email), - NEW."passwordHash", - NEW."acceptTerms", - NEW."acceptStats", - NEW.salt, - NEW."lastLoginAt", - COALESCE(NEW.deleted, false), - NEW."createdAt", - NEW."updatedAt", - NEW."deletedAt", - NEW."acceptedAt", - COALESCE(NEW."acceptDataSharing", false), - NEW."rolesUpdatedAt", - NEW."extId", - public.encrypt_text(NEW."initialPassword"), - COALESCE(NEW."emailVerified", false), - NEW."emailVerificationToken", - NEW."resetToken", - NEW."lastPasswordResetEmailSent", - NEW."lastVerificationEmailSent", - NEW."twoFactorOtp", - NEW."twoFactorOtpExpiresAt", - NEW."twoFactorMethods", - NEW."totpSecret", - NEW."orcidId", - NEW."ldapUsername", - NEW."samlNameId" - ) - RETURNING id INTO v_id; - - NEW.id := v_id; - RETURN NEW; - END IF; - - IF TG_OP = 'UPDATE' THEN - IF EXISTS ( - SELECT 1 - FROM public.user_secure u - WHERE u.id <> OLD.id - AND public.decrypt_text(u.email) = NEW.email - ) THEN - RAISE unique_violation USING MESSAGE = 'duplicate key value violates unique constraint "user_email_key"'; - END IF; - - UPDATE public.user_secure - SET - "firstName" = public.encrypt_text(NEW."firstName"), - "lastName" = public.encrypt_text(NEW."lastName"), - "userName" = NEW."userName", - email = public.encrypt_text(NEW.email), - "passwordHash" = NEW."passwordHash", - "acceptTerms" = NEW."acceptTerms", - "acceptStats" = NEW."acceptStats", - salt = NEW.salt, - "lastLoginAt" = NEW."lastLoginAt", - deleted = NEW.deleted, - "createdAt" = NEW."createdAt", - "updatedAt" = NEW."updatedAt", - "deletedAt" = NEW."deletedAt", - "acceptedAt" = NEW."acceptedAt", - "acceptDataSharing" = NEW."acceptDataSharing", - "rolesUpdatedAt" = NEW."rolesUpdatedAt", - "extId" = NEW."extId", - "initialPassword" = public.encrypt_text(NEW."initialPassword"), - "emailVerified" = NEW."emailVerified", - "emailVerificationToken" = NEW."emailVerificationToken", - "resetToken" = NEW."resetToken", - "lastPasswordResetEmailSent" = NEW."lastPasswordResetEmailSent", - "lastVerificationEmailSent" = NEW."lastVerificationEmailSent", - "twoFactorOtp" = NEW."twoFactorOtp", - "twoFactorOtpExpiresAt" = NEW."twoFactorOtpExpiresAt", - "twoFactorMethods" = NEW."twoFactorMethods", - "totpSecret" = NEW."totpSecret", - "orcidId" = NEW."orcidId", - "ldapUsername" = NEW."ldapUsername", - "samlNameId" = NEW."samlNameId" - WHERE id = OLD.id; - - RETURN NEW; - END IF; - - IF TG_OP = 'DELETE' THEN - DELETE FROM public.user_secure WHERE id = OLD.id; - RETURN OLD; - END IF; - - RETURN NULL; - END; - $$; - `, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - CREATE TRIGGER user_view_iud_trigger - INSTEAD OF INSERT OR UPDATE OR DELETE ON public."user" - FOR EACH ROW - EXECUTE FUNCTION public.user_view_iud(); - `, - { transaction } - ); - }); - }, - - async down(queryInterface) { - const encryptionKey = process.env.DB_ENCRYPTION_KEY; - if (!encryptionKey) { - throw new Error('DB_ENCRYPTION_KEY must be set before reverting user encryption migration'); - } - - await queryInterface.sequelize.transaction(async (transaction) => { - await queryInterface.sequelize.query( - `SELECT set_config('app.encryption_key', :encryptionKey, false);`, - { - replacements: { encryptionKey }, - transaction, - } - ); - - await queryInterface.sequelize.query( - `DROP TRIGGER IF EXISTS user_view_iud_trigger ON public."user";`, - { transaction } - ); - await queryInterface.sequelize.query( - `DROP FUNCTION IF EXISTS public.user_view_iud();`, - { transaction } - ); - await queryInterface.sequelize.query( - `DROP VIEW IF EXISTS public."user";`, - { transaction } - ); - - await queryInterface.sequelize.query( - ` - ALTER TABLE public.user_secure - ALTER COLUMN "firstName" TYPE text USING public.decrypt_text("firstName"), - ALTER COLUMN "lastName" TYPE text USING public.decrypt_text("lastName"), - ALTER COLUMN email TYPE text USING public.decrypt_text(email), - ALTER COLUMN "initialPassword" TYPE text USING public.decrypt_text("initialPassword"); - `, - { transaction } - ); - - await queryInterface.sequelize.query( - `ALTER TABLE public.user_secure RENAME TO "user";`, - { transaction } - ); - }); - } -}; From 0228f6ccdce04a8874c5caa063dcb9a6fc92f8a1 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:44:37 +0200 Subject: [PATCH 04/30] feat: add encryption functionalities --- backend/utils/encryption.js | 126 ++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 backend/utils/encryption.js diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js new file mode 100644 index 000000000..67cb3919f --- /dev/null +++ b/backend/utils/encryption.js @@ -0,0 +1,126 @@ +/** + * AES-256-GCM encryption utilities for sensitive user fields. + * + * Key source: DB_ENCRYPTION_KEY (or DB_ENCRYPTIAN_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. + */ + +'use strict'; + +const crypto = require('crypto'); + +const ALGORITHM = 'aes-256-gcm'; +const IV_LENGTH = 12; +const AUTH_TAG_LENGTH = 16; +// 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 raw = process.env.DB_ENCRYPTION_KEY || process.env.DB_ENCRYPTIAN_KEY; + if (!raw) { + throw new Error( + 'DB_ENCRYPTION_KEY must be set in .env. ' + + 'Generate one with: openssl rand -base64 32' + ); + } + const key = Buffer.from(raw, 'base64'); + if (key.length !== 32) { + throw new Error( + `DB_ENCRYPTION_KEY must decode to exactly 32 bytes (got ${key.length}). ` + + 'Generate a valid key with: openssl rand -base64 32' + ); + } + 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 + * @returns {string|null} Base64-encoded encrypted string, or null for null/undefined input + */ +function encrypt(plaintext) { + if (plaintext === null || plaintext === undefined) return null; + + const key = getKey(); + const iv = crypto.randomBytes(IV_LENGTH); + const cipher = crypto.createCipheriv(ALGORITHM, key, 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; + } +} + +/** + * Returns true if the value looks like it was produced by encrypt(). + * Useful in migration scripts to skip rows that are already encrypted. + * + * @param {string|null|undefined} value + * @returns {boolean} + */ +function isEncrypted(value) { + if (!value || typeof value !== 'string') return false; + try { + return Buffer.from(value, 'base64').length >= MIN_ENCRYPTED_LENGTH; + } catch { + return false; + } +} + +module.exports = { encrypt, decrypt, isEncrypted }; From fbad854050136c34837f8417d6b491c8a1ac9c57 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:45:14 +0200 Subject: [PATCH 05/30] feat: add hooks for automatic encryption/decryption handling --- backend/db/plugins.js | 87 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/backend/db/plugins.js b/backend/db/plugins.js index 16a170fe6..bdf03c224 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -1,3 +1,88 @@ +const { encrypt, decrypt } = require('../utils/encryption'); + +/** + * 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. + * + * Usage in a model's User.init() options: + * encryptedFields: ['firstName', 'lastName', 'email'] + * + * The plugin automatically injects beforeCreate, beforeUpdate, and afterFind hooks + * that encrypt/decrypt those fields transparently. No per-model hook code needed. + * + * @param {Object} options - The model options passed to Model.init() + */ +function addEncryptionHooks(options) { + const fields = options.encryptedFields; + if (!fields || !Array.isArray(fields) || fields.length === 0) return; + + if (!options.hooks) options.hooks = {}; + + // Encrypt on INSERT + addHook(options.hooks, 'beforeCreate', (instance) => { + for (const field of fields) { + const val = instance[field]; + if (val !== null && val !== undefined) { + instance[field] = encrypt(val); + } + } + }); + + addHook(options.hooks, 'beforeUpsert', (instance) => { + for (const field of fields) { + const val = instance[field]; + if (val !== null && val !== undefined) { + instance[field] = encrypt(val); + } + } + }); + + // Encrypt changed fields on UPDATE + addHook(options.hooks, 'beforeUpdate', (instance) => { + for (const field of fields) { + if (instance.changed(field)) { + const val = instance[field]; + if (val !== null && val !== undefined) { + instance[field] = encrypt(val); + } + } + } + }); + + // 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 field of fields) { + const val = row[field]; + if (val !== null && val !== undefined) { + row[field] = decrypt(val); + } + } + } + }); +} + /** * Plugin to add global change tracking hooks to all models. * @@ -6,6 +91,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); // Add hooks to the model const globalHooks = { From e62f502e95fd24b0b6e05e9b8c5123917156539b Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:45:56 +0200 Subject: [PATCH 06/30] feat: add fields to be encrypted --- backend/db/models/user.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/db/models/user.js b/backend/db/models/user.js index 8677cb114..b4e0df721 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -597,6 +597,8 @@ module.exports = (sequelize, DataTypes) => { sequelize, modelName: "user", tableName: "user", + //Keys that require encryptian + encryptedFields: ['firstName', 'lastName', 'email', 'salt', 'initialPassword', 'twoFactorOtp', 'totpSecret', 'orcidId', 'ldapUsername', 'samlNameId', 'extId'], hooks: { afterCreate: async (user, options) => { const {context, transaction} = options; From 07dc3f2d475db6d312fd2a2b3795bf9776334694 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:46:31 +0200 Subject: [PATCH 07/30] feat: add db encryption key to the env file --- .env | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.env b/.env index 8d31d2f8d..7f1f8c5fc 100644 --- a/.env +++ b/.env @@ -64,3 +64,6 @@ PG_STATS_MIN_AGE_MS=1000 # NOTE: Current SQL has a hard LIMIT 10; change only matters if code is updated # to parameterize the LIMIT. PG_STATS_TOP_N=10 + +# Encryption key for database fields (e.g. user email). Must be 32 bytes (256 bits) when base64-decoded. +DB_ENCRYPTION_KEY=Wvt3REwr+ppVXTbJRKOGcgcBQ0iY0FV2P2Dln9FUcl4= From 7674fa857269e3fed36d15ede9bf9073eeb2e112 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:47:10 +0200 Subject: [PATCH 08/30] feat: add migration to encrypt user data --- .../20260612100001-encrypt-user-fields.js | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 backend/db/migrations/20260612100001-encrypt-user-fields.js 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..428bde0eb --- /dev/null +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -0,0 +1,93 @@ +'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 (or DB_ENCRYPTIAN_KEY) to be set in the environment. + * Skips rows where the field already appears encrypted (safe to re-run). + */ + +const { encrypt, isEncrypted } = require('../../utils/encryption'); + +module.exports = { + async up(queryInterface) { + const encryptionKey = process.env.DB_ENCRYPTION_KEY || process.env.DB_ENCRYPTIAN_KEY; + if (!encryptionKey) { + throw new Error( + 'DB_ENCRYPTION_KEY (or DB_ENCRYPTIAN_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" FROM "user"`, + { type: queryInterface.sequelize.QueryTypes.SELECT, transaction } + ); + + for (const user of users) { + const updates = {}; + + if (user.firstName && !isEncrypted(user.firstName)) { + updates.firstName = encrypt(user.firstName); + } + if (user.lastName && !isEncrypted(user.lastName)) { + updates.lastName = encrypt(user.lastName); + } + if (user.email && !isEncrypted(user.email)) { + const encryptedEmail = encrypt(user.email); + updates.email = encryptedEmail; + } + if (user.initialPassword && !isEncrypted(user.initialPassword)) { + updates.initialPassword = encrypt(user.initialPassword); + } + if (user.twoFactorOtp && !isEncrypted(user.twoFactorOtp)) { + updates.twoFactorOtp = encrypt(user.twoFactorOtp); + } + if (user.totpSecret && !isEncrypted(user.totpSecret)) { + updates.totpSecret = encrypt(user.totpSecret); + } + if (user.orcidId && !isEncrypted(user.orcidId)) { + updates.orcidId = encrypt(user.orcidId); + } + if (user.ldapUsername && !isEncrypted(user.ldapUsername)) { + updates.ldapUsername = encrypt(user.ldapUsername); + } + if (user.samlNameId && !isEncrypted(user.samlNameId)) { + updates.samlNameId = encrypt(user.samlNameId); + } + + 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) { + // Reversing this migration would require decrypting all rows. + // Use the DB_ENCRYPTION_KEY to manually decrypt if needed. + // This down() is intentionally a no-op to avoid accidental data loss. + console.warn( + '[down] 20260612100001-encrypt-user-fields: ' + + 'This migration cannot be automatically reversed. ' + + 'Decrypt rows manually using the encryption key if needed.' + ); + }, +}; From 168a0c910c09876c68d225b220762db92f5b8670 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 12 Jun 2026 22:49:24 +0200 Subject: [PATCH 09/30] feat: removing extra role button --- frontend/src/components/dashboard/Users.vue | 7 ------- 1 file changed, 7 deletions(-) diff --git a/frontend/src/components/dashboard/Users.vue b/frontend/src/components/dashboard/Users.vue index 3aa906253..7e0460e03 100644 --- a/frontend/src/components/dashboard/Users.vue +++ b/frontend/src/components/dashboard/Users.vue @@ -33,13 +33,6 @@ /> - Date: Fri, 12 Jun 2026 23:17:02 +0200 Subject: [PATCH 10/30] fix: update salt with encryption key --- .../20260612100001-encrypt-user-fields.js | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js index 428bde0eb..6e469bf6f 100644 --- a/backend/db/migrations/20260612100001-encrypt-user-fields.js +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -22,41 +22,44 @@ module.exports = { const transaction = await queryInterface.sequelize.transaction(); try { const users = await queryInterface.sequelize.query( - `SELECT id, "firstName", "lastName", email, "initialPassword", "twoFactorOtp", "totpSecret", "orcidId", "ldapUsername", "samlNameId" FROM "user"`, + `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 && !isEncrypted(user.firstName)) { + if (user.firstName) { updates.firstName = encrypt(user.firstName); } - if (user.lastName && !isEncrypted(user.lastName)) { + if (user.lastName) { updates.lastName = encrypt(user.lastName); } - if (user.email && !isEncrypted(user.email)) { + if (user.email) { const encryptedEmail = encrypt(user.email); updates.email = encryptedEmail; } - if (user.initialPassword && !isEncrypted(user.initialPassword)) { + if (user.initialPassword) { updates.initialPassword = encrypt(user.initialPassword); } - if (user.twoFactorOtp && !isEncrypted(user.twoFactorOtp)) { + if (user.twoFactorOtp) { updates.twoFactorOtp = encrypt(user.twoFactorOtp); } - if (user.totpSecret && !isEncrypted(user.totpSecret)) { + if (user.totpSecret) { updates.totpSecret = encrypt(user.totpSecret); } - if (user.orcidId && !isEncrypted(user.orcidId)) { + if (user.orcidId) { updates.orcidId = encrypt(user.orcidId); } - if (user.ldapUsername && !isEncrypted(user.ldapUsername)) { + if (user.ldapUsername) { updates.ldapUsername = encrypt(user.ldapUsername); } - if (user.samlNameId && !isEncrypted(user.samlNameId)) { + 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) From a261817b203c1f90806daf8b1473d49d685b0388 Mon Sep 17 00:00:00 2001 From: karimouf Date: Sat, 13 Jun 2026 00:38:24 +0200 Subject: [PATCH 11/30] feat: add before bulk create hook --- backend/db/plugins.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/backend/db/plugins.js b/backend/db/plugins.js index aa182a7d0..dde5a497a 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -67,6 +67,19 @@ function addEncryptionHooks(options) { } }); + // Encrypt on bulk INSERT + addHook(options.hooks, 'beforeBulkCreate', (options) => { + const records = options.records || options.instances || []; + for (const instance of records) { + for (const field of fields) { + const val = instance[field]; + if (val !== null && val !== undefined) { + instance[field] = encrypt(val); + } + } + } + }); + // Decrypt on every read (single instance or array) addHook(options.hooks, 'afterFind', (result) => { if (!result) return; From 95c46ac75029988e19c889c340f0bc32ae113a89 Mon Sep 17 00:00:00 2001 From: karimouf Date: Sat, 13 Jun 2026 00:38:44 +0200 Subject: [PATCH 12/30] fix: remove wrong naming --- backend/db/migrations/20260612100001-encrypt-user-fields.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js index 6e469bf6f..05f1fa35e 100644 --- a/backend/db/migrations/20260612100001-encrypt-user-fields.js +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -4,7 +4,7 @@ * Encrypt existing plaintext values in user fields: firstName, lastName, email, initialPassword. * Also populates emailHash from the newly encrypted email value. * - * Requires DB_ENCRYPTION_KEY (or DB_ENCRYPTIAN_KEY) to be set in the environment. + * Requires DB_ENCRYPTION_KEY to be set in the environment. * Skips rows where the field already appears encrypted (safe to re-run). */ @@ -12,10 +12,10 @@ const { encrypt, isEncrypted } = require('../../utils/encryption'); module.exports = { async up(queryInterface) { - const encryptionKey = process.env.DB_ENCRYPTION_KEY || process.env.DB_ENCRYPTIAN_KEY; + const encryptionKey = process.env.DB_ENCRYPTION_KEY; if (!encryptionKey) { throw new Error( - 'DB_ENCRYPTION_KEY (or DB_ENCRYPTIAN_KEY) must be set before running the user encryption data migration' + 'DB_ENCRYPTION_KEY must be set before running the user encryption data migration' ); } From b119eadb9a3deb8033607061e394f5a0ce71cc9b Mon Sep 17 00:00:00 2001 From: karimouf Date: Sat, 13 Jun 2026 00:39:30 +0200 Subject: [PATCH 13/30] fix: remove wrong naming --- backend/utils/encryption.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index 67cb3919f..e34231a47 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -1,7 +1,7 @@ /** * AES-256-GCM encryption utilities for sensitive user fields. * - * Key source: DB_ENCRYPTION_KEY (or DB_ENCRYPTIAN_KEY) in .env + * Key source: DB_ENCRYPTION_KEY in .env * Must be a Base64-encoded 32-byte value. * Generate with: openssl rand -base64 32 * @@ -27,7 +27,7 @@ const MIN_ENCRYPTED_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH + 1; * @returns {Buffer} 32-byte key */ function getKey() { - const raw = process.env.DB_ENCRYPTION_KEY || process.env.DB_ENCRYPTIAN_KEY; + const raw = process.env.DB_ENCRYPTION_KEY ; if (!raw) { throw new Error( 'DB_ENCRYPTION_KEY must be set in .env. ' + From bc4902dfb0332520483768fb5bc6cbe6e9423443 Mon Sep 17 00:00:00 2001 From: karimouf Date: Wed, 17 Jun 2026 14:13:21 +0200 Subject: [PATCH 14/30] feat: backup command --- .gitignore | 2 ++ Makefile | 14 ++++++++++++++ docker-compose.yml | 11 ++++++----- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 2d9daf0c0..6c01debd4 100644 --- a/.gitignore +++ b/.gitignore @@ -8,12 +8,14 @@ files/*.zip # database dumps db_dumps/ +backups/ # backend backend/sessions backend/node_modules backend/logs backend/coverage +backend/encryption.key # ignore build files dist/ diff --git a/Makefile b/Makefile index 3eb860d5d..dffa2b72c 100644 --- a/Makefile +++ b/Makefile @@ -28,6 +28,7 @@ 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 CONTAINER= Create full backup (DB dump + .env + encryptionkey + files)" @echo "make backup_db CONTAINER= Backup the database in the given container" @echo "make recover_db CONTAINER= DUMP= Recover database into container" @echo "make anonymize_dump CONTAINER= DUMP= [SEED=] [NUM=] Create anonymized dump (consent-filtered + pseudonymized)" @@ -152,6 +153,19 @@ else @cat "db_dumps/$${DUMP}" | docker exec -i $${CONTAINER} psql -U postgres endif +.PHONY: backup +backup: backup_db + @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: 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: From 67d725107a29f76f4a77d274ffc1cdd5bbba04b7 Mon Sep 17 00:00:00 2001 From: karimouf Date: Wed, 17 Jun 2026 14:14:30 +0200 Subject: [PATCH 15/30] feat: initialize new encryption key each server restart --- .../20260612100001-encrypt-user-fields.js | 5 +- backend/utils/encryption.js | 62 ++++++++++++------- backend/webserver/Server.js | 3 +- 3 files changed, 44 insertions(+), 26 deletions(-) diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js index 05f1fa35e..847f29ff7 100644 --- a/backend/db/migrations/20260612100001-encrypt-user-fields.js +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -8,11 +8,12 @@ * Skips rows where the field already appears encrypted (safe to re-run). */ -const { encrypt, isEncrypted } = require('../../utils/encryption'); +const { encrypt, getKey, initializeEncryptionKey } = require('../../utils/encryption'); module.exports = { async up(queryInterface) { - const encryptionKey = process.env.DB_ENCRYPTION_KEY; + initializeEncryptionKey(); + const encryptionKey = getKey(); if (!encryptionKey) { throw new Error( 'DB_ENCRYPTION_KEY must be set before running the user encryption data migration' diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index e34231a47..3bf7c3aad 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -15,10 +15,14 @@ '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"); // Minimum byte length of a valid encrypted buffer: IV + authTag + 1 byte ciphertext const MIN_ENCRYPTED_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH + 1; @@ -27,23 +31,31 @@ const MIN_ENCRYPTED_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH + 1; * @returns {Buffer} 32-byte key */ function getKey() { - const raw = process.env.DB_ENCRYPTION_KEY ; - if (!raw) { + const keyFilePath = KEY_FILE; + + if (!fs.existsSync(keyFilePath)) { throw new Error( - 'DB_ENCRYPTION_KEY must be set in .env. ' + - 'Generate one with: openssl rand -base64 32' + `Encryption key file not found: ${keyFilePath}. ` + + "Start the server once to generate it." ); } - const key = Buffer.from(raw, 'base64'); + + 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( - `DB_ENCRYPTION_KEY must decode to exactly 32 bytes (got ${key.length}). ` + - 'Generate a valid key with: openssl rand -base64 32' + `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. @@ -107,20 +119,24 @@ function decrypt(value) { } } -/** - * Returns true if the value looks like it was produced by encrypt(). - * Useful in migration scripts to skip rows that are already encrypted. - * - * @param {string|null|undefined} value - * @returns {boolean} - */ -function isEncrypted(value) { - if (!value || typeof value !== 'string') return false; - try { - return Buffer.from(value, 'base64').length >= MIN_ENCRYPTED_LENGTH; - } catch { - return false; - } +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; } -module.exports = { encrypt, decrypt, isEncrypted }; +module.exports = { encrypt, decrypt, initializeEncryptionKey, getKey }; diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index a9f962bab..90fa327fd 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/generic"); +const { initializeEncryptionKey } = require("../utils/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 { From b2d778bd937f50f23eb55432cbc09e5a2841d02c Mon Sep 17 00:00:00 2001 From: karimouf Date: Thu, 18 Jun 2026 12:23:03 +0200 Subject: [PATCH 16/30] fix: add a down method for the encrypted fields --- .../20260612100001-encrypt-user-fields.js | 80 ++++++++++++++++--- 1 file changed, 71 insertions(+), 9 deletions(-) diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js index 847f29ff7..f0b4a1b96 100644 --- a/backend/db/migrations/20260612100001-encrypt-user-fields.js +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -8,7 +8,7 @@ * Skips rows where the field already appears encrypted (safe to re-run). */ -const { encrypt, getKey, initializeEncryptionKey } = require('../../utils/encryption'); +const { encrypt, getKey, initializeEncryptionKey, decrypt } = require('../../utils/encryption'); module.exports = { async up(queryInterface) { @@ -85,13 +85,75 @@ module.exports = { }, async down(queryInterface) { - // Reversing this migration would require decrypting all rows. - // Use the DB_ENCRYPTION_KEY to manually decrypt if needed. - // This down() is intentionally a no-op to avoid accidental data loss. - console.warn( - '[down] 20260612100001-encrypt-user-fields: ' + - 'This migration cannot be automatically reversed. ' + - 'Decrypt rows manually using the encryption key if needed.' - ); +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; + } }, }; From 770bc09cbc2c193d94a0dc06a2795017126e30c4 Mon Sep 17 00:00:00 2001 From: karimouf Date: Thu, 18 Jun 2026 13:10:54 +0200 Subject: [PATCH 17/30] fix --- backend/db/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/db/index.js b/backend/db/index.js index 53d8463b2..444219742 100644 --- a/backend/db/index.js +++ b/backend/db/index.js @@ -1,7 +1,7 @@ /** * Declare all necessary dependencies to work with the database models * - * @author Nils Dycke, Dennis Zyska, Junaid Feroz + * @author Nils Dycke, Dennis Zyska */ 'use strict'; From 12d5eb22c0b70c1f2b6c8afa761647db5eaf4091 Mon Sep 17 00:00:00 2001 From: karimouf Date: Thu, 18 Jun 2026 13:13:29 +0200 Subject: [PATCH 18/30] fix: remove encrption key from env file --- .env | 3 --- backend/utils/encryption.js | 2 ++ 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.env b/.env index 7f1f8c5fc..8d31d2f8d 100644 --- a/.env +++ b/.env @@ -64,6 +64,3 @@ PG_STATS_MIN_AGE_MS=1000 # NOTE: Current SQL has a hard LIMIT 10; change only matters if code is updated # to parameterize the LIMIT. PG_STATS_TOP_N=10 - -# Encryption key for database fields (e.g. user email). Must be 32 bytes (256 bits) when base64-decoded. -DB_ENCRYPTION_KEY=Wvt3REwr+ppVXTbJRKOGcgcBQ0iY0FV2P2Dln9FUcl4= diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index 3bf7c3aad..eddd872b2 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -10,8 +10,10 @@ * * 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'); From 8355e11dfd2d312105b754e53f6ad3382a760322 Mon Sep 17 00:00:00 2001 From: karimouf Date: Thu, 18 Jun 2026 23:46:13 +0200 Subject: [PATCH 19/30] feat: add enable encryption env variable to enable/disable encryption --- .env | 3 +++ .../migrations/20260612100001-encrypt-user-fields.js | 10 +++++++++- backend/db/plugins.js | 2 ++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/.env b/.env index 8d31d2f8d..3b8fefb1f 100644 --- a/.env +++ b/.env @@ -64,3 +64,6 @@ PG_STATS_MIN_AGE_MS=1000 # NOTE: Current SQL has a hard LIMIT 10; change only matters if code is updated # to parameterize the LIMIT. PG_STATS_TOP_N=10 + +#enable or disable encryption +ENCRYPTION_ENABLED=false diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js index f0b4a1b96..ec878e502 100644 --- a/backend/db/migrations/20260612100001-encrypt-user-fields.js +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -12,6 +12,10 @@ const { encrypt, getKey, initializeEncryptionKey, decrypt } = require('../../uti module.exports = { async up(queryInterface) { + const isEncryptionEnabled = process.env.ENCRYPTION_ENABLED === 'true'; + if (!isEncryptionEnabled) { + return; + } initializeEncryptionKey(); const encryptionKey = getKey(); if (!encryptionKey) { @@ -85,7 +89,11 @@ module.exports = { }, async down(queryInterface) { -initializeEncryptionKey(); + const isEncryptionEnabled = process.env.ENCRYPTION_ENABLED === 'true'; + if (!isEncryptionEnabled) { + return; + } + initializeEncryptionKey(); const encryptionKey = getKey(); if (!encryptionKey) { throw new Error( diff --git a/backend/db/plugins.js b/backend/db/plugins.js index dde5a497a..b5a4fd373 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -31,6 +31,8 @@ function addHook(hooks, hookName, fn) { * @param {Object} options - The model options passed to Model.init() */ function addEncryptionHooks(options) { + const isEncryptionEnabled = process.env.ENCRYPTION_ENABLED === 'true'; + if (!isEncryptionEnabled) return; // skip adding hooks if encryption is disabled const fields = options.encryptedFields; if (!fields || !Array.isArray(fields) || fields.length === 0) return; From 444aadc857cef090265c5c3aff105e2a1d8eefbb Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 23 Jun 2026 00:42:11 +0200 Subject: [PATCH 20/30] feat: add script to change encryption key --- Makefile | 6 +++ backend/package.json | 3 +- backend/scripts/changeEncryptionKey.js | 60 ++++++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 backend/scripts/changeEncryptionKey.js diff --git a/Makefile b/Makefile index dffa2b72c..4296d364b 100644 --- a/Makefile +++ b/Makefile @@ -38,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, utils/modules/editor-delta-conversion" + @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 @@ -228,6 +229,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/package.json b/backend/package.json index f865247a5..d4519afe2 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,7 +16,8 @@ "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" }, "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..bc0fa6b7c --- /dev/null +++ b/backend/scripts/changeEncryptionKey.js @@ -0,0 +1,60 @@ +'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 rotate-key NEW_KEY=<64-char hex> + * + * Or directly: + * cd backend && NEW_KEY= node scripts/rotateEncryptionKey.js + * + * On success the new key is written to backend/encryption.key. + */ + +const fs = require('fs'); +const path = require('path'); +const { getKey, reEncryptAllModels } = require('../utils/encryption'); +const db = require('../db'); + +const KEY_FILE = path.resolve(__dirname, '../encryption.key'); + +async function main() { + if (!process.env.NEW_KEY) { + console.error('ERROR: NEW_KEY is not set.'); + process.exit(1); + } + + if (db?.sequelize?.options) db.sequelize.options.logging = false; + + const oldKey = getKey(); // reads and validates backend/encryption.key + const newKey = Buffer.from(process.env.NEW_KEY, '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, process.env.NEW_KEY, { 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); +}); From 82504383c4dcd37fb0ab7d754fdb4cef7ab871d0 Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 23 Jun 2026 00:43:15 +0200 Subject: [PATCH 21/30] feat: change db encryption state when the .env encryption state is changed --- .gitignore | 1 + backend/utils/encryption.js | 172 +++++++++++++++++++++++++++++++++++- backend/webserver/Server.js | 5 +- 3 files changed, 172 insertions(+), 6 deletions(-) diff --git a/.gitignore b/.gitignore index 6c01debd4..537b6a52e 100644 --- a/.gitignore +++ b/.gitignore @@ -16,6 +16,7 @@ backend/node_modules backend/logs backend/coverage backend/encryption.key +backend/encryption.state # ignore build files dist/ diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index eddd872b2..8b74889db 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -25,6 +25,7 @@ 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; @@ -63,14 +64,15 @@ function getKey() { * 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) { +function encrypt(plaintext, key) { if (plaintext === null || plaintext === undefined) return null; - const key = getKey(); + const resolvedKey = key ? parseKey(key) : getKey(); const iv = crypto.randomBytes(IV_LENGTH); - const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: AUTH_TAG_LENGTH }); + const cipher = crypto.createCipheriv(ALGORITHM, resolvedKey, iv, { authTagLength: AUTH_TAG_LENGTH }); const ciphertext = Buffer.concat([ cipher.update(String(plaintext), 'utf8'), @@ -141,4 +143,166 @@ function initializeEncryptionKey() { return key; } -module.exports = { encrypt, decrypt, initializeEncryptionKey, getKey }; +/** + * 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.'); +} + +/** + * 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(); + try { + const rows = await sequelize.query( + `SELECT "${pk}", ${fields.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 fields) { + 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 }); +} + +module.exports = { encrypt, decrypt, initializeEncryptionKey, getKey, reEncryptValue, reEncryptAllModels, decryptAllModels, encryptAllModels, syncEncryptionState }; diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 90fa327fd..0e57f3795 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -20,7 +20,7 @@ const nodemailer = require('nodemailer'); const { setupDevAdmin } = require('./utils/devAdmin'); const { initializeAuth } = require("./auth"); const { parseUserAgent } = require("../utils/generic"); -const { initializeEncryptionKey } = require("../utils/encryption"); +const { initializeEncryptionKey, syncEncryptionState } = require("../utils/encryption"); /** * Defines Express Webserver of Content Server @@ -479,8 +479,9 @@ module.exports = class Server { * Start the webserver * @param port */ - start(port) { + async start(port) { this.logger.debug("Start Webserver..."); + await syncEncryptionState(this.db); this.http = this.httpServer.listen(port, () => { this.logger.info("Server started on port " + port); }); From b4e5a7e5f447c873479eb7db86290008856e75da Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 23 Jun 2026 14:42:04 +0200 Subject: [PATCH 22/30] feat: generate new key if no new key is provided --- backend/db/models/user.js | 2 +- backend/scripts/changeEncryptionKey.js | 28 +++++++++++++++++++------- backend/utils/encryption.js | 10 ++++++++- 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/backend/db/models/user.js b/backend/db/models/user.js index b4e0df721..d7dc53777 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -598,7 +598,7 @@ module.exports = (sequelize, DataTypes) => { modelName: "user", tableName: "user", //Keys that require encryptian - encryptedFields: ['firstName', 'lastName', 'email', 'salt', 'initialPassword', 'twoFactorOtp', 'totpSecret', 'orcidId', 'ldapUsername', 'samlNameId', 'extId'], + encryptedFields: ['firstName', 'lastName', 'email', 'salt', 'initialPassword', 'twoFactorOtp', 'totpSecret', 'orcidId', 'ldapUsername', 'samlNameId'], hooks: { afterCreate: async (user, options) => { const {context, transaction} = options; diff --git a/backend/scripts/changeEncryptionKey.js b/backend/scripts/changeEncryptionKey.js index bc0fa6b7c..5f0754d58 100644 --- a/backend/scripts/changeEncryptionKey.js +++ b/backend/scripts/changeEncryptionKey.js @@ -15,21 +15,35 @@ const fs = require('fs'); const path = require('path'); -const { getKey, reEncryptAllModels } = require('../utils/encryption'); +const readline = require('readline'); +const { getKey, generateEncryptionKey, reEncryptAllModels } = require('../utils/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() { - if (!process.env.NEW_KEY) { - console.error('ERROR: NEW_KEY is not set.'); - process.exit(1); + 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(); // reads and validates backend/encryption.key - const newKey = Buffer.from(process.env.NEW_KEY, 'hex'); + 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}).`); @@ -48,7 +62,7 @@ async function main() { console.log(` [${model}] re-encrypted ${updated}/${total} row(s)`); } - fs.writeFileSync(KEY_FILE, process.env.NEW_KEY, { encoding: 'utf8', mode: 0o600 }); + 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(); diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index 8b74889db..8467dccc5 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -305,4 +305,12 @@ async function syncEncryptionState(db) { fs.writeFileSync(STATE_FILE, String(isEnabled), { encoding: 'utf8', mode: 0o600 }); } -module.exports = { encrypt, decrypt, initializeEncryptionKey, getKey, reEncryptValue, reEncryptAllModels, decryptAllModels, encryptAllModels, syncEncryptionState }; +/** + * 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'); +} + +module.exports = { encrypt, decrypt, initializeEncryptionKey, getKey, generateEncryptionKey, reEncryptValue, reEncryptAllModels, decryptAllModels, encryptAllModels, syncEncryptionState }; From 8cf9586144093edf6375db180736ff9ae7d28a72 Mon Sep 17 00:00:00 2001 From: karimouf Date: Tue, 23 Jun 2026 16:10:47 +0200 Subject: [PATCH 23/30] feat: enable decrypting before back up --- Makefile | 29 +++++++++++++++---- backend/package.json | 4 ++- backend/scripts/toggleEncryption.js | 44 +++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 7 deletions(-) create mode 100644 backend/scripts/toggleEncryption.js diff --git a/Makefile b/Makefile index 4296d364b..e4db0f918 100644 --- a/Makefile +++ b/Makefile @@ -29,7 +29,7 @@ help: @echo "make build-clean Clean the environment of production build" @echo "make docker Start docker images" @echo "make backup CONTAINER= Create full backup (DB dump + .env + encryptionkey + files)" - @echo "make backup_db CONTAINER= Backup the database in the given container" + @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" @@ -138,10 +138,24 @@ 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; \ + if [ "$$DECRYPT_ANSWER" = "y" ] || [ "$$DECRYPT_ANSWER" = "Y" ]; then \ + echo "[backup] Decrypting DB before dump..."; \ + (cd backend && npm run --silent decrypt-db); \ + fi; \ + docker exec -t $(CONTAINER) pg_dumpall -c -U postgres > db_dumps/dump_$$(date +%d-%m-%Y_%H_%M_%S).sql; \ + if [ "$$DECRYPT_ANSWER" = "y" ] || [ "$$DECRYPT_ANSWER" = "Y" ]; then \ + echo "[backup] Re-encrypting DB after dump..."; \ + (cd backend && npm run --silent encrypt-db); \ + echo "[backup] Done - dump is plaintext"; \ + fi .PHONY: recover_db recover_db: @@ -156,8 +170,11 @@ 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 + @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" \ diff --git a/backend/package.json b/backend/package.json index d4519afe2..0b9b2c3f3 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,7 +17,9 @@ "pretest": "cross-env NODE_ENV=test npm run db_migrate:reset", "set-admin-password": "node scripts/setAdminPassword.js", "anonymize": "node scripts/anonymize.js", - "change-encryption-key": "node scripts/changeEncryptionKey.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/toggleEncryption.js b/backend/scripts/toggleEncryption.js new file mode 100644 index 000000000..bd340611d --- /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/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); +}); From f693c3ecc2383125856b0af88bc80f87e6fc585e Mon Sep 17 00:00:00 2001 From: karimouf Date: Sun, 28 Jun 2026 23:05:19 +0200 Subject: [PATCH 24/30] refactor: db is cloned and then decrypted --- Makefile | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index e4db0f918..b0f162f4a 100644 --- a/Makefile +++ b/Makefile @@ -146,15 +146,19 @@ endif 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 \ - echo "[backup] Decrypting DB before dump..."; \ - (cd backend && npm run --silent decrypt-db); \ - fi; \ - docker exec -t $(CONTAINER) pg_dumpall -c -U postgres > db_dumps/dump_$$(date +%d-%m-%Y_%H_%M_%S).sql; \ - if [ "$$DECRYPT_ANSWER" = "y" ] || [ "$$DECRYPT_ANSWER" = "Y" ]; then \ - echo "[backup] Re-encrypting DB after dump..."; \ - (cd backend && npm run --silent encrypt-db); \ - echo "[backup] Done - dump is plaintext"; \ + 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 From 102913363e191d8ff74063b77a2058bdf3fd7289 Mon Sep 17 00:00:00 2001 From: karimouf Date: Wed, 1 Jul 2026 17:05:02 +0200 Subject: [PATCH 25/30] fix: fix function docstring --- backend/scripts/changeEncryptionKey.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/scripts/changeEncryptionKey.js b/backend/scripts/changeEncryptionKey.js index 5f0754d58..87f5004c5 100644 --- a/backend/scripts/changeEncryptionKey.js +++ b/backend/scripts/changeEncryptionKey.js @@ -5,10 +5,10 @@ * The current key is read from backend/encryption.key. * * Usage (via Makefile): - * make rotate-key NEW_KEY=<64-char hex> + * make change_encryption_key NEW_KEY=<64-char hex> * * Or directly: - * cd backend && NEW_KEY= node scripts/rotateEncryptionKey.js + * cd backend && NEW_KEY= node scripts/changeEncryptionKey.js * * On success the new key is written to backend/encryption.key. */ From bc1772c3e9397eb16d7e2dc28211eb451e6186fd Mon Sep 17 00:00:00 2001 From: karimouf Date: Wed, 1 Jul 2026 17:06:18 +0200 Subject: [PATCH 26/30] feat: unique fields have an addition column in order to preserve the unique attribute that is served by the db --- backend/db/models/user.js | 4 +- backend/db/plugins.js | 91 +++++++++++++++++++------------------ backend/utils/encryption.js | 80 ++++++++++++++++++++++++++++++-- backend/webserver/Server.js | 3 +- 4 files changed, 127 insertions(+), 51 deletions(-) diff --git a/backend/db/models/user.js b/backend/db/models/user.js index d7dc53777..25a758001 100644 --- a/backend/db/models/user.js +++ b/backend/db/models/user.js @@ -597,8 +597,8 @@ module.exports = (sequelize, DataTypes) => { sequelize, modelName: "user", tableName: "user", - //Keys that require encryptian - encryptedFields: ['firstName', 'lastName', 'email', 'salt', 'initialPassword', 'twoFactorOtp', 'totpSecret', 'orcidId', 'ldapUsername', 'samlNameId'], + //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 b5a4fd373..e06cbdc5d 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -1,4 +1,5 @@ -const { encrypt, decrypt } = require('../utils/encryption'); +const { DataTypes } = require('sequelize'); +const { encrypt, decrypt, hashForUnique } = require('../utils/encryption'); /** * Merge a new hook function into a model's hooks options object. @@ -22,63 +23,65 @@ function addHook(hooks, hookName, fn) { /** * Plugin to add generic field-level encryption to any model that declares encryptedFields. * - * Usage in a model's User.init() options: - * encryptedFields: ['firstName', 'lastName', 'email'] + * Each entry in encryptedFields can be a plain string or an object: + * encryptedFields: ['firstName', { name: 'email', unique: true }] * - * The plugin automatically injects beforeCreate, beforeUpdate, and afterFind hooks - * that encrypt/decrypt those fields transparently. No per-model hook code needed. + * 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} 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) { - const isEncryptionEnabled = process.env.ENCRYPTION_ENABLED === 'true'; - if (!isEncryptionEnabled) return; // skip adding hooks if encryption is disabled - const fields = options.encryptedFields; - if (!fields || !Array.isArray(fields) || fields.length === 0) return; +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 field of fields) { - const val = instance[field]; - if (val !== null && val !== undefined) { - instance[field] = encrypt(val); - } - } + for (const name of fieldNames) encryptField(instance, name); }); addHook(options.hooks, 'beforeUpsert', (instance) => { - for (const field of fields) { - const val = instance[field]; - if (val !== null && val !== undefined) { - instance[field] = encrypt(val); - } - } + for (const name of fieldNames) encryptField(instance, name); }); // Encrypt changed fields on UPDATE addHook(options.hooks, 'beforeUpdate', (instance) => { - for (const field of fields) { - if (instance.changed(field)) { - const val = instance[field]; - if (val !== null && val !== undefined) { - instance[field] = encrypt(val); - } - } + for (const name of fieldNames) { + if (instance.changed(name)) encryptField(instance, name); } }); // Encrypt on bulk INSERT - addHook(options.hooks, 'beforeBulkCreate', (options) => { - const records = options.records || options.instances || []; + addHook(options.hooks, 'beforeBulkCreate', (opts) => { + const records = opts.records || opts.instances || []; for (const instance of records) { - for (const field of fields) { - const val = instance[field]; - if (val !== null && val !== undefined) { - instance[field] = encrypt(val); - } - } + for (const name of fieldNames) encryptField(instance, name); } }); @@ -88,11 +91,9 @@ function addEncryptionHooks(options) { const rows = Array.isArray(result) ? result : [result]; for (const row of rows) { if (!row || typeof row !== 'object') continue; - for (const field of fields) { - const val = row[field]; - if (val !== null && val !== undefined) { - row[field] = decrypt(val); - } + for (const name of fieldNames) { + const val = row[name]; + if (val != null) row[name] = decrypt(val); } } }); @@ -107,7 +108,7 @@ function GlobalChangeTrackingPlugin(sequelize) { // Register global hooks for all models sequelize.addHook('beforeDefine', (attributes, options) => { // Inject encryption hooks for models that declare encryptedFields - addEncryptionHooks(options); + addEncryptionHooks(options, attributes); // Add hooks to the model const globalHooks = { @@ -183,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/utils/encryption.js b/backend/utils/encryption.js index 8467dccc5..2687e324d 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -161,6 +161,16 @@ function parseKey(keyInput) { 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. @@ -205,16 +215,17 @@ async function _applyToAllModels(db, transformFn) { 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}", ${fields.map(f => `"${f}"`).join(', ')} FROM "${tableName}"`, + `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 fields) { + for (const field of fieldNames) { if (row[field] != null) { updates[field] = transformFn(row[field]); } @@ -313,4 +324,67 @@ function generateEncryptionKey() { return crypto.randomBytes(32).toString('hex'); } -module.exports = { encrypt, decrypt, initializeEncryptionKey, getKey, generateEncryptionKey, reEncryptValue, reEncryptAllModels, decryptAllModels, encryptAllModels, syncEncryptionState }; +/** + * 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 uniqueFields = (Model.options?.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: `${tableName}_${hashField}_unique`, + }); + } catch { /* constraint already exists */ } + } + } + } +} + +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 5dea11dc1..cbfc36d92 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -20,7 +20,7 @@ const nodemailer = require('nodemailer'); const { setupDevAdmin } = require('./utils/devAdmin'); const { initializeAuth } = require("./auth"); const { parseUserAgent } = require("../utils/generic"); -const { initializeEncryptionKey, syncEncryptionState } = require("../utils/encryption"); +const { initializeEncryptionKey, syncEncryptionState, syncHashColumns } = require("../utils/encryption"); /** * Defines Express Webserver of Content Server @@ -482,6 +482,7 @@ module.exports = class Server { 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); }); From ea05cc7b010e9f58942409464cbd72c16aa3bcb3 Mon Sep 17 00:00:00 2001 From: karimouf Date: Fri, 10 Jul 2026 21:32:24 +0200 Subject: [PATCH 27/30] feat: add error message handling to handle unique constraint --- backend/db/MetaModel.js | 5 +++++ backend/utils/encryption.js | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) 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/utils/encryption.js b/backend/utils/encryption.js index 2687e324d..5221ff622 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -379,9 +379,9 @@ async function syncHashColumns(db) { await qi.addConstraint(tableName, { fields: [hashField], type: 'unique', - name: `${tableName}_${hashField}_unique`, + name: "SequelizeUniqueConstraintError", }); - } catch { /* constraint already exists */ } + } catch {} } } } From 199ec00fcffd10fcda71aff6aa623fb0f98ca5a9 Mon Sep 17 00:00:00 2001 From: karimouf Date: Sat, 11 Jul 2026 13:05:03 +0200 Subject: [PATCH 28/30] fix: add guard for mapping empty arrays --- backend/utils/encryption.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/utils/encryption.js b/backend/utils/encryption.js index 5221ff622..274490a7e 100644 --- a/backend/utils/encryption.js +++ b/backend/utils/encryption.js @@ -338,9 +338,9 @@ async function syncHashColumns(db) { const qi = sequelize.getQueryInterface(); for (const [, Model] of Object.entries(models)) { - const uniqueFields = (Model.options?.encryptedFields || []) - .filter(f => typeof f !== 'string' && f.unique) - .map(f => f.name); + 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; From 73f5397bf4c0326c8ba8f92bbc871ed9676d8a7a Mon Sep 17 00:00:00 2001 From: karimouf Date: Sat, 11 Jul 2026 13:59:45 +0200 Subject: [PATCH 29/30] fic: add warning when changing encryption env variable --- .env | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.env b/.env index d06da6a0e..47b120fb7 100644 --- a/.env +++ b/.env @@ -65,7 +65,10 @@ PG_STATS_MIN_AGE_MS=1000 # to parameterize the LIMIT. PG_STATS_TOP_N=10 -#enable or disable encryption -ENCRYPTION_ENABLED=false +# 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 From faf1de9a1d8579a033fc67b0903a45fdc38cf5e9 Mon Sep 17 00:00:00 2001 From: karimouf Date: Wed, 22 Jul 2026 15:35:32 +0300 Subject: [PATCH 30/30] refactor: move encryption in helper folder --- backend/db/migrations/20260612100001-encrypt-user-fields.js | 2 +- backend/db/plugins.js | 2 +- backend/scripts/changeEncryptionKey.js | 2 +- backend/scripts/toggleEncryption.js | 2 +- backend/utils/{ => helper}/encryption.js | 0 backend/webserver/Server.js | 2 +- 6 files changed, 5 insertions(+), 5 deletions(-) rename backend/utils/{ => helper}/encryption.js (100%) diff --git a/backend/db/migrations/20260612100001-encrypt-user-fields.js b/backend/db/migrations/20260612100001-encrypt-user-fields.js index ec878e502..ef083310f 100644 --- a/backend/db/migrations/20260612100001-encrypt-user-fields.js +++ b/backend/db/migrations/20260612100001-encrypt-user-fields.js @@ -8,7 +8,7 @@ * Skips rows where the field already appears encrypted (safe to re-run). */ -const { encrypt, getKey, initializeEncryptionKey, decrypt } = require('../../utils/encryption'); +const { encrypt, getKey, initializeEncryptionKey, decrypt } = require('../../utils/helper/encryption'); module.exports = { async up(queryInterface) { diff --git a/backend/db/plugins.js b/backend/db/plugins.js index e06cbdc5d..ad791f194 100644 --- a/backend/db/plugins.js +++ b/backend/db/plugins.js @@ -1,5 +1,5 @@ const { DataTypes } = require('sequelize'); -const { encrypt, decrypt, hashForUnique } = require('../utils/encryption'); +const { encrypt, decrypt, hashForUnique } = require('../utils/helper/encryption.js'); /** * Merge a new hook function into a model's hooks options object. diff --git a/backend/scripts/changeEncryptionKey.js b/backend/scripts/changeEncryptionKey.js index 87f5004c5..c9060770a 100644 --- a/backend/scripts/changeEncryptionKey.js +++ b/backend/scripts/changeEncryptionKey.js @@ -16,7 +16,7 @@ const fs = require('fs'); const path = require('path'); const readline = require('readline'); -const { getKey, generateEncryptionKey, reEncryptAllModels } = require('../utils/encryption'); +const { getKey, generateEncryptionKey, reEncryptAllModels } = require('../utils/helper/encryption'); const db = require('../db'); const KEY_FILE = path.resolve(__dirname, '../encryption.key'); diff --git a/backend/scripts/toggleEncryption.js b/backend/scripts/toggleEncryption.js index bd340611d..3081dac57 100644 --- a/backend/scripts/toggleEncryption.js +++ b/backend/scripts/toggleEncryption.js @@ -9,7 +9,7 @@ * ENCRYPTION_MODE=encrypt node scripts/toggleEncryption.js */ -const { decryptAllModels, encryptAllModels } = require('../utils/encryption'); +const { decryptAllModels, encryptAllModels } = require('../utils/helper/encryption'); const db = require('../db'); async function main() { diff --git a/backend/utils/encryption.js b/backend/utils/helper/encryption.js similarity index 100% rename from backend/utils/encryption.js rename to backend/utils/helper/encryption.js diff --git a/backend/webserver/Server.js b/backend/webserver/Server.js index 8ffef5533..7e2a40de8 100644 --- a/backend/webserver/Server.js +++ b/backend/webserver/Server.js @@ -20,7 +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/encryption"); +const { initializeEncryptionKey, syncEncryptionState, syncHashColumns } = require("../utils/helper/encryption"); /** * Defines Express Webserver of Content Server