diff --git a/README.md b/README.md index c771b24..16f4514 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ [![Build Status](https://img.shields.io/github/actions/workflow/status/tsmx/mongoose-encrypted-string/git-build.yml?branch=master)](https://img.shields.io/github/actions/workflow/status/tsmx/mongoose-encrypted-string/git-build.yml?branch=master) [![Coverage Status](https://coveralls.io/repos/github/tsmx/mongoose-encrypted-string/badge.svg?branch=master)](https://coveralls.io/github/tsmx/mongoose-encrypted-string?branch=master) -> `EncryptedString` type for Mongoose schemas. Provides AES-256-CBC encryption-at-rest for strings. +> `EncryptedString` type for Mongoose schemas. Provides AES-256-GCM and AES-256-CBC encryption-at-rest for strings. + +**Note:** The AES-256-GCM algorithm provides an additional cryptographic tamper-safety of the encrypted data by adding an authTag and should be preferred over AES-256-CBC. See also the [migration guide](#migrating-from-aes-cbc-to-aes-gcm) if you are already using AES-256-CBC. ## Usage @@ -27,32 +29,32 @@ Person = mongoose.model('Person', { let testPerson = new Person(); testPerson.id = 'id-test'; -testPerson.firstName = 'Hans'; // stored encrypted -testPerson.lastName = 'Müller'; // stored encrypted +testPerson.firstName = 'Hans'; // stored AES-256-GCM encrypted +testPerson.lastName = 'Müller'; // stored AES-256-GCM encrypted await testPerson.save(); let queriedPerson = await Person.findOne({ id: 'id-test' }); console.log(queriedPerson.firstName); // 'Hans', decrypted automatically -console.log(queriedPerson.lastName); // 'Müller, decrypted automatically +console.log(queriedPerson.lastName); // 'Müller', decrypted automatically ``` -Directly querying the MongoDB will return the encrypted data. +Directly querying the MongoDB will return the encrypted data. With the default AES-256-GCM algorithm, each encrypted field is stored as a 3-part string (`iv|authTag|ciphertext`). AES-256-CBC produces a 2-part string (`iv|ciphertext`). ```bash > db.persons.findOne({ id: 'id-test' }); { "_id" : ObjectId("5f8576cc0a6ca01d8e5c479c"), "id" : "id-test", - "firstName" : "66db1589b5c0de7f98f5260092e6799f|a6cb74bc05a52d1244addb125352bb0d", - "lastName" : "2b85f4ca2d98ad1234da376a6d0d9128|d5b0257d3797da7047bfea6dfa62e19c", + "firstName" : "66db1589b5c0de7f98f5260092e6799f|a3f1c2e4b5d6789012345678abcdef01|a6cb74bc05a52d1244addb125352bb0d", + "lastName" : "2b85f4ca2d98ad1234da376a6d0d9128|9f8e7d6c5b4a3210fedcba9876543210|d5b0257d3797da7047bfea6dfa62e19c", "__v" : 0 } ``` ## API -### registerEncryptedString(mongoose, key) +### registerEncryptedString(mongoose, key[, algorithm]) -Registers the new type `EncryptedString` in the `mongoose` instance's schema types. Encryption/decryption is done with AES-256-CBC using the given `key`. After calling this funtion you can start using the new type via `mongoose.Schema.Types.EncryptedString` in your schemas. +Registers the new type `EncryptedString` in the `mongoose` instance's schema types. Encryption/decryption is done using the given `key` and `algorithm` (default: `aes-256-gcm`). After calling this function you can start using the new type via `mongoose.Schema.Types.EncryptedString` in your schemas. #### mongoose @@ -62,9 +64,13 @@ The mongoose instance where `EncryptedString` should be registered. The key used for encryption/decryption. Length must be 32 bytes. See [notes](#notes) for details. +#### algorithm + +Optional. The encryption algorithm to use. Accepted values: `aes-256-gcm`, `aes-256-cbc`. Default: `aes-256-gcm`. Throws an `Error` if an unsupported value is passed. + ## Use with lean() queries -For performance reasons it maybe useful to use Mongoose's `lean()` queries. Doing so, the query will return the raw JSON objects from the MongoDB database where all properties of type `EncryptedString` are encrypted. +For performance reasons it may be useful to use Mongoose's `lean()` queries. Doing so, the query will return the raw JSON objects from the MongoDB database where all properties of type `EncryptedString` are encrypted. To get the clear text values back you can directly use [@tsmx/string-crypto](https://www.npmjs.com/package/@tsmx/string-crypto) which is also used internally in this package for encryption and decryption. @@ -72,10 +78,10 @@ To get the clear text values back you can directly use [@tsmx/string-crypto](htt const key = 'YOUR KEY HERE'; const sc = require('@tsmx/string-crypto'); -// query raw objects with encrypted string values +// query raw objects with encrypted string values, either AES-256-GCM or AES-256-CBC let person = await Person.findOne({ id: 'id-test' }).lean(); -// decrypt using string-crypto +// decrypt using string-crypto (algorithm is detected automatically) let firstName = sc.decrypt(person.firstName, { key: key }); let lastName = sc.decrypt(person.lastName, { key: key }); ``` @@ -87,3 +93,11 @@ let lastName = sc.decrypt(person.lastName, { key: key }); - a string of 32 characters length, or - a hexadecimal value of 64 characters length (= 32 bytes) - Don't override getters/setter for `EncryptedString` class or schema elements of this type. This would break the encryption. + +## Migrating from AES-CBC to AES-GCM + +Switching the algorithm after data has already been stored will break decryption of existing documents. To safely migrate existing CBC-encrypted data to GCM, follow these steps: + +1. Keep calling `registerEncryptedString(mongoose, key, 'aes-256-cbc')` until migration is complete. +2. Run a one-off migration script: query affected documents via `.lean()` to get raw encrypted values, then for each `EncryptedString` field decrypt with `sc.decrypt(value, { key })` and re-encrypt with `sc.encrypt(value, { key, algorithm: 'aes-256-gcm' })`, and write back using `collection.updateOne(...)` directly (bypassing Mongoose to avoid double-encryption). +3. Once all documents are migrated, switch to `registerEncryptedString(mongoose, key)` (GCM default). diff --git a/mongoose-encrypted-string.js b/mongoose-encrypted-string.js index 422d703..ef97cec 100644 --- a/mongoose-encrypted-string.js +++ b/mongoose-encrypted-string.js @@ -1,10 +1,23 @@ const SchemaType = require('mongoose').SchemaType; const sc = require('@tsmx/string-crypto'); +const allowedAlgorithms = ['aes-256-gcm', 'aes-256-cbc']; + class EncryptedString extends SchemaType { constructor(key, options) { - options.get = (v) => { return sc.decrypt(v, { key: EncryptedString.options.key, passNull: true }); }; - options.set = (v) => { return sc.encrypt(v, { key: EncryptedString.options.key, passNull: true }); }; + options.get = (v) => { + return sc.decrypt(v, { + key: EncryptedString.options.key, + passNull: true + }); + }; + options.set = (v) => { + return sc.encrypt(v, { + key: EncryptedString.options.key, + passNull: true, + algorithm: EncryptedString.options.algorithm + }); + }; super(key, options, 'EncryptedString'); } @@ -18,7 +31,11 @@ class EncryptedString extends SchemaType { } -module.exports.registerEncryptedString = function (mongoose, key) { +module.exports.registerEncryptedString = function (mongoose, key, algorithm = 'aes-256-gcm') { + if (!allowedAlgorithms.includes(algorithm)) { + throw new Error(`Invalid algorithm '${algorithm}'. Allowed: ${allowedAlgorithms.join(', ')}`); + } EncryptedString.options.key = key; + EncryptedString.options.algorithm = algorithm; mongoose.Schema.Types.EncryptedString = EncryptedString; }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ae09aea..7c531e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.9", "license": "MIT", "dependencies": { - "@tsmx/string-crypto": "^1.0.4", + "@tsmx/string-crypto": "^2.0.0", "mongoose": "^9.0.0" }, "devDependencies": { @@ -1232,13 +1232,13 @@ } }, "node_modules/@tsmx/string-crypto": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@tsmx/string-crypto/-/string-crypto-1.0.7.tgz", - "integrity": "sha512-lXgcJTOnfR4J4b+aRubB5JPnogtsGSqcj/hXb16J8kerAQhQWO6zDf4H814K5ZxFxqjlixe4x66goBJSet235A==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tsmx/string-crypto/-/string-crypto-2.0.0.tgz", + "integrity": "sha512-k+zVoCEtzjLVAEJwxzhctSw4ZT3PXdymMKfIhsv78dXQUYmqasyuuRUTdzpRAJI2532l27vyyjoIjxNeXQLHPQ==", "license": "MIT", "engines": { - "node": ">=10.0.0", - "npm": ">=6.0.0" + "node": ">=16.9.0", + "npm": ">=7.0.0" } }, "node_modules/@types/babel__core": { diff --git a/package.json b/package.json index ab38d93..d53fa35 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@tsmx/mongoose-encrypted-string", "version": "1.0.9", - "description": "EncryptedString type for Mongoose schemas.", + "description": "EncryptedString type for Mongoose schemas providing AES-GCM and AES-CBC encryption at rest.", "main": "mongoose-encrypted-string.js", "engines": { "node": ">=18.0.0", @@ -25,10 +25,13 @@ "decryption", "string", "AES", + "AES-256-GCM", + "AES-256-CBC", + "encryption-at-rest", "crypto" ], "dependencies": { - "@tsmx/string-crypto": "^1.0.4", + "@tsmx/string-crypto": "^2.0.0", "mongoose": "^9.0.0" }, "devDependencies": { diff --git a/test/mongoose-encryptedstring.test.js b/test/encryptedstring-aes-cbc.test.js similarity index 70% rename from test/mongoose-encryptedstring.test.js rename to test/encryptedstring-aes-cbc.test.js index ed7b087..ab2413a 100644 --- a/test/mongoose-encryptedstring.test.js +++ b/test/encryptedstring-aes-cbc.test.js @@ -3,7 +3,7 @@ const sc = require('@tsmx/string-crypto'); const { MongoMemoryServer } = require('mongodb-memory-server'); const mes = require('../mongoose-encrypted-string'); -describe('mongoose-encrypted-string test suite', () => { +describe('mongoose-encrypted-string AES-256-CBC test suite', () => { const testKey = '9af7d400be4705147dc724db25bfd2513aa11d6013d7bf7bdb2bfe050593bd0f'; @@ -14,7 +14,7 @@ describe('mongoose-encrypted-string test suite', () => { beforeAll(async () => { mongoServer = await MongoMemoryServer.create({ dbName: 'encryptedstring' }); await mongoose.connect(mongoServer.getUri()); - mes.registerEncryptedString(mongoose, testKey); + mes.registerEncryptedString(mongoose, testKey, 'aes-256-cbc'); Person = mongoose.model('Person', { id: { type: String, required: true }, firstName: { type: mongoose.Schema.Types.EncryptedString }, @@ -47,36 +47,38 @@ describe('mongoose-encrypted-string test suite', () => { let savedPerson = await person.save(); expect(savedPerson).toBeDefined(); expect(savedPerson._id).toBeDefined(); - expect(savedPerson.firstName).toBe('Hans'); - expect(savedPerson.lastName).toBe('Müller'); + expect(savedPerson.firstName).toStrictEqual('Hans'); + expect(savedPerson.lastName).toStrictEqual('Müller'); let savedPersonLean = await Person.findById(savedPerson._id).lean(); - expect(savedPersonLean.firstName).not.toBe('Hans'); + expect(savedPersonLean.firstName).not.toStrictEqual('Hans'); let firstNameParts = savedPersonLean.firstName.split('|'); - expect(firstNameParts.length).toBe(2); - expect(savedPersonLean.lastName).not.toBe('Müller'); + expect(firstNameParts.length).toStrictEqual(2); + expect(savedPersonLean.lastName).not.toStrictEqual('Müller'); let lastNameParts = savedPersonLean.firstName.split('|'); - expect(lastNameParts.length).toBe(2); + expect(lastNameParts.length).toStrictEqual(2); }); it('tests a successful document update', async () => { let person = await Person.findOne({ id: 'id-test' }); expect(person).toBeDefined(); - expect(person.firstName).toBe('FirstNameTest'); - expect(person.lastName).toBe('LastNameTest'); + expect(person.firstName).toStrictEqual('FirstNameTest'); + expect(person.lastName).toStrictEqual('LastNameTest'); person.firstName = 'NewFirstName'; await person.save(); let updatedPerson = await Person.findOne({ id: 'id-test' }); - expect(updatedPerson.firstName).toBe('NewFirstName'); - expect(updatedPerson.lastName).toBe('LastNameTest'); + expect(updatedPerson.firstName).toStrictEqual('NewFirstName'); + expect(updatedPerson.lastName).toStrictEqual('LastNameTest'); }); it('tests a successful manual decryption of a document from a lean query', async () => { let person = await Person.findOne({ id: 'id-test' }).lean(); expect(person).toBeDefined(); - expect(person.firstName).not.toBe('FirstNameTest'); - expect(person.lastName).not.toBe('LastNameTest'); - expect(sc.decrypt(person.firstName, { key: testKey })).toBe('FirstNameTest'); - expect(sc.decrypt(person.lastName, { key: testKey })).toBe('LastNameTest'); + expect(person.firstName).not.toStrictEqual('FirstNameTest'); + expect(person.firstName.split('|').length).toStrictEqual(2); + expect(person.lastName).not.toStrictEqual('LastNameTest'); + expect(person.lastName.split('|').length).toStrictEqual(2); + expect(sc.decrypt(person.firstName, { key: testKey })).toStrictEqual('FirstNameTest'); + expect(sc.decrypt(person.lastName, { key: testKey })).toStrictEqual('LastNameTest'); }); it('tests a successful document creation and retrieval with null values', async () => { @@ -88,12 +90,12 @@ describe('mongoose-encrypted-string test suite', () => { expect(savedPerson).toBeDefined(); expect(savedPerson._id).toBeDefined(); expect(savedPerson.firstName).toStrictEqual(null); - expect(savedPerson.lastName).toBe('Müller'); + expect(savedPerson.lastName).toStrictEqual('Müller'); let retrievedPerson = await Person.findOne({ id: 'id-1' }); expect(retrievedPerson).toBeDefined(); expect(retrievedPerson._id).toBeDefined(); expect(retrievedPerson.firstName).toStrictEqual(null); - expect(retrievedPerson.lastName).toBe('Müller'); + expect(retrievedPerson.lastName).toStrictEqual('Müller'); }); }); \ No newline at end of file diff --git a/test/encryptedstring-aes-gcm.test.js b/test/encryptedstring-aes-gcm.test.js new file mode 100644 index 0000000..056fc8b --- /dev/null +++ b/test/encryptedstring-aes-gcm.test.js @@ -0,0 +1,114 @@ +const mongoose = require('mongoose'); +const sc = require('@tsmx/string-crypto'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mes = require('../mongoose-encrypted-string'); + +describe('mongoose-encrypted-string AES-256-GCM test suite', () => { + + const testKey = '9af7d400be4705147dc724db25bfd2513aa11d6013d7bf7bdb2bfe050593bd0f'; + + var mongoServer = null; + var Person = null; + + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create({ dbName: 'encryptedstring' }); + await mongoose.connect(mongoServer.getUri()); + mes.registerEncryptedString(mongoose, testKey, 'aes-256-gcm'); + Person = mongoose.model('Person', { + id: { type: String, required: true }, + firstName: { type: mongoose.Schema.Types.EncryptedString }, + lastName: { type: mongoose.Schema.Types.EncryptedString } + }); + }); + + afterAll(async () => { + await mongoose.connection.close(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + let testPerson = new Person(); + testPerson.id = 'id-test'; + testPerson.firstName = 'FirstNameTest'; + testPerson.lastName = 'LastNameTest'; + await testPerson.save(); + }); + + afterEach(async () => { + await Person.deleteMany(); + }); + + it('tests a successful document creation', async () => { + let person = new Person(); + person.id = 'id-1'; + person.firstName = 'Hans'; + person.lastName = 'Müller'; + let savedPerson = await person.save(); + expect(savedPerson).toBeDefined(); + expect(savedPerson._id).toBeDefined(); + expect(savedPerson.firstName).toStrictEqual('Hans'); + expect(savedPerson.lastName).toStrictEqual('Müller'); + let savedPersonLean = await Person.findById(savedPerson._id).lean(); + expect(savedPersonLean.firstName).not.toStrictEqual('Hans'); + let firstNameParts = savedPersonLean.firstName.split('|'); + expect(firstNameParts.length).toStrictEqual(3); + expect(savedPersonLean.lastName).not.toStrictEqual('Müller'); + let lastNameParts = savedPersonLean.firstName.split('|'); + expect(lastNameParts.length).toStrictEqual(3); + }); + + it('tests a successful document update', async () => { + let person = await Person.findOne({ id: 'id-test' }); + expect(person).toBeDefined(); + expect(person.firstName).toStrictEqual('FirstNameTest'); + expect(person.lastName).toStrictEqual('LastNameTest'); + person.firstName = 'NewFirstName'; + await person.save(); + let updatedPerson = await Person.findOne({ id: 'id-test' }); + expect(updatedPerson.firstName).toStrictEqual('NewFirstName'); + expect(updatedPerson.lastName).toStrictEqual('LastNameTest'); + }); + + it('tests a successful manual decryption of a document from a lean query', async () => { + let person = await Person.findOne({ id: 'id-test' }).lean(); + expect(person).toBeDefined(); + expect(person.firstName).not.toStrictEqual('FirstNameTest'); + expect(person.firstName.split('|').length).toStrictEqual(3); + expect(person.lastName).not.toStrictEqual('LastNameTest'); + expect(person.lastName.split('|').length).toStrictEqual(3); + expect(sc.decrypt(person.firstName, { key: testKey })).toStrictEqual('FirstNameTest'); + expect(sc.decrypt(person.lastName, { key: testKey })).toStrictEqual('LastNameTest'); + }); + + it('tests a successful document creation and retrieval with null values', async () => { + let person = new Person(); + person.id = 'id-1'; + person.firstName = null; + person.lastName = 'Müller'; + let savedPerson = await person.save(); + expect(savedPerson).toBeDefined(); + expect(savedPerson._id).toBeDefined(); + expect(savedPerson.firstName).toStrictEqual(null); + expect(savedPerson.lastName).toStrictEqual('Müller'); + let retrievedPerson = await Person.findOne({ id: 'id-1' }); + expect(retrievedPerson).toBeDefined(); + expect(retrievedPerson._id).toBeDefined(); + expect(retrievedPerson.firstName).toStrictEqual(null); + expect(retrievedPerson.lastName).toStrictEqual('Müller'); + }); + + it('tests failed decryption due to tampered authTag', async () => { + let personLean = await Person.findOne({ id: 'id-test' }).lean(); + let parts = personLean.firstName.split('|'); + parts[1] = parts[1][0] === 'a' ? 'b' + parts[1].slice(1) : 'a' + parts[1].slice(1); + let tamperedValue = parts.join('|'); + await mongoose.connection.collection('people').updateOne( + { id: 'id-test' }, + { $set: { firstName: tamperedValue } } + ); + let person = await Person.findOne({ id: 'id-test' }); + expect(() => person.firstName).toThrow(); + }); + +}); \ No newline at end of file diff --git a/test/encryptedstring.test.js b/test/encryptedstring.test.js new file mode 100644 index 0000000..7261488 --- /dev/null +++ b/test/encryptedstring.test.js @@ -0,0 +1,53 @@ +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mes = require('../mongoose-encrypted-string'); + +describe('mongoose-encrypted-string test suite', () => { + + const testKey = '9af7d400be4705147dc724db25bfd2513aa11d6013d7bf7bdb2bfe050593bd0f'; + + var mongoServer = null; + + beforeEach(async () => { + mongoServer = await MongoMemoryServer.create({ dbName: 'encryptedstring' }); + await mongoose.connect(mongoServer.getUri()); + }); + + afterEach(async () => { + await mongoose.connection.close(); + await mongoServer.stop(); + }); + + it('tests a successful document creation with AES-GCM as default algorithm', async () => { + mes.registerEncryptedString(mongoose, testKey); + let Person = mongoose.model('Person', { + id: { type: String, required: true }, + firstName: { type: mongoose.Schema.Types.EncryptedString }, + lastName: { type: mongoose.Schema.Types.EncryptedString } + }); + // test doc creation + let person = new Person(); + person.id = 'id-1'; + person.firstName = 'Hans'; + person.lastName = 'Müller'; + let savedPerson = await person.save(); + expect(savedPerson).toBeDefined(); + expect(savedPerson._id).toBeDefined(); + expect(savedPerson.firstName).toStrictEqual('Hans'); + expect(savedPerson.lastName).toStrictEqual('Müller'); + let savedPersonLean = await Person.findById(savedPerson._id).lean(); + expect(savedPersonLean.firstName).not.toStrictEqual('Hans'); + let firstNameParts = savedPersonLean.firstName.split('|'); + expect(firstNameParts.length).toStrictEqual(3); + expect(savedPersonLean.lastName).not.toStrictEqual('Müller'); + let lastNameParts = savedPersonLean.firstName.split('|'); + expect(lastNameParts.length).toStrictEqual(3); + // tear-down + await Person.deleteMany(); + }); + + it('tests an exception because of an unknown algorithm', async () => { + expect(() => mes.registerEncryptedString(mongoose, testKey, 'fake-algo')).toThrow(); + }); + +}); \ No newline at end of file