From db8eef7e0a0cd71379ac3bce0254d5b5ca0e034a Mon Sep 17 00:00:00 2001 From: bailey Date: Mon, 17 Mar 2025 13:52:08 -0600 Subject: [PATCH 1/6] Add support for encrypted models and discriminators --- docs/field-level-encryption.md | 10 + lib/drivers/node-mongodb-native/connection.js | 60 ++ lib/schema.js | 57 ++ lib/schema/bigint.js | 2 +- lib/schema/boolean.js | 2 +- lib/schema/buffer.js | 2 +- lib/schema/decimal128.js | 2 +- lib/schema/int32.js | 2 +- lib/schema/objectId.js | 2 +- lib/utils.js | 10 + scripts/configure-cluster-with-encryption.sh | 2 +- scripts/run-encryption-tests.sh | 39 + test/encryptedSchema.test.js | 683 +++++++++++++-- test/encryption/encryption.test.js | 785 ++++++++++++++++-- 14 files changed, 1530 insertions(+), 128 deletions(-) create mode 100755 scripts/run-encryption-tests.sh diff --git a/docs/field-level-encryption.md b/docs/field-level-encryption.md index 8cf4f27d551..828bcd36664 100644 --- a/docs/field-level-encryption.md +++ b/docs/field-level-encryption.md @@ -151,3 +151,13 @@ To declare a field as encrypted, you must: 2. Choose an encryption type for the schema and configure the schema for the encryption type Not all schematypes are supported for CSFLE and QE. For an overview of valid schema types, refer to MongoDB's documentation. + +### Registering Models + +Encrypted schemas must be registered on a connection, not the Mongoose global: + +```javascript + +const connection = mongoose.createConnection(); +const UserModel = connection.model('User', encryptedUserSchema); +``` diff --git a/lib/drivers/node-mongodb-native/connection.js b/lib/drivers/node-mongodb-native/connection.js index e96b89c9398..3befc25a002 100644 --- a/lib/drivers/node-mongodb-native/connection.js +++ b/lib/drivers/node-mongodb-native/connection.js @@ -12,6 +12,7 @@ const pkg = require('../../../package.json'); const processConnectionOptions = require('../../helpers/processConnectionOptions'); const setTimeout = require('../../helpers/timers').setTimeout; const utils = require('../../utils'); +const Schema = require('../../schema'); /** * A [node-mongodb-native](https://github.com/mongodb/node-mongodb-native) connection implementation. @@ -320,6 +321,16 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio }; } + const { schemaMap, encryptedFieldsMap } = this._buildEncryptionSchemas(); + + if (Object.keys(schemaMap).length > 0) { + options.autoEncryption.schemaMap = schemaMap; + } + + if (Object.keys(encryptedFieldsMap).length > 0) { + options.autoEncryption.encryptedFieldsMap = encryptedFieldsMap; + } + this.readyState = STATES.connecting; this._connectionString = uri; @@ -343,6 +354,55 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio return this; }; +/** + * Given a connection, which may or may not have encrypted models, build + * a schemaMap and/or an encryptedFieldsMap for the connection, combining all models + * into a single schemaMap and encryptedFields map. + * + * @returns a copy of the options object with a schemaMap and/or an encryptedFieldsMap added to the options' autoEncryption + * options. + */ +NativeConnection.prototype._buildEncryptionSchemas = function() { + const qeMappings = {}; + const csfleMappings = {}; + + // If discriminators are configured for the collection, there might be multiple models + // pointing to the same namespace. For this scenario, we merge all the schemas for each namespace + // into a single schema. + // Notably, this doesn't allow for discriminators to declare multiple values on the same fields. + for (const model of Object.values(this.models)) { + const { schema, collection: { collectionName } } = model; + const namespace = `${this.$dbName}.${collectionName}`; + if (schema.encryptionType() === 'csfle') { + csfleMappings[namespace] ??= new Schema({}, { encryptionType: 'csfle' }); + csfleMappings[namespace].add(schema); + } else if (schema.encryptionType() === 'queryableEncryption') { + qeMappings[namespace] ??= new Schema({}, { encryptionType: 'queryableEncryption' }); + qeMappings[namespace].add(schema); + } + } + + const schemaMap = Object.entries(csfleMappings).reduce( + (schemaMap, [namespace, schema]) => { + schemaMap[namespace] = schema._buildSchemaMap(); + return schemaMap; + }, + {} + ); + + const encryptedFieldsMap = Object.entries(qeMappings).reduce( + (encryptedFieldsMap, [namespace, schema]) => { + encryptedFieldsMap[namespace] = schema._buildEncryptedFields(); + return encryptedFieldsMap; + }, + {} + ); + + return { + schemaMap, encryptedFieldsMap + }; +}; + /*! * ignore */ diff --git a/lib/schema.js b/lib/schema.js index 5679e421109..2494a1ccee1 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -914,6 +914,63 @@ Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() { return Object.keys(this.encryptedFields).length > 0; }; +Schema.prototype._buildEncryptedFields = function() { + const fields = Object.entries(this.encryptedFields).map( + ([path, config]) => { + const bsonType = this.path(path).autoEncryptionType(); + // { path, bsonType, keyId, queries? } + return { path, bsonType, ...config }; + }); + + return { fields }; +}; + +Schema.prototype._buildSchemaMap = function() { + /** + * `schemaMap`s are JSON schemas, which use the following structure to represent objects: + * { field: { bsonType: 'object', properties: { ... } } } + * + * for example, a schema that looks like this `{ a: { b: int32 } }` would be encoded as + * `{ a: { bsonType: 'object', properties: { b: < encryption configuration > } } }` + * + * This function takes an array of path segments, an output object (that gets mutated) and + * a value to associated with the full path, and constructs a valid CSFLE JSON schema path for + * the object. This works for deeply nested properties as well. + * + * @param {string[]} path array of path components + * @param {object} object the object in which to build a JSON schema of `path`'s properties + * @param {object} value the value to associate with the path in object + */ + function buildNestedPath(path, object, value) { + let i = 0, component = path[i]; + for (; i < path.length - 1; ++i, component = path[i]) { + object[component] = object[component] == null ? { + bsonType: 'object', + properties: {} + } : object[component]; + object = object[component].properties; + } + object[component] = value; + } + + const schemaMapPropertyReducer = (accum, [path, propertyConfig]) => { + const bsonType = this.path(path).autoEncryptionType(); + const pathComponents = path.split('.'); + const configuration = { encrypt: { ...propertyConfig, bsonType } }; + buildNestedPath(pathComponents, accum, configuration); + return accum; + }; + + const properties = Object.entries(this.encryptedFields).reduce( + schemaMapPropertyReducer, + {}); + + return { + bsonType: 'object', + properties + }; +}; + /** * Add an alias for `path`. This means getting or setting the `alias` * is equivalent to getting or setting the `path`. diff --git a/lib/schema/bigint.js b/lib/schema/bigint.js index be937eafbf5..47235f47293 100644 --- a/lib/schema/bigint.js +++ b/lib/schema/bigint.js @@ -255,7 +255,7 @@ SchemaBigInt.prototype.toJSONSchema = function toJSONSchema(options) { }; SchemaBigInt.prototype.autoEncryptionType = function autoEncryptionType() { - return 'int64'; + return 'long'; }; /*! diff --git a/lib/schema/boolean.js b/lib/schema/boolean.js index ed478b95bf8..da2af1a9019 100644 --- a/lib/schema/boolean.js +++ b/lib/schema/boolean.js @@ -305,7 +305,7 @@ SchemaBoolean.prototype.toJSONSchema = function toJSONSchema(options) { }; SchemaBoolean.prototype.autoEncryptionType = function autoEncryptionType() { - return 'boolean'; + return 'bool'; }; /*! diff --git a/lib/schema/buffer.js b/lib/schema/buffer.js index f9d3027367d..2e9fdc71c04 100644 --- a/lib/schema/buffer.js +++ b/lib/schema/buffer.js @@ -315,7 +315,7 @@ SchemaBuffer.prototype.toJSONSchema = function toJSONSchema(options) { }; SchemaBuffer.prototype.autoEncryptionType = function autoEncryptionType() { - return 'binary'; + return 'binData'; }; /*! diff --git a/lib/schema/decimal128.js b/lib/schema/decimal128.js index b3d80d54a6c..9202a364248 100644 --- a/lib/schema/decimal128.js +++ b/lib/schema/decimal128.js @@ -236,7 +236,7 @@ SchemaDecimal128.prototype.toJSONSchema = function toJSONSchema(options) { }; SchemaDecimal128.prototype.autoEncryptionType = function autoEncryptionType() { - return 'decimal128'; + return 'decimal'; }; /*! diff --git a/lib/schema/int32.js b/lib/schema/int32.js index 65bfb66e174..81599f76d6d 100644 --- a/lib/schema/int32.js +++ b/lib/schema/int32.js @@ -261,7 +261,7 @@ SchemaInt32.prototype.toJSONSchema = function toJSONSchema(options) { }; SchemaInt32.prototype.autoEncryptionType = function autoEncryptionType() { - return 'int32'; + return 'int'; }; diff --git a/lib/schema/objectId.js b/lib/schema/objectId.js index fd379e014d1..cc2515513b0 100644 --- a/lib/schema/objectId.js +++ b/lib/schema/objectId.js @@ -305,7 +305,7 @@ SchemaObjectId.prototype.toJSONSchema = function toJSONSchema(options) { }; SchemaObjectId.prototype.autoEncryptionType = function autoEncryptionType() { - return 'objectid'; + return 'objectId'; }; /*! diff --git a/lib/utils.js b/lib/utils.js index e0cddc0ba6a..0cbb8662f17 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1029,3 +1029,13 @@ exports.injectTimestampsOption = function injectTimestampsOption(writeOperation, } writeOperation.timestamps = timestampsOption; }; + +exports.print = function(...args) { + const { inspect } = require('util'); + console.error( + inspect( + ...args, + { depth: Infinity } + ) + ); +}; diff --git a/scripts/configure-cluster-with-encryption.sh b/scripts/configure-cluster-with-encryption.sh index 7520e00bcd9..234d6040e38 100644 --- a/scripts/configure-cluster-with-encryption.sh +++ b/scripts/configure-cluster-with-encryption.sh @@ -8,7 +8,7 @@ export CWD=$(pwd); export DRIVERS_TOOLS_PINNED_COMMIT=35d0592c76f4f3d25a5607895eb21b491dd52543; # install extra dependency -npm install mongodb-client-encryption +npm install --no-save mongodb-client-encryption # set up mongodb cluster and encryption configuration if the data/ folder does not exist if [ ! -d "data" ]; then diff --git a/scripts/run-encryption-tests.sh b/scripts/run-encryption-tests.sh new file mode 100755 index 00000000000..0209292168d --- /dev/null +++ b/scripts/run-encryption-tests.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash + +# sets up mongodb cluster and encryption configuration, adds relevant variables to the environment, and runs encryption tests + +export CWD=$(pwd); + +# set up mongodb cluster and encryption configuration if the data/ folder does not exist +# note: for tooling, cluster set-up and configuration look into the 'scripts/configure-cluster-with-encryption.sh' script + +if [ -d "data" ]; then + cd data +else + source $CWD/scripts/configure-cluster-with-encryption.sh +fi + +# extracts MONGOOSE_TEST_URI and CRYPT_SHARED_LIB_PATH from .yml file into environment variables for this test run +read -r -d '' SOURCE_SCRIPT << EOM +const fs = require('fs'); +const file = fs.readFileSync('mo-expansion.yml', { encoding: 'utf-8' }) + .trim().split('\\n'); +const regex = /^(?.*): "(?.*)"$/; +const variables = file.map( + (line) => regex.exec(line.trim()).groups +).map( + ({key, value}) => \`export \${key}='\${value}'\` +).join('\n'); + +process.stdout.write(variables); +process.stdout.write('\n'); +EOM + +node --eval "$SOURCE_SCRIPT" | tee expansions.sh +source expansions.sh + +export MONGOOSE_TEST_URI=$MONGODB_URI + +# run encryption tests +cd .. +npx mocha --exit ./test/encryption/*.test.js diff --git a/test/encryptedSchema.test.js b/test/encryptedSchema.test.js index 5134d39864e..aa71c52500f 100644 --- a/test/encryptedSchema.test.js +++ b/test/encryptedSchema.test.js @@ -22,94 +22,174 @@ function schemaHasEncryptedProperty(schema, path) { return path in schema.encryptedFields; } -const KEY_ID = new UUID(); +const KEY_ID = '9fbdace3-4e48-412d-88df-3807e8009522'; const algorithm = 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'; describe('encrypted schema declaration', function() { - describe('Tests that fields of valid schema types can be declared as encrypted schemas', function() { - const basicSchemaTypes = [ - { type: String, name: 'string' }, - { type: Schema.Types.Boolean, name: 'boolean' }, - { type: Schema.Types.Buffer, name: 'buffer' }, - { type: Date, name: 'date' }, - { type: ObjectId, name: 'objectid' }, - { type: BigInt, name: 'bigint' }, - { type: Decimal128, name: 'Decimal128' }, - { type: Int32, name: 'int32' }, - { type: Double, name: 'double' } - ]; - - for (const { type, name } of basicSchemaTypes) { - describe(`When a schema is instantiated with an encrypted field of type ${name}`, function() { + describe('schemaMap generation tests', function() { + for (const { type, name, encryptionType, schemaMap, encryptedFields } of primitiveSchemaMapTests()) { + describe(`When a schema is instantiated with an encrypted field of type ${name} for ${encryptionType}`, function() { let schema; + const encrypt = { + keyId: KEY_ID + }; + encryptionType === 'csfle' && (encrypt.algorithm = algorithm); + beforeEach(function() { schema = new Schema({ field: { - type, encrypt: { keyId: KEY_ID, algorithm } + type, encrypt } }, { - encryptionType: 'csfle' + encryptionType }); }); it(`Then the schema has an encrypted property of type ${name}`, function() { assert.ok(schemaHasEncryptedProperty(schema, 'field')); }); - }); - } - - describe('when a schema is instantiated with a nested encrypted schema', function() { - let schema; - beforeEach(function() { - const encryptedSchema = new Schema({ - encrypted: { - type: String, encrypt: { keyId: KEY_ID, algorithm } - } - }, { encryptionType: 'csfle' }); - schema = new Schema({ - field: encryptedSchema - }, { encryptionType: 'csfle' }); - }); + encryptionType === 'csfle' && it('then the generated schemaMap is correct', function() { + assert.deepEqual(schema._buildSchemaMap(), schemaMap); + }); - it('then the schema has a nested property that is encrypted', function() { - assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])); + encryptionType === 'qe' && it('then the generated encryptedFieldsMap is correct', function() { + assert.deepEqual(schema._buildEncryptedFields(), encryptedFields); + }); }); - }); + } + }); - describe('when a schema is instantiated with a nested schema object', function() { - let schema; - beforeEach(function() { - schema = new Schema({ - field: { + describe('Tests that fields of valid schema types can be declared as encrypted schemas', function() { + const tests = { + 'nested schema for csfle': + { + schemaFactory: () => { + const encryptedSchema = new Schema({ encrypted: { type: String, encrypt: { keyId: KEY_ID, algorithm } } + }, { encryptionType: 'csfle' }); + return new Schema({ + field: encryptedSchema + }, { encryptionType: 'csfle' }); + }, predicate: (schema) => assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])), + schemaMap: { + bsonType: 'object', + properties: { + field: { + bsonType: 'object', + properties: { + encrypted: { encrypt: { bsonType: 'string', algorithm, keyId: KEY_ID } } + } + } } - }, { encryptionType: 'csfle' }); - }); - - it('then the schema has a nested property that is encrypted', function() { - assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])); - }); - }); - - describe('when a schema is instantiated as an Array', function() { - let schema; - beforeEach(function() { - schema = new Schema({ - encrypted: { - type: [Number], - encrypt: { keyId: KEY_ID, algorithm } + } + }, + 'nested schema for qe': { + schemaFactory: () => { + const encryptedSchema = new Schema({ + encrypted: { + type: String, encrypt: { keyId: KEY_ID } + } + }, { encryptionType: 'qe' }); + return new Schema({ + field: encryptedSchema + }, { encryptionType: 'qe' }); + }, predicate: (schema) => assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])), + encryptedFields: { + fields: [ + { path: 'field.encrypted', keyId: KEY_ID, bsonType: 'string' } + ] + } + }, + 'nested object for csfle': + { + schemaFactory: () => { + return new Schema({ + field: { + encrypted: { + type: String, encrypt: { keyId: KEY_ID, algorithm } + } + } + }, { encryptionType: 'csfle' }); + }, predicate: (schema) => assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])), + schemaMap: { + bsonType: 'object', + properties: { + field: { + bsonType: 'object', + properties: { + encrypted: { encrypt: { bsonType: 'string', algorithm, keyId: KEY_ID } } + } + } } - }, { encryptionType: 'csfle' }); - }); + } + }, + 'nested object for qe': { + schemaFactory: () => { + return new Schema({ + field: { + encrypted: { + type: String, encrypt: { keyId: KEY_ID } + } + } + }, { encryptionType: 'qe' }); + }, predicate: (schema) => assert.ok(schemaHasEncryptedProperty(schema, ['field', 'encrypted'])), + encryptedFields: { + fields: [ + { path: 'field.encrypted', keyId: KEY_ID, bsonType: 'string' } + ] + } + }, + 'schema with encrypted array for csfle': { + schemaFactory: () => { + return new Schema({ + encrypted: { + type: [Number], + encrypt: { keyId: KEY_ID, algorithm } + } + }, { encryptionType: 'csfle' }); + }, predicate: (schema) => assert.ok(schemaHasEncryptedProperty(schema, ['encrypted'])), + schemaMap: { + bsonType: 'object', + properties: { + encrypted: { + encrypt: { + bsonType: 'array', + keyId: KEY_ID, + algorithm + } + } + } + } + }, + 'schema with encrypted array for qe': { + schemaFactory: () => { + return new Schema({ + encrypted: { + type: [Number], + encrypt: { keyId: KEY_ID } + } + }, { encryptionType: 'qe' }); + }, predicate: (schema) => assert.ok(schemaHasEncryptedProperty(schema, ['encrypted'])), + encryptedFields: { + fields: [ + { path: 'encrypted', keyId: KEY_ID, bsonType: 'array' } + ] + } + } + }; - it('then the schema has a nested property that is encrypted', function() { - assert.ok(schemaHasEncryptedProperty(schema, 'encrypted')); - }); - }); + for (const [description, { schemaFactory, predicate, schemaMap, encryptedFields }] of Object.entries(tests)) { + it(description, function() { + const schema = schemaFactory(); + predicate(schema); + schemaMap && assert.deepEqual(schema._buildSchemaMap(), schemaMap); + encryptedFields && assert.deepEqual(schema._buildEncryptedFields(), encryptedFields); + }); + } }); describe('invalid schema types for encrypted schemas', function() { @@ -121,7 +201,7 @@ describe('encrypted schema declaration', function() { type: Number, encrypt: { keyId: KEY_ID, algorithm } } }, { encryptionType: 'csfle' }); - }, /Invalid BSON type/); + }, /Invalid BSON type for FLE field: 'field'/); }); }); @@ -133,7 +213,7 @@ describe('encrypted schema declaration', function() { type: Schema.Types.Mixed, encrypt: { keyId: KEY_ID, algorithm } } }, { encryptionType: 'csfle' }); - }, /Invalid BSON type/); + }, /Invalid BSON type for FLE field: 'field'/); }); }); @@ -159,7 +239,7 @@ describe('encrypted schema declaration', function() { type: Int8, encrypt: { keyId: KEY_ID, algorithm } } }, { encryptionType: 'csfle' }); - }, /Invalid BSON type/); + }, /Invalid BSON type for FLE field: 'field'/); }); }); @@ -536,3 +616,476 @@ describe('encrypted schema declaration', function() { }); }); }); + +function primitiveSchemaMapTests() { + return [ + { + name: 'string', + type: String, + encryptionType: 'csfle', + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'string' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'string', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'string', + type: String, + encryptionType: 'qe', + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'string' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'string', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'boolean', + type: Schema.Types.Boolean, + encryptionType: 'csfle', + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'bool' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'bool', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'boolean', + encryptionType: 'qe', + type: Schema.Types.Boolean, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'bool' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'bool', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'buffer', + encryptionType: 'csfle', + type: Schema.Types.Buffer, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'binData' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'binData', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'buffer', + encryptionType: 'qe', + type: Schema.Types.Buffer, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'binData' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'binData', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'date', + encryptionType: 'csfle', + type: Date, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'date' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'date', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'date', + encryptionType: 'qe', + type: Date, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'date' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'date', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'objectid', + encryptionType: 'csfle', + type: ObjectId, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'objectId' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'objectId', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'objectid', + encryptionType: 'qe', + type: ObjectId, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'objectId' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'objectId', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'bigint', + encryptionType: 'csfle', + type: BigInt, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'long' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'long', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'bigint', + encryptionType: 'qe', + type: BigInt, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'long' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'long', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'Decimal128', + encryptionType: 'csfle', + type: Decimal128, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'decimal' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'decimal', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'Decimal128', + encryptionType: 'qe', + type: Decimal128, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'decimal' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'decimal', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'int32', + encryptionType: 'csfle', + type: Int32, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'int' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'int', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'int32', + encryptionType: 'qe', + type: Int32, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'int' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'int', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + }, + { + name: 'double', + encryptionType: 'csfle', + type: Double, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic', + bsonType: 'double' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'double', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + } + ] + } + }, + { + name: 'double', + encryptionType: 'qe', + type: Double, + schemaMap: { + bsonType: 'object', + properties: { + field: { + encrypt: { + keyId: '9fbdace3-4e48-412d-88df-3807e8009522', + bsonType: 'double' + } + } + } + }, + encryptedFields: { + fields: [ + { + path: 'field', + bsonType: 'double', + keyId: '9fbdace3-4e48-412d-88df-3807e8009522' + } + ] + } + } + ]; +} diff --git a/test/encryption/encryption.test.js b/test/encryption/encryption.test.js index 11369408b24..d1b7e33d41d 100644 --- a/test/encryption/encryption.test.js +++ b/test/encryption/encryption.test.js @@ -1,12 +1,24 @@ 'use strict'; const assert = require('assert'); -const mongodb = require('mongodb'); -const fs = require('fs'); +const mdb = require('mongodb'); const isBsonType = require('../../lib/helpers/isBsonType'); +const { Schema, createConnection } = require('../../lib'); +const { ObjectId, Double, Int32, Decimal128 } = require('bson'); +const fs = require('fs'); const LOCAL_KEY = Buffer.from('Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk', 'base64'); +/** + * @param {object} object + * @param {string} property + */ +function isEncryptedValue(object, property) { + const value = object[property]; + assert.ok(isBsonType(value, 'Binary'), `auto encryption for property ${property} failed: not a BSON binary.`); + assert.ok(value.sub_type === 6, `auto encryption for property ${property} failed: not subtype 6.`); +} + describe('ci', () => { const cachedUri = process.env.MONGOOSE_TEST_URI; @@ -42,78 +54,739 @@ describe('ci', () => { }); }); - describe('basic integration', () => { - let keyVaultClient; - let dataKey; - let encryptedClient; - let unencryptedClient; - - beforeEach(async function() { - keyVaultClient = new mongodb.MongoClient(process.env.MONGOOSE_TEST_URI); - await keyVaultClient.connect(); - await keyVaultClient.db('keyvault').collection('datakeys'); - const clientEncryption = new mongodb.ClientEncryption(keyVaultClient, { - keyVaultNamespace: 'keyvault.datakeys', - kmsProviders: { local: { key: LOCAL_KEY } } + let keyId, keyId2, keyId3; + let utilClient; + + beforeEach(async function() { + const keyVaultClient = new mdb.MongoClient(process.env.MONGOOSE_TEST_URI); + await keyVaultClient.connect(); + await keyVaultClient.db('keyvault').collection('datakeys'); + const clientEncryption = new mdb.ClientEncryption(keyVaultClient, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } } + }); + keyId = await clientEncryption.createDataKey('local'); + keyId2 = await clientEncryption.createDataKey('local'); + keyId3 = await clientEncryption.createDataKey('local'); + await keyVaultClient.close(); + + utilClient = new mdb.MongoClient(process.env.MONGOOSE_TEST_URI); + }); + + afterEach(async function() { + await utilClient.db('db').dropDatabase({ + w: 'majority' + }); + await utilClient.close(); + }); + + describe('Tests that fields of valid schema types can be declared as encrypted schemas', function() { + const algorithm = 'AEAD_AES_256_CBC_HMAC_SHA_512-Random'; + let connection; + let schema; + let model; + + const basicSchemaTypes = [ + { type: String, name: 'string', input: 3, expected: 3 }, + { type: Schema.Types.Boolean, name: 'boolean', input: true, expected: true }, + { type: Schema.Types.Buffer, name: 'buffer', input: Buffer.from([1, 2, 3]) }, + { type: Date, name: 'date', input: new Date(12, 12, 2012), expected: new Date(12, 12, 2012) }, + { type: ObjectId, name: 'objectid', input: new ObjectId() }, + { type: BigInt, name: 'bigint', input: 3n }, + { type: Decimal128, name: 'Decimal128', input: new Decimal128('1.5') }, + { type: Int32, name: 'int32', input: new Int32(5), expected: 5 }, + { type: Double, name: 'double', input: new Double(1.5) } + ]; + + for (const { type, name, input, expected } of basicSchemaTypes) { + this.afterEach(async function() { + await connection?.close(); }); - dataKey = await clientEncryption.createDataKey('local'); - encryptedClient = new mongodb.MongoClient( - process.env.MONGOOSE_TEST_URI, - { - autoEncryption: { - keyVaultNamespace: 'keyvault.datakeys', - kmsProviders: { local: { key: LOCAL_KEY } }, - schemaMap: { - 'db.coll': { - bsonType: 'object', - encryptMetadata: { - keyId: [dataKey] - }, - properties: { - a: { - encrypt: { - bsonType: 'int', - algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Random', - keyId: [dataKey] - } + // eslint-disable-next-line no-inner-declarations + async function test() { + const [{ _id }] = await model.insertMany([{ field: input }]); + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.field, 'Binary')); + assert.ok(encryptedDoc.field.sub_type === 6); + + const doc = await model.findOne({ _id }); + if (Buffer.isBuffer(input)) { + // mongoose's Buffer does not support deep equality - instead use the Buffer.equals method. + assert.ok(doc.field.equals(input)); + } else { + assert.deepEqual(doc.field, expected ?? input); + } + } + + describe('CSFLE', function() { + beforeEach(async function() { + schema = new Schema({ + field: { + type, encrypt: { keyId: [keyId], algorithm } + } + }, { + encryptionType: 'csfle' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + }); + + it(`${name} encrypts and decrypts`, test); + }); + + describe('queryableEncryption', function() { + beforeEach(async function() { + schema = new Schema({ + field: { + type, encrypt: { keyId: keyId } + } + }, { + encryptionType: 'queryableEncryption' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + }); + + it(`${name} encrypts and decrypts`, test); + }); + } + + describe('nested object schemas', function() { + const tests = { + 'nested object schemas for CSFLE': { + modelFactory: () => { + const schema = new Schema({ + a: { + b: { + c: { + type: String, + encrypt: { keyId: [keyId], algorithm } + } + } + } + }, { + encryptionType: 'csfle' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + + } + }, + 'nested object schemas for QE': { + modelFactory: () => { + const schema = new Schema({ + a: { + b: { + c: { + type: String, + encrypt: { keyId: keyId } } } } - }, + }, { + encryptionType: 'queryableEncryption' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + + } + }, + 'nested schemas for csfle': { + modelFactory: () => { + const nestedSchema = new Schema({ + b: { + c: { + type: String, + encrypt: { keyId: [keyId], algorithm } + } + } + }, { + encryptionType: 'csfle' + }); + + const schema = new Schema({ + a: nestedSchema + }, { + encryptionType: 'csfle' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + + } + }, + 'nested schemas for QE': { + modelFactory: () => { + const nestedSchema = new Schema({ + b: { + c: { + type: String, + encrypt: { keyId: keyId } + } + } + }, { + encryptionType: 'queryableEncryption' + }); + const schema = new Schema({ + a: nestedSchema + }, { + encryptionType: 'queryableEncryption' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + + } + } + }; + + for (const [description, { modelFactory }] of Object.entries(tests)) { + describe(description, function() { + it('encrypts and decrypts', async function() { + const { model } = modelFactory(); + + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + + const [{ _id }] = await model.insertMany([{ a: { b: { c: 'hello' } } }]); + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.a.b.c, 'Binary')); + assert.ok(encryptedDoc.a.b.c.sub_type === 6); + + const doc = await model.findOne({ _id }); + assert.deepEqual(doc.a.b.c, 'hello'); + }); + }); + } + }); + + describe('array encrypted fields', function() { + const tests = { + 'array fields for CSFLE': { + modelFactory: () => { + const schema = new Schema({ + a: { + type: [Int32], + encrypt: { + keyId: [keyId], + algorithm + } + } + }, { + encryptionType: 'csfle' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + } + }, + 'array field for QE': { + modelFactory: () => { + const schema = new Schema({ + a: { + type: [Int32], + encrypt: { + keyId + } + } + }, { + encryptionType: 'queryableEncryption' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + } + } + }; + + for (const [description, { modelFactory }] of Object.entries(tests)) { + describe(description, function() { + it('encrypts and decrypts', async function() { + const { model } = modelFactory(); + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + + const [{ _id }] = await model.insertMany([{ a: [new Int32(3)] }]); + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.a, 'Binary')); + assert.ok(encryptedDoc.a.sub_type === 6); + + const doc = await model.findOne({ _id }); + assert.deepEqual(doc.a, [3]); + }); + }); + } + }); + + describe('multiple encrypted fields in a model', function() { + const tests = { + 'multiple fields in a schema for CSFLE': { + modelFactory: () => { + const encrypt = { + keyId: [keyId], + algorithm + }; + + const schema = new Schema({ + a: { + type: String, + encrypt + }, + b: { + type: BigInt + }, + c: { + d: { + type: String, + encrypt + } + } + }, { + encryptionType: 'csfle' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + } + }, + 'multiple fields in a schema for QE': { + modelFactory: () => { + const schema = new Schema({ + a: { + type: String, + encrypt: { + keyId + } + }, + b: { + type: BigInt + }, + c: { + d: { + type: String, + encrypt: { + keyId: keyId2 + } + } + } + }, { + encryptionType: 'queryableEncryption' + }); + + connection = createConnection(); + model = connection.model('Schema', schema); + return { model }; + } + } + }; + + for (const [description, { modelFactory }] of Object.entries(tests)) { + describe(description, function() { + it('encrypts and decrypts', async function() { + const { model } = modelFactory(); + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + + const [{ _id }] = await model.insertMany([{ a: 'hello', b: 1n, c: { d: 'world' } }]); + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.a, 'Binary')); + assert.ok(encryptedDoc.a.sub_type === 6); + assert.ok(typeof encryptedDoc.b === 'number'); + assert.ok(isBsonType(encryptedDoc.c.d, 'Binary')); + assert.ok(encryptedDoc.c.d.sub_type === 6); + + const doc = await model.findOne({ _id }, {}); + assert.deepEqual(doc.a, 'hello'); + assert.deepEqual(doc.b, 1n); + assert.deepEqual(doc.c, { d: 'world' }); + }); + }); + } + }); + + describe('multiple schemas', function() { + const tests = { + 'multiple schemas for CSFLE': { + modelFactory: () => { + connection = createConnection(); + const encrypt = { + keyId: [keyId], + algorithm + }; + const model1 = connection.model('Model1', new Schema({ + a: { + type: String, + encrypt + } + }, { + encryptionType: 'csfle' + })); + const model2 = connection.model('Model2', new Schema({ + b: { + type: String, + encrypt + } + }, { + encryptionType: 'csfle' + })); + + return { model1, model2 }; + } + }, + 'multiple schemas for QE': { + modelFactory: () => { + connection = createConnection(); + const model1 = connection.model('Model1', new Schema({ + a: { + type: String, + encrypt: { + keyId + } + } + }, { + encryptionType: 'queryableEncryption' + })); + const model2 = connection.model('Model2', new Schema({ + b: { + type: String, + encrypt: { + keyId + } + } + }, { + encryptionType: 'queryableEncryption' + })); + + return { model1, model2 }; + } + } + }; + + for (const [description, { modelFactory }] of Object.entries(tests)) { + describe(description, function() { + it('encrypts and decrypts', async function() { + const { model1, model2 } = modelFactory(); + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + + { + const [{ _id }] = await model1.insertMany([{ a: 'hello' }]); + const encryptedDoc = await utilClient.db('db').collection('model1').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.a, 'Binary')); + assert.ok(encryptedDoc.a.sub_type === 6); + + const doc = await model1.findOne({ _id }); + assert.deepEqual(doc.a, 'hello'); + } + + { + const [{ _id }] = await model2.insertMany([{ b: 'world' }]); + const encryptedDoc = await utilClient.db('db').collection('model2').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.b, 'Binary')); + assert.ok(encryptedDoc.b.sub_type === 6); + + const doc = await model2.findOne({ _id }); + assert.deepEqual(doc.b, 'world'); + } + }); + }); + } + }); + + describe('CSFLE and QE schemas on the same connection', function() { + it('encrypts and decrypts', async function() { + connection = createConnection(); + const model1 = connection.model('Model1', new Schema({ + a: { + type: String, + encrypt: { + keyId + } + } + }, { + encryptionType: 'queryableEncryption' + })); + const model2 = connection.model('Model2', new Schema({ + b: { + type: String, + encrypt: { + keyId: [keyId], + algorithm + } + } + }, { + encryptionType: 'csfle' + })); + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, extraOptions: { cryptdSharedLibRequired: true, cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH } } + }); + + { + const [{ _id }] = await model1.insertMany([{ a: 'hello' }]); + const encryptedDoc = await utilClient.db('db').collection('model1').findOne({ _id }); + + assert.ok(isBsonType(encryptedDoc.a, 'Binary')); + assert.ok(encryptedDoc.a.sub_type === 6); + + const doc = await model1.findOne({ _id }); + assert.deepEqual(doc.a, 'hello'); } - ); - unencryptedClient = new mongodb.MongoClient(process.env.MONGOOSE_TEST_URI); - }); + { + const [{ _id }] = await model2.insertMany([{ b: 'world' }]); + const encryptedDoc = await utilClient.db('db').collection('model2').findOne({ _id }); - afterEach(async function() { - await keyVaultClient.close(); - await encryptedClient.close(); - await unencryptedClient.close(); + assert.ok(isBsonType(encryptedDoc.b, 'Binary')); + assert.ok(encryptedDoc.b.sub_type === 6); + + const doc = await model2.findOne({ _id }); + assert.deepEqual(doc.b, 'world'); + } + }); }); - it('ci set-up should support basic mongodb auto-encryption integration', async() => { - await encryptedClient.connect(); - const { insertedId } = await encryptedClient.db('db').collection('coll').insertOne({ a: 1 }); + describe('Models with discriminators', function() { + let discrim1, discrim2, model; - // client not configured with autoEncryption, returns a encrypted binary type, meaning that encryption succeeded - const encryptedResult = await unencryptedClient.db('db').collection('coll').findOne({ _id: insertedId }); + describe('csfle', function() { + beforeEach(async function() { + connection = createConnection(); + + const schema = new Schema({ + name: { + type: String, encrypt: { keyId: [keyId], algorithm } + } + }, { + encryptionType: 'csfle' + }); + model = connection.model('Schema', schema); + discrim1 = model.discriminator('Test', new Schema({ + age: { + type: Int32, encrypt: { keyId: [keyId], algorithm } + } + }, { + encryptionType: 'csfle' + })); - assert.ok(encryptedResult); - assert.ok(encryptedResult.a); - assert.ok(isBsonType(encryptedResult.a, 'Binary')); - assert.ok(encryptedResult.a.sub_type === 6); + discrim2 = model.discriminator('Test2', new Schema({ + dob: { + type: Int32, encrypt: { keyId: [keyId], algorithm } + } + }, { + encryptionType: 'csfle' + })); + + + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + }); + it('encrypts', async function() { + { + const doc = new discrim1({ name: 'bailey', age: 32 }); + await doc.save(); + + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id: doc._id }); + + isEncryptedValue(encryptedDoc, 'age'); + } + + { + const doc = new discrim2({ name: 'bailey', dob: 32 }); + await doc.save(); + + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id: doc._id }); + + isEncryptedValue(encryptedDoc, 'dob'); + } + }); + + it('decrypts', async function() { + { + const doc = new discrim1({ name: 'bailey', age: 32 }); + await doc.save(); + + const decryptedDoc = await discrim1.findOne({ _id: doc._id }); + + assert.equal(decryptedDoc.age, 32); + } + + { + const doc = new discrim2({ name: 'bailey', dob: 32 }); + await doc.save(); + + const decryptedDoc = await discrim2.findOne({ _id: doc._id }); + + assert.equal(decryptedDoc.dob, 32); + } + }); + }); + + + describe('queryableEncryption', function() { + beforeEach(async function() { + connection = createConnection(); + + const schema = new Schema({ + name: { + type: String, encrypt: { keyId } + } + }, { + encryptionType: 'queryableEncryption' + }); + model = connection.model('Schema', schema); + discrim1 = model.discriminator('Test', new Schema({ + age: { + type: Int32, encrypt: { keyId: keyId2 } + } + }, { + encryptionType: 'queryableEncryption' + })); + + discrim2 = model.discriminator('Test2', new Schema({ + dob: { + type: Int32, encrypt: { keyId: keyId3 } + } + }, { + encryptionType: 'queryableEncryption' + })); + + await connection.openUri(process.env.MONGOOSE_TEST_URI, { + dbName: 'db', autoEncryption: { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: { local: { key: LOCAL_KEY } }, + extraOptions: { + cryptdSharedLibRequired: true, + cryptSharedLibPath: process.env.CRYPT_SHARED_LIB_PATH + } + } + }); + }); + it('encrypts', async function() { + { + const doc = new discrim1({ name: 'bailey', age: 32 }); + await doc.save(); + + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id: doc._id }); + + isEncryptedValue(encryptedDoc, 'age'); + } + + { + const doc = new discrim2({ name: 'bailey', dob: 32 }); + await doc.save(); + + const encryptedDoc = await utilClient.db('db').collection('schemas').findOne({ _id: doc._id }); + + isEncryptedValue(encryptedDoc, 'dob'); + } + }); + + it('decrypts', async function() { + { + const doc = new discrim1({ name: 'bailey', age: 32 }); + await doc.save(); + + const decryptedDoc = await discrim1.findOne({ _id: doc._id }); + + assert.equal(decryptedDoc.age, 32); + } + + { + const doc = new discrim2({ name: 'bailey', dob: 32 }); + await doc.save(); + + const decryptedDoc = await discrim2.findOne({ _id: doc._id }); + + assert.equal(decryptedDoc.dob, 32); + } + }); + }); - // when the encryptedClient runs a find, the original unencrypted value is returned - const unencryptedResult = await encryptedClient.db('db').collection('coll').findOne({ _id: insertedId }); - assert.ok(unencryptedResult); - assert.ok(unencryptedResult.a === 1); }); }); }); From f1c986cdcd1bf4f8dd25bce921dc26d805f79317 Mon Sep 17 00:00:00 2001 From: bailey Date: Tue, 18 Mar 2025 08:14:11 -0600 Subject: [PATCH 2/6] misc test cleanups --- test/encryption/encryption.test.js | 34 +++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/test/encryption/encryption.test.js b/test/encryption/encryption.test.js index d1b7e33d41d..e821482b5e9 100644 --- a/test/encryption/encryption.test.js +++ b/test/encryption/encryption.test.js @@ -6,6 +6,7 @@ const isBsonType = require('../../lib/helpers/isBsonType'); const { Schema, createConnection } = require('../../lib'); const { ObjectId, Double, Int32, Decimal128 } = require('bson'); const fs = require('fs'); +const mongoose = require('../../lib'); const LOCAL_KEY = Buffer.from('Mng0NCt4ZHVUYUJCa1kxNkVyNUR1QURhZ2h2UzR2d2RrZzh0cFBwM3R6NmdWMDFBMUN3YkQ5aXRRMkhGRGdQV09wOGVNYUMxT2k3NjZKelhaQmRCZGJkTXVyZG9uSjFk', 'base64'); @@ -19,20 +20,22 @@ function isEncryptedValue(object, property) { assert.ok(value.sub_type === 6, `auto encryption for property ${property} failed: not subtype 6.`); } -describe('ci', () => { - +describe('encryption integration tests', () => { const cachedUri = process.env.MONGOOSE_TEST_URI; const cachedLib = process.env.CRYPT_SHARED_LIB_PATH; before(function() { const cwd = process.cwd(); const file = fs.readFileSync(cwd + '/data/mo-expansion.yml', { encoding: 'utf-8' }).trim().split('\n'); + + // matches `key="value"` and extracts key and value. const regex = /^(?.*): "(?.*)"$/; - const variables = file.map((line) => regex.exec(line.trim()).groups).reduce((acc, { key, value }) => ({ ...acc, [key]: value }), {}); + const variables = Object.fromEntries(file.map((line) => regex.exec(line.trim()).groups).map(({ key, value }) => [key, value])); console.log('File contents', file); console.log('Variables', variables); - process.env.CRYPT_SHARED_LIB_PATH = variables.CRYPT_SHARED_LIB_PATH; - process.env.MONGOOSE_TEST_URI = variables.MONGODB_URI; + + process.env.CRYPT_SHARED_LIB_PATH ??= variables.CRYPT_SHARED_LIB_PATH; + process.env.MONGOOSE_TEST_URI ??= variables.MONGODB_URI; }); after(function() { @@ -40,16 +43,23 @@ describe('ci', () => { process.env.MONGOOSE_TEST_URI = cachedUri; }); - describe('environmental variables', () => { + describe('meta: environmental variables are correctly set up', () => { it('MONGOOSE_TEST_URI is set', async function() { const uri = process.env.MONGOOSE_TEST_URI; - console.log('MONGOOSE_TEST_URI=', uri); assert.ok(uri); }); + it('MONGOOSE_TEST_URI points to running cluster', async function() { + try { + const connection = await mongoose.connect(process.env.MONGOOSE_TEST_URI); + await connection.disconnect(); + } catch (error) { + throw new Error('Unable to connect to running cluster', { cause: error }); + } + }); + it('CRYPT_SHARED_LIB_PATH is set', async function() { const shared_library_path = process.env.CRYPT_SHARED_LIB_PATH; - console.log('CRYPT_SHARED_LIB_PATH=', shared_library_path); assert.ok(shared_library_path); }); }); @@ -98,11 +108,11 @@ describe('ci', () => { { type: Double, name: 'double', input: new Double(1.5) } ]; - for (const { type, name, input, expected } of basicSchemaTypes) { - this.afterEach(async function() { - await connection?.close(); - }); + afterEach(async function() { + await connection?.close(); + }); + for (const { type, name, input, expected } of basicSchemaTypes) { // eslint-disable-next-line no-inner-declarations async function test() { const [{ _id }] = await model.insertMany([{ field: input }]); From 682fb117e01f85b09336eb9ebf50f6743e220e94 Mon Sep 17 00:00:00 2001 From: bailey Date: Tue, 18 Mar 2025 07:54:41 -0600 Subject: [PATCH 3/6] update setup script --- scripts/configure-cluster-with-encryption.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/scripts/configure-cluster-with-encryption.sh b/scripts/configure-cluster-with-encryption.sh index 234d6040e38..d7a93f8a623 100644 --- a/scripts/configure-cluster-with-encryption.sh +++ b/scripts/configure-cluster-with-encryption.sh @@ -4,8 +4,8 @@ # this script downloads all tools required to use FLE with mongodb, then starts a cluster of the provided configuration (sharded on 8.0 server) -export CWD=$(pwd); -export DRIVERS_TOOLS_PINNED_COMMIT=35d0592c76f4f3d25a5607895eb21b491dd52543; +export CWD=$(pwd) +export DRIVERS_TOOLS_PINNED_COMMIT=4e18803c074231ec9fc3ace8f966e2c49d9874bb # install extra dependency npm install --no-save mongodb-client-encryption @@ -33,11 +33,9 @@ if [ ! -d "data" ]; then # configure cluster settings export DRIVERS_TOOLS=$CWD/data/drivers-evergreen-tools - export MONGODB_VERSION=8.0 - export AUTH=true + export AUTH=auth export MONGODB_BINARIES=$DRIVERS_TOOLS/mongodb/bin - export MONGO_ORCHESTRATION_HOME=$DRIVERS_TOOLS/mo - export PROJECT_ORCHESTRATION_HOME=$DRIVERS_TOOLS/.evergreen/orchestration + export MONGO_ORCHESTRATION_HOME=$DRIVERS_TOOLS/.evergreen/orchestration export TOPOLOGY=sharded_cluster export SSL=nossl @@ -46,12 +44,12 @@ if [ ! -d "data" ]; then mkdir mo cd - - rm expansions.sh 2> /dev/null + rm expansions.sh 2>/dev/null echo 'Configuring Cluster...' # start cluster - (bash $DRIVERS_TOOLS/.evergreen/run-orchestration.sh) 1> /dev/null 2> /dev/null + (bash $DRIVERS_TOOLS/.evergreen/run-orchestration.sh) 1>/dev/null 2>/dev/null echo 'Cluster Configuration Finished!' From cda0e267271513e2033ab608eb5a810c41bcfe56 Mon Sep 17 00:00:00 2001 From: bailey Date: Tue, 18 Mar 2025 09:06:45 -0600 Subject: [PATCH 4/6] misc cleanup --- lib/utils.js | 10 --------- scripts/run-encryption-tests.sh | 39 --------------------------------- 2 files changed, 49 deletions(-) delete mode 100755 scripts/run-encryption-tests.sh diff --git a/lib/utils.js b/lib/utils.js index 0cbb8662f17..e0cddc0ba6a 100644 --- a/lib/utils.js +++ b/lib/utils.js @@ -1029,13 +1029,3 @@ exports.injectTimestampsOption = function injectTimestampsOption(writeOperation, } writeOperation.timestamps = timestampsOption; }; - -exports.print = function(...args) { - const { inspect } = require('util'); - console.error( - inspect( - ...args, - { depth: Infinity } - ) - ); -}; diff --git a/scripts/run-encryption-tests.sh b/scripts/run-encryption-tests.sh deleted file mode 100755 index 0209292168d..00000000000 --- a/scripts/run-encryption-tests.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env bash - -# sets up mongodb cluster and encryption configuration, adds relevant variables to the environment, and runs encryption tests - -export CWD=$(pwd); - -# set up mongodb cluster and encryption configuration if the data/ folder does not exist -# note: for tooling, cluster set-up and configuration look into the 'scripts/configure-cluster-with-encryption.sh' script - -if [ -d "data" ]; then - cd data -else - source $CWD/scripts/configure-cluster-with-encryption.sh -fi - -# extracts MONGOOSE_TEST_URI and CRYPT_SHARED_LIB_PATH from .yml file into environment variables for this test run -read -r -d '' SOURCE_SCRIPT << EOM -const fs = require('fs'); -const file = fs.readFileSync('mo-expansion.yml', { encoding: 'utf-8' }) - .trim().split('\\n'); -const regex = /^(?.*): "(?.*)"$/; -const variables = file.map( - (line) => regex.exec(line.trim()).groups -).map( - ({key, value}) => \`export \${key}='\${value}'\` -).join('\n'); - -process.stdout.write(variables); -process.stdout.write('\n'); -EOM - -node --eval "$SOURCE_SCRIPT" | tee expansions.sh -source expansions.sh - -export MONGOOSE_TEST_URI=$MONGODB_URI - -# run encryption tests -cd .. -npx mocha --exit ./test/encryption/*.test.js From edfbdfa20c23cd31d30c2984a7692758757fc0ea Mon Sep 17 00:00:00 2001 From: bailey Date: Tue, 18 Mar 2025 09:23:14 -0600 Subject: [PATCH 5/6] documentation --- docs/field-level-encryption.md | 55 ++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/docs/field-level-encryption.md b/docs/field-level-encryption.md index 828bcd36664..d9e20b763b2 100644 --- a/docs/field-level-encryption.md +++ b/docs/field-level-encryption.md @@ -161,3 +161,58 @@ Encrypted schemas must be registered on a connection, not the Mongoose global: const connection = mongoose.createConnection(); const UserModel = connection.model('User', encryptedUserSchema); ``` + +### Connecting and configuring encryption options + +CSFLE/QE in Mongoose work by generating the encryption schema that the MongoDB driver expects for each encrypted model on the connection. This happens automatically the model's connection is established. + +Queryable encryption and csfle requires all the same configuration as outlined in <>, except for the schemaMap or encryptedFieldsMap options. + +```javascript +const keyVaultNamespace = 'client.encryption'; +const kmsProviders = { local: { key } }; +await connection.openUri(`mongodb://localhost:27017`, { + // Configure auto encryption + autoEncryption: { + keyVaultNamespace: 'datakeys.datakeys', + kmsProviders + } +}); +``` + +Once the connection is established, Mongoose's operations will work as usual. Writes are encrypted automatically by the MongoDB driver prior to sending them to the server and reads are decrypted by the driver after fetching documents from the server. + +### Discriminators + +Discriminators are supported for encrypted models as well: + +```javascript +const connection = createConnection(); + +const schema = new Schema({ + name: { + type: String, encrypt: { keyId } + } +}, { + encryptionType: 'queryableEncryption' +}); + +const Model = connection.model('BaseUserModel', schema); +const ModelWithAge = model.discriminator('ModelWithAge', new Schema({ + age: { + type: Int32, encrypt: { keyId: keyId2 } + } +}, { + encryptionType: 'queryableEncryption' +})); + +const ModelWithBirthday = model.discriminator('ModelWithBirthday', new Schema({ + dob: { + type: Int32, encrypt: { keyId: keyId3 } + } +}, { + encryptionType: 'queryableEncryption' +})); +``` + +When generating encryption schemas, Mongoose merges all discriminators together for the all discriminators declared on the same namespace. As a result, discriminators that declare the same key with different types are not supported. Furthermore, all discriminators must share the same encryption type - it is not possible to configure discriminators on the same model for both CSFLE and QE. \ No newline at end of file From 1a48b98d1d1755223d2dbd45fd0ca037ad86b6fb Mon Sep 17 00:00:00 2001 From: bailey Date: Tue, 18 Mar 2025 10:36:02 -0600 Subject: [PATCH 6/6] doc comments --- lib/drivers/node-mongodb-native/connection.js | 3 +-- lib/schema.js | 6 ++++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/drivers/node-mongodb-native/connection.js b/lib/drivers/node-mongodb-native/connection.js index 3befc25a002..bc412e75d4b 100644 --- a/lib/drivers/node-mongodb-native/connection.js +++ b/lib/drivers/node-mongodb-native/connection.js @@ -359,8 +359,7 @@ NativeConnection.prototype.createClient = async function createClient(uri, optio * a schemaMap and/or an encryptedFieldsMap for the connection, combining all models * into a single schemaMap and encryptedFields map. * - * @returns a copy of the options object with a schemaMap and/or an encryptedFieldsMap added to the options' autoEncryption - * options. + * @returns the generated schemaMap and encryptedFieldsMap */ NativeConnection.prototype._buildEncryptionSchemas = function() { const qeMappings = {}; diff --git a/lib/schema.js b/lib/schema.js index 2494a1ccee1..d0032c15a92 100644 --- a/lib/schema.js +++ b/lib/schema.js @@ -914,6 +914,9 @@ Schema.prototype._hasEncryptedFields = function _hasEncryptedFields() { return Object.keys(this.encryptedFields).length > 0; }; +/** + * Builds an encryptedFieldsMap for the schema. + */ Schema.prototype._buildEncryptedFields = function() { const fields = Object.entries(this.encryptedFields).map( ([path, config]) => { @@ -925,6 +928,9 @@ Schema.prototype._buildEncryptedFields = function() { return { fields }; }; +/** + * Builds a schemaMap for the schema, if the schema is configured for client-side field level encryption. + */ Schema.prototype._buildSchemaMap = function() { /** * `schemaMap`s are JSON schemas, which use the following structure to represent objects: