Encryption of sensitive data - #194
Conversation
Add DB-only migration to encrypt user table fields with pgcrypto and wire encryption helpers in db/index.js.
44d84ec to
c49c087
Compare
dennis-zyska
left a comment
There was a problem hiding this comment.
@bingobongomann the code looks good, could you test it locally, if it works, we can merge
Looks good. The User Table of a newly set up server is encrypted. New encryption key was generated during the migration. |
bingobongomann
left a comment
There was a problem hiding this comment.
Implementation looks good, encryption and decryption work well and are correctly updating the DB when the env is changed.
Left one comment about make change_encryption_key to add optional generation of a new key if none is provided
bingobongomann
left a comment
There was a problem hiding this comment.
Looks Good now! Encryption can be updated via command line.
The only thing missing is the decrypted database backup command and then we can merge this
…Lab/CARE into feat-156-pgcrypto_encryption
…unique attribute that is served by the db
bingobongomann
left a comment
There was a problem hiding this comment.
sidecar backup looks good, adding constraints and hooks for BlindIndexes looks good too.
Just need Documentation and some clarification about existing unique fields.
|
this is ready @bingobongomann |
| const { encrypt, getKey, initializeEncryptionKey, decrypt } = require('../../utils/helper/encryption'); | ||
|
|
||
| module.exports = { | ||
| async up(queryInterface) { |
There was a problem hiding this comment.
The up() and down() here have a lot of duplicate code. Can we reuse the code from backend/utils/helper/encryption.js instead? It already has encryptAllModels/decryptAllModels (via _applyToAllModels)
| modelName: "user", | ||
| tableName: "user", | ||
| //Keys that require encryption unique set to false by default | ||
| encryptedFields: ['firstName', 'lastName', { name: 'email', unique: true }, 'salt', 'initialPassword', 'twoFactorOtp', 'totpSecret', 'orcidId', 'ldapUsername', 'samlNameId'], |
There was a problem hiding this comment.
I think we also need to encrypt extId, but it is currently an Integer. Maybe we should change it to String, but we need to make sure that it will still work everywhere, maybe need a parse to int when requesting Moodle (needs testing).
| }); | ||
|
|
||
| // Encrypt on bulk INSERT | ||
| addHook(options.hooks, 'beforeBulkCreate', (opts) => { |
There was a problem hiding this comment.
addEncryptionHooks is not registering beforeBulkUpdate, which is called by Model.update(...), e.g. from User.resetUserPwd() (backend/db/models/user.js:511). It would write the salt in plaintext to the database.

Overview
This PR introduces generic application-layer field-level encryption for sensitive user data using AES-256-GCM.
Changes
Core encryption
encryption.jsutility with AES-256-GCM encrypt/decrypt helpers and key managementGlobalChangeTrackingPlugin— models declareencryptedFieldsand encryption/decryption happens automatically on all writes and readsENCRYPTION_ENABLEDchanges on startup and encrypts or decrypts all affected fields automaticallyEncrypted user fields
The following
usermodel fields are now encrypted at rest:firstName,lastName,email,initialPassword,salttwoFactorOtp,totpSecret,samlNameIdorcidId,ldapUsername,extIdNew user-facing behaviour
ENCRYPTION_ENABLEDin.env— the server applies the change automatically on next startupNew developer API
backend/utils/encryption.jsencrypt(plaintext, key?)— AES-256-GCM encryption with Base64 serialisation; accepts an optional explicit keydecrypt(value)— always uses the stored keyModel-level
encryptedFieldsoption on model definitions for declarative field-level encryption — any model that declares it is automatically covered by all bulk operationsBulk operations
encryptAllModels(db)/decryptAllModels(db)— bulk encrypt/decrypt all fields across all models that declareencryptedFieldsKey rotation
reEncryptAllModels(db, newKey)/reEncryptValue(value, newKey)— old key read fromencryption.key, new key passed explicitlybackend/scripts/changeEncryptionKey.js+make change_encryption_key NEW_KEY=<hex>— CLI for key rotation without server downtimeStartup sync
syncEncryptionState(db)— comparesENCRYPTION_ENABLEDagainstencryption.stateon startup and triggers encrypt/decrypt automatically if the flag changedMigration
20260612100001-encrypt-user-fieldssimplified to useencryptAllModels/decryptAllModels— new models withencryptedFieldsare automatically covered without migration changesKnown limitations
ENCRYPTION_ENABLEDis detected at server startup only — changing the flag requires a restartencryption.statetracks the last known flag value; on first startup after the migration the state file is created without re-encrypting (migration handles initial encryption)Future steps
encryptedFieldsto other tables as needed — no migration or script changes required