Skip to content

Commit e09108d

Browse files
authored
Merge pull request #2 from tsmx/aes-gcm-feature
AES GCM feature
2 parents fce7a8b + 4d124f3 commit e09108d

7 files changed

Lines changed: 244 additions & 41 deletions

README.md

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
[![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)
77
[![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)
88

9-
> `EncryptedString` type for Mongoose schemas. Provides AES-256-CBC encryption-at-rest for strings.
9+
> `EncryptedString` type for Mongoose schemas. Provides AES-256-GCM and AES-256-CBC encryption-at-rest for strings.
10+
11+
**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.
1012

1113
## Usage
1214

@@ -27,32 +29,32 @@ Person = mongoose.model('Person', {
2729

2830
let testPerson = new Person();
2931
testPerson.id = 'id-test';
30-
testPerson.firstName = 'Hans'; // stored encrypted
31-
testPerson.lastName = 'Müller'; // stored encrypted
32+
testPerson.firstName = 'Hans'; // stored AES-256-GCM encrypted
33+
testPerson.lastName = 'Müller'; // stored AES-256-GCM encrypted
3234
await testPerson.save();
3335

3436

3537
let queriedPerson = await Person.findOne({ id: 'id-test' });
3638
console.log(queriedPerson.firstName); // 'Hans', decrypted automatically
37-
console.log(queriedPerson.lastName); // 'Müller, decrypted automatically
39+
console.log(queriedPerson.lastName); // 'Müller', decrypted automatically
3840
```
39-
Directly querying the MongoDB will return the encrypted data.
41+
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`).
4042
```bash
4143
> db.persons.findOne({ id: 'id-test' });
4244
{
4345
"_id" : ObjectId("5f8576cc0a6ca01d8e5c479c"),
4446
"id" : "id-test",
45-
"firstName" : "66db1589b5c0de7f98f5260092e6799f|a6cb74bc05a52d1244addb125352bb0d",
46-
"lastName" : "2b85f4ca2d98ad1234da376a6d0d9128|d5b0257d3797da7047bfea6dfa62e19c",
47+
"firstName" : "66db1589b5c0de7f98f5260092e6799f|a3f1c2e4b5d6789012345678abcdef01|a6cb74bc05a52d1244addb125352bb0d",
48+
"lastName" : "2b85f4ca2d98ad1234da376a6d0d9128|9f8e7d6c5b4a3210fedcba9876543210|d5b0257d3797da7047bfea6dfa62e19c",
4749
"__v" : 0
4850
}
4951
```
5052

5153
## API
5254

53-
### registerEncryptedString(mongoose, key)
55+
### registerEncryptedString(mongoose, key[, algorithm])
5456

55-
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.
57+
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.
5658

5759
#### mongoose
5860

@@ -62,20 +64,24 @@ The mongoose instance where `EncryptedString` should be registered.
6264

6365
The key used for encryption/decryption. Length must be 32 bytes. See [notes](#notes) for details.
6466

67+
#### algorithm
68+
69+
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.
70+
6571
## Use with lean() queries
6672

67-
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.
73+
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.
6874

6975
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.
7076

7177
```js
7278
const key = 'YOUR KEY HERE';
7379
const sc = require('@tsmx/string-crypto');
7480

75-
// query raw objects with encrypted string values
81+
// query raw objects with encrypted string values, either AES-256-GCM or AES-256-CBC
7682
let person = await Person.findOne({ id: 'id-test' }).lean();
7783

78-
// decrypt using string-crypto
84+
// decrypt using string-crypto (algorithm is detected automatically)
7985
let firstName = sc.decrypt(person.firstName, { key: key });
8086
let lastName = sc.decrypt(person.lastName, { key: key });
8187
```
@@ -87,3 +93,11 @@ let lastName = sc.decrypt(person.lastName, { key: key });
8793
- a string of 32 characters length, or
8894
- a hexadecimal value of 64 characters length (= 32 bytes)
8995
- Don't override getters/setter for `EncryptedString` class or schema elements of this type. This would break the encryption.
96+
97+
## Migrating from AES-CBC to AES-GCM
98+
99+
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:
100+
101+
1. Keep calling `registerEncryptedString(mongoose, key, 'aes-256-cbc')` until migration is complete.
102+
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).
103+
3. Once all documents are migrated, switch to `registerEncryptedString(mongoose, key)` (GCM default).

mongoose-encrypted-string.js

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,23 @@
11
const SchemaType = require('mongoose').SchemaType;
22
const sc = require('@tsmx/string-crypto');
33

4+
const allowedAlgorithms = ['aes-256-gcm', 'aes-256-cbc'];
5+
46
class EncryptedString extends SchemaType {
57
constructor(key, options) {
6-
options.get = (v) => { return sc.decrypt(v, { key: EncryptedString.options.key, passNull: true }); };
7-
options.set = (v) => { return sc.encrypt(v, { key: EncryptedString.options.key, passNull: true }); };
8+
options.get = (v) => {
9+
return sc.decrypt(v, {
10+
key: EncryptedString.options.key,
11+
passNull: true
12+
});
13+
};
14+
options.set = (v) => {
15+
return sc.encrypt(v, {
16+
key: EncryptedString.options.key,
17+
passNull: true,
18+
algorithm: EncryptedString.options.algorithm
19+
});
20+
};
821
super(key, options, 'EncryptedString');
922
}
1023

@@ -18,7 +31,11 @@ class EncryptedString extends SchemaType {
1831

1932
}
2033

21-
module.exports.registerEncryptedString = function (mongoose, key) {
34+
module.exports.registerEncryptedString = function (mongoose, key, algorithm = 'aes-256-gcm') {
35+
if (!allowedAlgorithms.includes(algorithm)) {
36+
throw new Error(`Invalid algorithm '${algorithm}'. Allowed: ${allowedAlgorithms.join(', ')}`);
37+
}
2238
EncryptedString.options.key = key;
39+
EncryptedString.options.algorithm = algorithm;
2340
mongoose.Schema.Types.EncryptedString = EncryptedString;
2441
};

package-lock.json

Lines changed: 6 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@tsmx/mongoose-encrypted-string",
33
"version": "1.0.9",
4-
"description": "EncryptedString type for Mongoose schemas.",
4+
"description": "EncryptedString type for Mongoose schemas providing AES-GCM and AES-CBC encryption at rest.",
55
"main": "mongoose-encrypted-string.js",
66
"engines": {
77
"node": ">=18.0.0",
@@ -25,10 +25,13 @@
2525
"decryption",
2626
"string",
2727
"AES",
28+
"AES-256-GCM",
29+
"AES-256-CBC",
30+
"encryption-at-rest",
2831
"crypto"
2932
],
3033
"dependencies": {
31-
"@tsmx/string-crypto": "^1.0.4",
34+
"@tsmx/string-crypto": "^2.0.0",
3235
"mongoose": "^9.0.0"
3336
},
3437
"devDependencies": {
Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ const sc = require('@tsmx/string-crypto');
33
const { MongoMemoryServer } = require('mongodb-memory-server');
44
const mes = require('../mongoose-encrypted-string');
55

6-
describe('mongoose-encrypted-string test suite', () => {
6+
describe('mongoose-encrypted-string AES-256-CBC test suite', () => {
77

88
const testKey = '9af7d400be4705147dc724db25bfd2513aa11d6013d7bf7bdb2bfe050593bd0f';
99

@@ -14,7 +14,7 @@ describe('mongoose-encrypted-string test suite', () => {
1414
beforeAll(async () => {
1515
mongoServer = await MongoMemoryServer.create({ dbName: 'encryptedstring' });
1616
await mongoose.connect(mongoServer.getUri());
17-
mes.registerEncryptedString(mongoose, testKey);
17+
mes.registerEncryptedString(mongoose, testKey, 'aes-256-cbc');
1818
Person = mongoose.model('Person', {
1919
id: { type: String, required: true },
2020
firstName: { type: mongoose.Schema.Types.EncryptedString },
@@ -47,36 +47,38 @@ describe('mongoose-encrypted-string test suite', () => {
4747
let savedPerson = await person.save();
4848
expect(savedPerson).toBeDefined();
4949
expect(savedPerson._id).toBeDefined();
50-
expect(savedPerson.firstName).toBe('Hans');
51-
expect(savedPerson.lastName).toBe('Müller');
50+
expect(savedPerson.firstName).toStrictEqual('Hans');
51+
expect(savedPerson.lastName).toStrictEqual('Müller');
5252
let savedPersonLean = await Person.findById(savedPerson._id).lean();
53-
expect(savedPersonLean.firstName).not.toBe('Hans');
53+
expect(savedPersonLean.firstName).not.toStrictEqual('Hans');
5454
let firstNameParts = savedPersonLean.firstName.split('|');
55-
expect(firstNameParts.length).toBe(2);
56-
expect(savedPersonLean.lastName).not.toBe('Müller');
55+
expect(firstNameParts.length).toStrictEqual(2);
56+
expect(savedPersonLean.lastName).not.toStrictEqual('Müller');
5757
let lastNameParts = savedPersonLean.firstName.split('|');
58-
expect(lastNameParts.length).toBe(2);
58+
expect(lastNameParts.length).toStrictEqual(2);
5959
});
6060

6161
it('tests a successful document update', async () => {
6262
let person = await Person.findOne({ id: 'id-test' });
6363
expect(person).toBeDefined();
64-
expect(person.firstName).toBe('FirstNameTest');
65-
expect(person.lastName).toBe('LastNameTest');
64+
expect(person.firstName).toStrictEqual('FirstNameTest');
65+
expect(person.lastName).toStrictEqual('LastNameTest');
6666
person.firstName = 'NewFirstName';
6767
await person.save();
6868
let updatedPerson = await Person.findOne({ id: 'id-test' });
69-
expect(updatedPerson.firstName).toBe('NewFirstName');
70-
expect(updatedPerson.lastName).toBe('LastNameTest');
69+
expect(updatedPerson.firstName).toStrictEqual('NewFirstName');
70+
expect(updatedPerson.lastName).toStrictEqual('LastNameTest');
7171
});
7272

7373
it('tests a successful manual decryption of a document from a lean query', async () => {
7474
let person = await Person.findOne({ id: 'id-test' }).lean();
7575
expect(person).toBeDefined();
76-
expect(person.firstName).not.toBe('FirstNameTest');
77-
expect(person.lastName).not.toBe('LastNameTest');
78-
expect(sc.decrypt(person.firstName, { key: testKey })).toBe('FirstNameTest');
79-
expect(sc.decrypt(person.lastName, { key: testKey })).toBe('LastNameTest');
76+
expect(person.firstName).not.toStrictEqual('FirstNameTest');
77+
expect(person.firstName.split('|').length).toStrictEqual(2);
78+
expect(person.lastName).not.toStrictEqual('LastNameTest');
79+
expect(person.lastName.split('|').length).toStrictEqual(2);
80+
expect(sc.decrypt(person.firstName, { key: testKey })).toStrictEqual('FirstNameTest');
81+
expect(sc.decrypt(person.lastName, { key: testKey })).toStrictEqual('LastNameTest');
8082
});
8183

8284
it('tests a successful document creation and retrieval with null values', async () => {
@@ -88,12 +90,12 @@ describe('mongoose-encrypted-string test suite', () => {
8890
expect(savedPerson).toBeDefined();
8991
expect(savedPerson._id).toBeDefined();
9092
expect(savedPerson.firstName).toStrictEqual(null);
91-
expect(savedPerson.lastName).toBe('Müller');
93+
expect(savedPerson.lastName).toStrictEqual('Müller');
9294
let retrievedPerson = await Person.findOne({ id: 'id-1' });
9395
expect(retrievedPerson).toBeDefined();
9496
expect(retrievedPerson._id).toBeDefined();
9597
expect(retrievedPerson.firstName).toStrictEqual(null);
96-
expect(retrievedPerson.lastName).toBe('Müller');
98+
expect(retrievedPerson.lastName).toStrictEqual('Müller');
9799
});
98100

99101
});
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
const mongoose = require('mongoose');
2+
const sc = require('@tsmx/string-crypto');
3+
const { MongoMemoryServer } = require('mongodb-memory-server');
4+
const mes = require('../mongoose-encrypted-string');
5+
6+
describe('mongoose-encrypted-string AES-256-GCM test suite', () => {
7+
8+
const testKey = '9af7d400be4705147dc724db25bfd2513aa11d6013d7bf7bdb2bfe050593bd0f';
9+
10+
var mongoServer = null;
11+
var Person = null;
12+
13+
14+
beforeAll(async () => {
15+
mongoServer = await MongoMemoryServer.create({ dbName: 'encryptedstring' });
16+
await mongoose.connect(mongoServer.getUri());
17+
mes.registerEncryptedString(mongoose, testKey, 'aes-256-gcm');
18+
Person = mongoose.model('Person', {
19+
id: { type: String, required: true },
20+
firstName: { type: mongoose.Schema.Types.EncryptedString },
21+
lastName: { type: mongoose.Schema.Types.EncryptedString }
22+
});
23+
});
24+
25+
afterAll(async () => {
26+
await mongoose.connection.close();
27+
await mongoServer.stop();
28+
});
29+
30+
beforeEach(async () => {
31+
let testPerson = new Person();
32+
testPerson.id = 'id-test';
33+
testPerson.firstName = 'FirstNameTest';
34+
testPerson.lastName = 'LastNameTest';
35+
await testPerson.save();
36+
});
37+
38+
afterEach(async () => {
39+
await Person.deleteMany();
40+
});
41+
42+
it('tests a successful document creation', async () => {
43+
let person = new Person();
44+
person.id = 'id-1';
45+
person.firstName = 'Hans';
46+
person.lastName = 'Müller';
47+
let savedPerson = await person.save();
48+
expect(savedPerson).toBeDefined();
49+
expect(savedPerson._id).toBeDefined();
50+
expect(savedPerson.firstName).toStrictEqual('Hans');
51+
expect(savedPerson.lastName).toStrictEqual('Müller');
52+
let savedPersonLean = await Person.findById(savedPerson._id).lean();
53+
expect(savedPersonLean.firstName).not.toStrictEqual('Hans');
54+
let firstNameParts = savedPersonLean.firstName.split('|');
55+
expect(firstNameParts.length).toStrictEqual(3);
56+
expect(savedPersonLean.lastName).not.toStrictEqual('Müller');
57+
let lastNameParts = savedPersonLean.firstName.split('|');
58+
expect(lastNameParts.length).toStrictEqual(3);
59+
});
60+
61+
it('tests a successful document update', async () => {
62+
let person = await Person.findOne({ id: 'id-test' });
63+
expect(person).toBeDefined();
64+
expect(person.firstName).toStrictEqual('FirstNameTest');
65+
expect(person.lastName).toStrictEqual('LastNameTest');
66+
person.firstName = 'NewFirstName';
67+
await person.save();
68+
let updatedPerson = await Person.findOne({ id: 'id-test' });
69+
expect(updatedPerson.firstName).toStrictEqual('NewFirstName');
70+
expect(updatedPerson.lastName).toStrictEqual('LastNameTest');
71+
});
72+
73+
it('tests a successful manual decryption of a document from a lean query', async () => {
74+
let person = await Person.findOne({ id: 'id-test' }).lean();
75+
expect(person).toBeDefined();
76+
expect(person.firstName).not.toStrictEqual('FirstNameTest');
77+
expect(person.firstName.split('|').length).toStrictEqual(3);
78+
expect(person.lastName).not.toStrictEqual('LastNameTest');
79+
expect(person.lastName.split('|').length).toStrictEqual(3);
80+
expect(sc.decrypt(person.firstName, { key: testKey })).toStrictEqual('FirstNameTest');
81+
expect(sc.decrypt(person.lastName, { key: testKey })).toStrictEqual('LastNameTest');
82+
});
83+
84+
it('tests a successful document creation and retrieval with null values', async () => {
85+
let person = new Person();
86+
person.id = 'id-1';
87+
person.firstName = null;
88+
person.lastName = 'Müller';
89+
let savedPerson = await person.save();
90+
expect(savedPerson).toBeDefined();
91+
expect(savedPerson._id).toBeDefined();
92+
expect(savedPerson.firstName).toStrictEqual(null);
93+
expect(savedPerson.lastName).toStrictEqual('Müller');
94+
let retrievedPerson = await Person.findOne({ id: 'id-1' });
95+
expect(retrievedPerson).toBeDefined();
96+
expect(retrievedPerson._id).toBeDefined();
97+
expect(retrievedPerson.firstName).toStrictEqual(null);
98+
expect(retrievedPerson.lastName).toStrictEqual('Müller');
99+
});
100+
101+
it('tests failed decryption due to tampered authTag', async () => {
102+
let personLean = await Person.findOne({ id: 'id-test' }).lean();
103+
let parts = personLean.firstName.split('|');
104+
parts[1] = parts[1][0] === 'a' ? 'b' + parts[1].slice(1) : 'a' + parts[1].slice(1);
105+
let tamperedValue = parts.join('|');
106+
await mongoose.connection.collection('people').updateOne(
107+
{ id: 'id-test' },
108+
{ $set: { firstName: tamperedValue } }
109+
);
110+
let person = await Person.findOne({ id: 'id-test' });
111+
expect(() => person.firstName).toThrow();
112+
});
113+
114+
});

0 commit comments

Comments
 (0)