Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 26 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -62,20 +64,24 @@ 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.

```js
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 });
```
Expand All @@ -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).
23 changes: 20 additions & 3 deletions mongoose-encrypted-string.js
Original file line number Diff line number Diff line change
@@ -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');
}

Expand All @@ -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;
};
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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 },
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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');
});

});
114 changes: 114 additions & 0 deletions test/encryptedstring-aes-gcm.test.js
Original file line number Diff line number Diff line change
@@ -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();
});

});
Loading