diff --git a/.eslintignore b/.eslintignore index 39a231fa1fc..fdc4ad443c6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -2,3 +2,6 @@ lib test/disabled !etc/docs + +src/client-side-encryption +test/unit/client-side-encryption diff --git a/.evergreen/config.in.yml b/.evergreen/config.in.yml index f1b94201d76..dd30ee38a3e 100644 --- a/.evergreen/config.in.yml +++ b/.evergreen/config.in.yml @@ -196,6 +196,8 @@ functions: export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export AWS_DEFAULT_REGION='us-east-1' export KMIP_TLS_CA_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/ca.pem" export KMIP_TLS_CERT_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/client.pem" @@ -250,6 +252,8 @@ functions: export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export AWS_DEFAULT_REGION='us-east-1' export KMIP_TLS_CA_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/ca.pem" export KMIP_TLS_CERT_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/client.pem" @@ -557,6 +561,8 @@ functions: cat < prepare_client_encryption.sh export CLIENT_ENCRYPTION='${CLIENT_ENCRYPTION}' export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' export CSFLE_GIT_REF='${CSFLE_GIT_REF}' @@ -974,6 +980,8 @@ functions: export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export CSFLE_GIT_REF='${CSFLE_GIT_REF}' export CDRIVER_GIT_REF='${CDRIVER_GIT_REF}' EOT diff --git a/.evergreen/config.yml b/.evergreen/config.yml index d99cd749cc8..8b1c7752674 100644 --- a/.evergreen/config.yml +++ b/.evergreen/config.yml @@ -165,6 +165,8 @@ functions: export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export AWS_DEFAULT_REGION='us-east-1' export KMIP_TLS_CA_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/ca.pem" export KMIP_TLS_CERT_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/client.pem" @@ -218,6 +220,8 @@ functions: export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export AWS_DEFAULT_REGION='us-east-1' export KMIP_TLS_CA_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/ca.pem" export KMIP_TLS_CERT_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/client.pem" @@ -501,6 +505,8 @@ functions: cat < prepare_client_encryption.sh export CLIENT_ENCRYPTION='${CLIENT_ENCRYPTION}' export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' export CSFLE_GIT_REF='${CSFLE_GIT_REF}' @@ -921,6 +927,8 @@ functions: export CSFLE_KMS_PROVIDERS='${CSFLE_KMS_PROVIDERS}' export AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' export AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' + export AWS_REGION='${AWS_REGION}' + export AWS_CMK_ID='${AWS_CMK_ID}' export CSFLE_GIT_REF='${CSFLE_GIT_REF}' export CDRIVER_GIT_REF='${CDRIVER_GIT_REF}' EOT @@ -2668,7 +2676,7 @@ tasks: - func: bootstrap kms servers - func: run custom csfle tests vars: - CSFLE_GIT_REF: c56c70340093070b1ef5c8a28190187eea21a6e9 + CSFLE_GIT_REF: 974a4614f8c1c3786e5e39fa63568d83f4f69ebd - name: run-custom-csfle-tests-5.0-master tags: - run-custom-dependency-tests @@ -2698,7 +2706,7 @@ tasks: - func: bootstrap kms servers - func: run custom csfle tests vars: - CSFLE_GIT_REF: c56c70340093070b1ef5c8a28190187eea21a6e9 + CSFLE_GIT_REF: 974a4614f8c1c3786e5e39fa63568d83f4f69ebd - name: run-custom-csfle-tests-rapid-master tags: - run-custom-dependency-tests @@ -2728,7 +2736,7 @@ tasks: - func: bootstrap kms servers - func: run custom csfle tests vars: - CSFLE_GIT_REF: c56c70340093070b1ef5c8a28190187eea21a6e9 + CSFLE_GIT_REF: 974a4614f8c1c3786e5e39fa63568d83f4f69ebd - name: run-custom-csfle-tests-latest-master tags: - run-custom-dependency-tests @@ -3646,7 +3654,6 @@ buildvariants: - test-auth-ldap - test-auth-oidc - test-socks5 - - test-socks5-csfle - test-socks5-tls - test-tls-support-latest - test-tls-support-6.0 @@ -3697,7 +3704,6 @@ buildvariants: - test-auth-ldap - test-auth-oidc - test-socks5 - - test-socks5-csfle - test-socks5-tls - test-tls-support-latest - test-tls-support-6.0 diff --git a/.evergreen/generate_evergreen_tasks.js b/.evergreen/generate_evergreen_tasks.js index 08f0300d3ca..2ef05d4fe5c 100644 --- a/.evergreen/generate_evergreen_tasks.js +++ b/.evergreen/generate_evergreen_tasks.js @@ -643,7 +643,7 @@ BUILD_VARIANTS.push({ const oneOffFuncAsTasks = []; -const FLE_PINNED_COMMIT = 'c56c70340093070b1ef5c8a28190187eea21a6e9'; +const FLE_PINNED_COMMIT = '974a4614f8c1c3786e5e39fa63568d83f4f69ebd'; for (const version of ['5.0', 'rapid', 'latest']) { for (const ref of [FLE_PINNED_COMMIT, 'master']) { @@ -807,6 +807,13 @@ for (const variant of BUILD_VARIANTS.filter( variant.tasks = variant.tasks.filter(name => !['test-socks5'].includes(name)); } +// TODO(NODE-5283): fix socks5 fle tests on node 20+ +for (const variant of BUILD_VARIANTS.filter( + variant => variant.expansions && [20].includes(variant.expansions.NODE_LTS_VERSION) +) ) { + variant.tasks = variant.tasks.filter(name => !['test-socks5-csfle'].includes(name)); +} + const fileData = yaml.load(fs.readFileSync(`${__dirname}/config.in.yml`, 'utf8')); fileData.tasks = (fileData.tasks || []) .concat(BASE_TASKS) diff --git a/.evergreen/run-azure-kms-tests.sh b/.evergreen/run-azure-kms-tests.sh index fd0d47fd9b1..5f1d53de3c8 100644 --- a/.evergreen/run-azure-kms-tests.sh +++ b/.evergreen/run-azure-kms-tests.sh @@ -9,7 +9,7 @@ source ".evergreen/init-node-and-npm-env.sh" set -o xtrace -npm install mongodb-client-encryption +npm install mongodb-client-encryption@alpha --force export MONGODB_URI="mongodb://localhost:27017" diff --git a/.evergreen/run-custom-csfle-tests.sh b/.evergreen/run-custom-csfle-tests.sh index 018fe5974ad..a10f4af5b61 100644 --- a/.evergreen/run-custom-csfle-tests.sh +++ b/.evergreen/run-custom-csfle-tests.sh @@ -55,54 +55,20 @@ pushd libmongocrypt/bindings/node npm install --production --ignore-scripts bash ./etc/build-static.sh +npm run rebuild # just in case this is necessary? + +ls +ls lib +BINDINGS_DIR=$(pwd) popd # libmongocrypt/bindings/node popd # ../csfle-deps-tmp # copy mongodb-client-encryption into driver's node_modules -cp -R ../csfle-deps-tmp/libmongocrypt/bindings/node node_modules/mongodb-client-encryption +npm link $BINDINGS_DIR export MONGODB_URI=${MONGODB_URI} export KMIP_TLS_CA_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/ca.pem" export KMIP_TLS_CERT_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/client.pem" export TEST_CSFLE=true -set +o errexit # We want to run both test suites even if the first fails npm run check:csfle -DRIVER_CSFLE_TEST_RESULT=$? -set -o errexit - -# Great! our drivers tests ran -# there are tests inside the bindings repo that we also want to check - -pushd ../csfle-deps-tmp/libmongocrypt/bindings/node - -# a mongocryptd was certainly started by the driver tests, -# let us let the bindings tests start their own -killall mongocryptd || true - -# only prod deps were installed earlier, install devDependencies here (except for mongodb!) -npm install --ignore-scripts - -# copy mongodb into CSFLE's node_modules -rm -rf node_modules/mongodb -cp -R "$ABS_PATH_TO_PATCH" node_modules/mongodb -pushd node_modules/mongodb -# lets be sure we have compiled TS since driver tests don't need to compile -npm run build:ts -popd # node_modules/mongodb - -# this variable needs to be empty -export MONGODB_NODE_SKIP_LIVE_TESTS="" -# all of the below must be defined (as well as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY) -export AWS_REGION="us-east-1" -export AWS_CMK_ID="arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0" - -npm test -- --colors - -popd # ../csfle-deps-tmp/libmongocrypt/bindings/node - -# Exit the script in a way that will show evergreen a pass or fail -if [ $DRIVER_CSFLE_TEST_RESULT -ne 0 ]; then - echo "Driver tests failed, look above for results" - exit 1 -fi diff --git a/.evergreen/run-gcp-kms-tests.sh b/.evergreen/run-gcp-kms-tests.sh index e89d30dba3c..b3c4aeaa2b4 100644 --- a/.evergreen/run-gcp-kms-tests.sh +++ b/.evergreen/run-gcp-kms-tests.sh @@ -9,7 +9,7 @@ source ".evergreen/init-node-and-npm-env.sh" set -o xtrace -npm install mongodb-client-encryption +npm install mongodb-client-encryption@alpha --force npm install gcp-metadata export MONGODB_URI="mongodb://localhost:27017" diff --git a/.evergreen/run-serverless-tests.sh b/.evergreen/run-serverless-tests.sh index a83eec538ca..7ffae747521 100755 --- a/.evergreen/run-serverless-tests.sh +++ b/.evergreen/run-serverless-tests.sh @@ -10,7 +10,7 @@ if [ -z ${MONGODB_URI+omitted} ]; then echo "MONGODB_URI is unset" && exit 1; fi if [ -z ${SERVERLESS_ATLAS_USER+omitted} ]; then echo "SERVERLESS_ATLAS_USER is unset" && exit 1; fi if [ -z ${SERVERLESS_ATLAS_PASSWORD+omitted} ]; then echo "SERVERLESS_ATLAS_PASSWORD is unset" && exit 1; fi -npm install mongodb-client-encryption +npm install mongodb-client-encryption@alpha --force npx mocha \ --config test/mocha_mongodb.json \ diff --git a/.evergreen/run-socks5-tests.sh b/.evergreen/run-socks5-tests.sh index 77e03f961e6..de77ac15ecb 100644 --- a/.evergreen/run-socks5-tests.sh +++ b/.evergreen/run-socks5-tests.sh @@ -5,6 +5,27 @@ source "${PROJECT_DIRECTORY}/.evergreen/init-node-and-npm-env.sh" set -o errexit # Exit the script with error if any of the commands fail set -o xtrace # For debuggability, no external credentials are used here +function setup_fle() { + export AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + export AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + export CSFLE_KMS_PROVIDERS=${CSFLE_KMS_PROVIDERS} + export CRYPT_SHARED_LIB_PATH=${CRYPT_SHARED_LIB_PATH} + echo "csfle CRYPT_SHARED_LIB_PATH: $CRYPT_SHARED_LIB_PATH" + + set -o xtrace # Write all commands first to stderr + set -o errexit # Exit the script with error if any of the commands fail + + # Get access to the AWS temporary credentials: + echo "adding temporary AWS credentials to environment" + # CSFLE_AWS_TEMP_ACCESS_KEY_ID, CSFLE_AWS_TEMP_SECRET_ACCESS_KEY, CSFLE_AWS_TEMP_SESSION_TOKEN + . "$DRIVERS_TOOLS"/.evergreen/csfle/set-temp-creds.sh + + npm i --force mongodb-client-encryption@alpha + export KMIP_TLS_CA_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/ca.pem" + export KMIP_TLS_CERT_FILE="${DRIVERS_TOOLS}/.evergreen/x509gen/client.pem" + export TEST_CSFLE=true +} + node -v PYTHON_BINARY=${PYTHON_BINARY:-python3} @@ -32,9 +53,10 @@ fi "$PYTHON_BINARY" "$SOCKS5_SERVER_SCRIPT" --port 1080 --auth username:p4ssw0rd --map "127.0.0.1:12345 to $FIRST_HOST" & SOCKS5_PROXY_PID=$! if [[ $TEST_SOCKS5_CSFLE == "true" ]]; then + setup_fle [ "$SSL" == "nossl" ] && [[ "$OSTYPE" == "linux-gnu"* ]] && \ env MONGODB_URI='mongodb://127.0.0.1:12345/?proxyHost=127.0.0.1&proxyUsername=username&proxyPassword=p4ssw0rd' \ - bash "${PROJECT_DIRECTORY}/.evergreen/run-custom-csfle-tests.sh" + npm run check:csfle else env SOCKS5_CONFIG='["127.0.0.1",1080,"username","p4ssw0rd"]' npm run check:socks5 fi @@ -44,9 +66,10 @@ kill $SOCKS5_PROXY_PID "$PYTHON_BINARY" "$SOCKS5_SERVER_SCRIPT" --port 1081 --map "127.0.0.1:12345 to $FIRST_HOST" & SOCKS5_PROXY_PID=$! if [[ $TEST_SOCKS5_CSFLE == "true" ]]; then + setup_fle [ "$SSL" == "nossl" ] && [[ "$OSTYPE" == "linux-gnu"* ]] && \ env MONGODB_URI='mongodb://127.0.0.1:12345/?proxyHost=127.0.0.1&proxyPort=1081' \ - bash "${PROJECT_DIRECTORY}/.evergreen/run-custom-csfle-tests.sh" + npm run check:csfle else env SOCKS5_CONFIG='["127.0.0.1",1081]' npm run check:socks5 fi diff --git a/.evergreen/run-tests.sh b/.evergreen/run-tests.sh index 38896706061..c2988ae6edf 100755 --- a/.evergreen/run-tests.sh +++ b/.evergreen/run-tests.sh @@ -52,7 +52,7 @@ else source "$DRIVERS_TOOLS"/.evergreen/csfle/set-temp-creds.sh fi -npm install mongodb-client-encryption +npm install mongodb-client-encryption@alpha --force npm install @mongodb-js/zstd npm install snappy diff --git a/.evergreen/run-unit-tests.sh b/.evergreen/run-unit-tests.sh index a1ab8e3a1a1..3f95c724d43 100644 --- a/.evergreen/run-unit-tests.sh +++ b/.evergreen/run-unit-tests.sh @@ -4,4 +4,6 @@ set -o errexit # Exit the script with error if any of the commands fail source "${PROJECT_DIRECTORY}/.evergreen/init-node-and-npm-env.sh" set -o xtrace +npm i --force mongodb-client-encryption@alpha + npx nyc npm run check:unit diff --git a/.gitignore b/.gitignore index 7a71ba0b970..9610afd15bc 100644 --- a/.gitignore +++ b/.gitignore @@ -95,7 +95,3 @@ node-artifacts # AWS SAM generated test/lambda/.aws-sam test/lambda/env.json - -!encryption/lib -!encryption/test -!encryption/test/types diff --git a/encryption/lib/autoEncrypter.js b/encryption/lib/autoEncrypter.js deleted file mode 100644 index f4e3dc081b7..00000000000 --- a/encryption/lib/autoEncrypter.js +++ /dev/null @@ -1,440 +0,0 @@ -'use strict'; - -module.exports = function (modules) { - const mc = require('bindings')('mongocrypt'); - const common = require('./common'); - const databaseNamespace = common.databaseNamespace; - const StateMachine = modules.stateMachine.StateMachine; - const MongocryptdManager = require('./mongocryptdManager').MongocryptdManager; - const MongoClient = modules.mongodb.MongoClient; - const MongoError = modules.mongodb.MongoError; - const BSON = modules.mongodb.BSON; - const { loadCredentials } = require('./providers/index'); - const cryptoCallbacks = require('./cryptoCallbacks'); - - /** - * Configuration options for a automatic client encryption. - * - * @typedef {Object} AutoEncrypter~AutoEncryptionOptions - * @property {MongoClient} [keyVaultClient] A `MongoClient` used to fetch keys from a key vault - * @property {string} [keyVaultNamespace] The namespace where keys are stored in the key vault - * @property {KMSProviders} [kmsProviders] Configuration options that are used by specific KMS providers during key generation, encryption, and decryption. - * @property {object} [schemaMap] A map of namespaces to a local JSON schema for encryption - * @property {boolean} [bypassAutoEncryption] Allows the user to bypass auto encryption, maintaining implicit decryption - * @property {AutoEncrypter~logger} [options.logger] An optional hook to catch logging messages from the underlying encryption engine - * @property {AutoEncrypter~AutoEncryptionExtraOptions} [extraOptions] Extra options related to the mongocryptd process - */ - - /** - * Extra options related to the mongocryptd process - * \* _Available in MongoDB 6.0 or higher._ - * @typedef {object} AutoEncrypter~AutoEncryptionExtraOptions - * @property {string} [mongocryptdURI] A local process the driver communicates with to determine how to encrypt values in a command. Defaults to "mongodb://%2Fvar%2Fmongocryptd.sock" if domain sockets are available or "mongodb://localhost:27020" otherwise - * @property {boolean} [mongocryptdBypassSpawn=false] If true, autoEncryption will not attempt to spawn a mongocryptd before connecting - * @property {string} [mongocryptdSpawnPath] The path to the mongocryptd executable on the system - * @property {string[]} [mongocryptdSpawnArgs] Command line arguments to use when auto-spawning a mongocryptd - * @property {string} [cryptSharedLibPath] Full path to a MongoDB Crypt shared library on the system. If specified, autoEncryption will not attempt to spawn a mongocryptd, but makes use of the shared library file specified. Note that the path must point to the shared libary file itself, not the folder which contains it \* - * @property {boolean} [cryptSharedLibRequired] If true, never use mongocryptd and fail when the MongoDB Crypt shared libary cannot be loaded. Defaults to true if [cryptSharedLibPath] is specified and false otherwise \* - */ - - /** - * @callback AutoEncrypter~logger - * @description A callback that is invoked with logging information from - * the underlying C++ Bindings. - * @param {AutoEncrypter~logLevel} level The level of logging. - * @param {string} message The message to log - */ - - /** - * @name AutoEncrypter~logLevel - * @enum {number} - * @description - * The level of severity of the log message - * - * | Value | Level | - * |-------|-------| - * | 0 | Fatal Error | - * | 1 | Error | - * | 2 | Warning | - * | 3 | Info | - * | 4 | Trace | - */ - - /** - * @classdesc An internal class to be used by the driver for auto encryption - * **NOTE**: Not meant to be instantiated directly, this is for internal use only. - */ - class AutoEncrypter { - /** - * Create an AutoEncrypter - * - * **Note**: Do not instantiate this class directly. Rather, supply the relevant options to a MongoClient - * - * **Note**: Supplying `options.schemaMap` provides more security than relying on JSON Schemas obtained from the server. - * It protects against a malicious server advertising a false JSON Schema, which could trick the client into sending unencrypted data that should be encrypted. - * Schemas supplied in the schemaMap only apply to configuring automatic encryption for Client-Side Field Level Encryption. - * Other validation rules in the JSON schema will not be enforced by the driver and will result in an error. - * @param {MongoClient} client The client autoEncryption is enabled on - * @param {AutoEncrypter~AutoEncryptionOptions} [options] Optional settings - * - * @example Create an AutoEncrypter that makes use of mongocryptd - * // Enabling autoEncryption via a MongoClient using mongocryptd - * const { MongoClient } = require('mongodb'); - * const client = new MongoClient(URL, { - * autoEncryption: { - * kmsProviders: { - * aws: { - * accessKeyId: AWS_ACCESS_KEY, - * secretAccessKey: AWS_SECRET_KEY - * } - * } - * } - * }); - * - * await client.connect(); - * // From here on, the client will be encrypting / decrypting automatically - * @example Create an AutoEncrypter that makes use of libmongocrypt's CSFLE shared library - * // Enabling autoEncryption via a MongoClient using CSFLE shared library - * const { MongoClient } = require('mongodb'); - * const client = new MongoClient(URL, { - * autoEncryption: { - * kmsProviders: { - * aws: {} - * }, - * extraOptions: { - * cryptSharedLibPath: '/path/to/local/crypt/shared/lib', - * cryptSharedLibRequired: true - * } - * } - * }); - * - * await client.connect(); - * // From here on, the client will be encrypting / decrypting automatically - */ - constructor(client, options) { - this._client = client; - this._bson = options.bson || BSON || client.topology.bson; - this._bypassEncryption = options.bypassAutoEncryption === true; - - this._keyVaultNamespace = options.keyVaultNamespace || 'admin.datakeys'; - this._keyVaultClient = options.keyVaultClient || client; - this._metaDataClient = options.metadataClient || client; - this._proxyOptions = options.proxyOptions || {}; - this._tlsOptions = options.tlsOptions || {}; - this._onKmsProviderRefresh = options.onKmsProviderRefresh; - this._kmsProviders = options.kmsProviders || {}; - - const mongoCryptOptions = {}; - if (options.schemaMap) { - mongoCryptOptions.schemaMap = Buffer.isBuffer(options.schemaMap) - ? options.schemaMap - : this._bson.serialize(options.schemaMap); - } - - if (options.encryptedFieldsMap) { - mongoCryptOptions.encryptedFieldsMap = Buffer.isBuffer(options.encryptedFieldsMap) - ? options.encryptedFieldsMap - : this._bson.serialize(options.encryptedFieldsMap); - } - - mongoCryptOptions.kmsProviders = !Buffer.isBuffer(this._kmsProviders) - ? this._bson.serialize(this._kmsProviders) - : this._kmsProviders; - - if (options.logger) { - mongoCryptOptions.logger = options.logger; - } - - if (options.extraOptions && options.extraOptions.cryptSharedLibPath) { - mongoCryptOptions.cryptSharedLibPath = options.extraOptions.cryptSharedLibPath; - } - - if (options.bypassQueryAnalysis) { - mongoCryptOptions.bypassQueryAnalysis = options.bypassQueryAnalysis; - } - - this._bypassMongocryptdAndCryptShared = this._bypassEncryption || options.bypassQueryAnalysis; - - if (options.extraOptions && options.extraOptions.cryptSharedLibSearchPaths) { - // Only for driver testing - mongoCryptOptions.cryptSharedLibSearchPaths = - options.extraOptions.cryptSharedLibSearchPaths; - } else if (!this._bypassMongocryptdAndCryptShared) { - mongoCryptOptions.cryptSharedLibSearchPaths = ['$SYSTEM']; - } - - Object.assign(mongoCryptOptions, { cryptoCallbacks }); - this._mongocrypt = new mc.MongoCrypt(mongoCryptOptions); - this._contextCounter = 0; - - if ( - options.extraOptions && - options.extraOptions.cryptSharedLibRequired && - !this.cryptSharedLibVersionInfo - ) { - throw new MongoError('`cryptSharedLibRequired` set but no crypt_shared library loaded'); - } - - // Only instantiate mongocryptd manager/client once we know for sure - // that we are not using the CSFLE shared library. - if (!this._bypassMongocryptdAndCryptShared && !this.cryptSharedLibVersionInfo) { - this._mongocryptdManager = new MongocryptdManager(options.extraOptions); - const clientOptions = { - useNewUrlParser: true, - useUnifiedTopology: true, - serverSelectionTimeoutMS: 10000 - }; - - if ( - options.extraOptions == null || - typeof options.extraOptions.mongocryptdURI !== 'string' - ) { - clientOptions.family = 4; - } - - this._mongocryptdClient = new MongoClient(this._mongocryptdManager.uri, clientOptions); - } - } - - /** - * @ignore - * @param {Function} callback Invoked when the mongocryptd client either successfully connects or errors - */ - init(callback) { - if (this._bypassMongocryptdAndCryptShared || this.cryptSharedLibVersionInfo) { - return callback(); - } - const _callback = (err, res) => { - if ( - err && - err.message && - (err.message.match(/timed out after/) || err.message.match(/ENOTFOUND/)) - ) { - callback( - new MongoError( - 'Unable to connect to `mongocryptd`, please make sure it is running or in your PATH for auto-spawn' - ) - ); - return; - } - - callback(err, res); - }; - - if (this._mongocryptdManager.bypassSpawn) { - return this._mongocryptdClient.connect().then( - result => { - return _callback(null, result); - }, - error => { - _callback(error, null); - } - ); - } - - this._mongocryptdManager.spawn(() => { - this._mongocryptdClient.connect().then( - result => { - return _callback(null, result); - }, - error => { - _callback(error, null); - } - ); - }); - } - - /** - * @ignore - * @param {Function} callback Invoked when the mongocryptd client either successfully disconnects or errors - */ - teardown(force, callback) { - if (this._mongocryptdClient) { - this._mongocryptdClient.close(force).then( - result => { - return callback(null, result); - }, - error => { - callback(error); - } - ); - } else { - callback(); - } - } - - /** - * @ignore - * Encrypt a command for a given namespace. - * - * @param {string} ns The namespace for this encryption context - * @param {object} cmd The command to encrypt - * @param {Function} callback - */ - encrypt(ns, cmd, options, callback) { - if (typeof ns !== 'string') { - throw new TypeError('Parameter `ns` must be a string'); - } - - if (typeof cmd !== 'object') { - throw new TypeError('Parameter `cmd` must be an object'); - } - - if (typeof options === 'function' && callback == null) { - callback = options; - options = {}; - } - - // If `bypassAutoEncryption` has been specified, don't encrypt - if (this._bypassEncryption) { - callback(undefined, cmd); - return; - } - - const bson = this._bson; - const commandBuffer = Buffer.isBuffer(cmd) ? cmd : bson.serialize(cmd, options); - - let context; - try { - context = this._mongocrypt.makeEncryptionContext(databaseNamespace(ns), commandBuffer); - } catch (err) { - callback(err, null); - return; - } - - // TODO: should these be accessors from the addon? - context.id = this._contextCounter++; - context.ns = ns; - context.document = cmd; - - const stateMachine = new StateMachine({ - bson, - ...options, - promoteValues: false, - promoteLongs: false, - proxyOptions: this._proxyOptions, - tlsOptions: this._tlsOptions - }); - stateMachine.execute(this, context, callback); - } - - /** - * @ignore - * Decrypt a command response - * - * @param {Buffer} buffer - * @param {Function} callback - */ - decrypt(response, options, callback) { - if (typeof options === 'function' && callback == null) { - callback = options; - options = {}; - } - - const bson = this._bson; - const buffer = Buffer.isBuffer(response) ? response : bson.serialize(response, options); - - let context; - try { - context = this._mongocrypt.makeDecryptionContext(buffer); - } catch (err) { - callback(err, null); - return; - } - - // TODO: should this be an accessor from the addon? - context.id = this._contextCounter++; - - const stateMachine = new StateMachine({ - bson, - ...options, - proxyOptions: this._proxyOptions, - tlsOptions: this._tlsOptions - }); - - const decorateResult = this[Symbol.for('@@mdb.decorateDecryptionResult')]; - stateMachine.execute(this, context, function (err, result) { - // Only for testing/internal usage - if (!err && result && decorateResult) { - err = decorateDecryptionResult(result, response, bson); - if (err) return callback(err); - } - callback(err, result); - }); - } - - /** - * Ask the user for KMS credentials. - * - * This returns anything that looks like the kmsProviders original input - * option. It can be empty, and any provider specified here will override - * the original ones. - */ - async askForKMSCredentials() { - return this._onKmsProviderRefresh - ? this._onKmsProviderRefresh() - : loadCredentials(this._kmsProviders); - } - - /** - * Return the current libmongocrypt's CSFLE shared library version - * as `{ version: bigint, versionStr: string }`, or `null` if no CSFLE - * shared library was loaded. - */ - get cryptSharedLibVersionInfo() { - return this._mongocrypt.cryptSharedLibVersionInfo; - } - - static get libmongocryptVersion() { - return mc.MongoCrypt.libmongocryptVersion; - } - } - - return { AutoEncrypter }; -}; - -/** - * Recurse through the (identically-shaped) `decrypted` and `original` - * objects and attach a `decryptedKeys` property on each sub-object that - * contained encrypted fields. Because we only call this on BSON responses, - * we do not need to worry about circular references. - * - * @internal - * @ignore - */ -function decorateDecryptionResult(decrypted, original, bson, isTopLevelDecorateCall = true) { - const decryptedKeys = Symbol.for('@@mdb.decryptedKeys'); - if (isTopLevelDecorateCall) { - // The original value could have been either a JS object or a BSON buffer - if (Buffer.isBuffer(original)) { - original = bson.deserialize(original); - } - if (Buffer.isBuffer(decrypted)) { - return new Error('Expected result of decryption to be deserialized BSON object'); - } - } - - if (!decrypted || typeof decrypted !== 'object') return; - for (const k of Object.keys(decrypted)) { - const originalValue = original[k]; - - // An object was decrypted by libmongocrypt if and only if it was - // a BSON Binary object with subtype 6. - if (originalValue && originalValue._bsontype === 'Binary' && originalValue.sub_type === 6) { - if (!decrypted[decryptedKeys]) { - Object.defineProperty(decrypted, decryptedKeys, { - value: [], - configurable: true, - enumerable: false, - writable: false - }); - } - decrypted[decryptedKeys].push(k); - // Do not recurse into this decrypted value. It could be a subdocument/array, - // in which case there is no original value associated with its subfields. - continue; - } - - decorateDecryptionResult(decrypted[k], originalValue, bson, false); - } -} diff --git a/encryption/lib/clientEncryption.js b/encryption/lib/clientEncryption.js deleted file mode 100644 index 652df755c16..00000000000 --- a/encryption/lib/clientEncryption.js +++ /dev/null @@ -1,819 +0,0 @@ -'use strict'; - -module.exports = function (modules) { - const mc = require('bindings')('mongocrypt'); - const common = require('./common'); - const databaseNamespace = common.databaseNamespace; - const collectionNamespace = common.collectionNamespace; - const promiseOrCallback = common.promiseOrCallback; - const maybeCallback = common.maybeCallback; - const StateMachine = modules.stateMachine.StateMachine; - const BSON = modules.mongodb.BSON; - const { - MongoCryptCreateEncryptedCollectionError, - MongoCryptCreateDataKeyError - } = require('./errors'); - const { loadCredentials } = require('./providers/index'); - const cryptoCallbacks = require('./cryptoCallbacks'); - const { promisify } = require('util'); - - /** @typedef {*} BSONValue - any serializable BSON value */ - /** @typedef {BSON.Long} Long A 64 bit integer, represented by the js-bson Long type.*/ - - /** - * @typedef {object} KMSProviders Configuration options that are used by specific KMS providers during key generation, encryption, and decryption. - * @property {object} [aws] Configuration options for using 'aws' as your KMS provider - * @property {string} [aws.accessKeyId] The access key used for the AWS KMS provider - * @property {string} [aws.secretAccessKey] The secret access key used for the AWS KMS provider - * @property {object} [local] Configuration options for using 'local' as your KMS provider - * @property {Buffer} [local.key] The master key used to encrypt/decrypt data keys. A 96-byte long Buffer. - * @property {object} [azure] Configuration options for using 'azure' as your KMS provider - * @property {string} [azure.tenantId] The tenant ID identifies the organization for the account - * @property {string} [azure.clientId] The client ID to authenticate a registered application - * @property {string} [azure.clientSecret] The client secret to authenticate a registered application - * @property {string} [azure.identityPlatformEndpoint] If present, a host with optional port. E.g. "example.com" or "example.com:443". This is optional, and only needed if customer is using a non-commercial Azure instance (e.g. a government or China account, which use different URLs). Defaults to "login.microsoftonline.com" - * @property {object} [gcp] Configuration options for using 'gcp' as your KMS provider - * @property {string} [gcp.email] The service account email to authenticate - * @property {string|Binary} [gcp.privateKey] A PKCS#8 encrypted key. This can either be a base64 string or a binary representation - * @property {string} [gcp.endpoint] If present, a host with optional port. E.g. "example.com" or "example.com:443". Defaults to "oauth2.googleapis.com" - */ - - /** - * @typedef {object} DataKey A data key as stored in the database. - * @property {UUID} _id A unique identifier for the key. - * @property {number} version A numeric identifier for the schema version of this document. Implicitly 0 if unset. - * @property {string[]} [keyAltNames] Alternate names to search for keys by. Used for a per-document key scenario in support of GDPR scenarios. - * @property {Binary} keyMaterial Encrypted data key material, BinData type General. - * @property {Date} creationDate The datetime the wrapped data key material was imported into the Key Database. - * @property {Date} updateDate The datetime the wrapped data key material was last modified. On initial import, this value will be set to creationDate. - * @property {number} status 0 = enabled, 1 = disabled - * @property {object} masterKey the encrypted master key - */ - - /** - * @typedef {string} KmsProvider A string containing the name of a kms provider. Valid options are 'aws', 'azure', 'gcp', 'kmip', or 'local' - */ - - /** - * @typedef {object} ClientSession The ClientSession class from the MongoDB Node driver (see https://mongodb.github.io/node-mongodb-native/4.8/classes/ClientSession.html) - */ - - /** - * @typedef {object} DeleteResult The result of a delete operation from the MongoDB Node driver (see https://mongodb.github.io/node-mongodb-native/4.8/interfaces/DeleteResult.html) - * @property {boolean} acknowledged Indicates whether this write result was acknowledged. If not, then all other members of this result will be undefined. - * @property {number} deletedCount The number of documents that were deleted - */ - - /** - * @typedef {object} BulkWriteResult The BulkWriteResult class from the MongoDB Node driver (https://mongodb.github.io/node-mongodb-native/4.8/classes/BulkWriteResult.html) - */ - - /** - * @typedef {object} FindCursor The FindCursor class from the MongoDB Node driver (see https://mongodb.github.io/node-mongodb-native/4.8/classes/FindCursor.html) - */ - - /** - * The public interface for explicit in-use encryption - */ - class ClientEncryption { - /** - * Create a new encryption instance - * - * @param {MongoClient} client The client used for encryption - * @param {object} options Additional settings - * @param {string} options.keyVaultNamespace The namespace of the key vault, used to store encryption keys - * @param {object} options.tlsOptions An object that maps KMS provider names to TLS options. - * @param {MongoClient} [options.keyVaultClient] A `MongoClient` used to fetch keys from a key vault. Defaults to `client` - * @param {KMSProviders} [options.kmsProviders] options for specific KMS providers to use - * - * @example - * new ClientEncryption(mongoClient, { - * keyVaultNamespace: 'client.encryption', - * kmsProviders: { - * local: { - * key: masterKey // The master key used for encryption/decryption. A 96-byte long Buffer - * } - * } - * }); - * - * @example - * new ClientEncryption(mongoClient, { - * keyVaultNamespace: 'client.encryption', - * kmsProviders: { - * aws: { - * accessKeyId: AWS_ACCESS_KEY, - * secretAccessKey: AWS_SECRET_KEY - * } - * } - * }); - */ - constructor(client, options) { - this._client = client; - this._bson = options.bson || BSON || client.topology.bson; - this._proxyOptions = options.proxyOptions; - this._tlsOptions = options.tlsOptions; - this._kmsProviders = options.kmsProviders || {}; - - if (options.keyVaultNamespace == null) { - throw new TypeError('Missing required option `keyVaultNamespace`'); - } - - const mongoCryptOptions = { ...options, cryptoCallbacks }; - - mongoCryptOptions.kmsProviders = !Buffer.isBuffer(this._kmsProviders) - ? this._bson.serialize(this._kmsProviders) - : this._kmsProviders; - - this._onKmsProviderRefresh = options.onKmsProviderRefresh; - this._keyVaultNamespace = options.keyVaultNamespace; - this._keyVaultClient = options.keyVaultClient || client; - this._mongoCrypt = new mc.MongoCrypt(mongoCryptOptions); - } - - /** - * @typedef {Binary} ClientEncryptionDataKeyId - * The id of an existing dataKey. Is a bson Binary value. - * Can be used for {@link ClientEncryption.encrypt}, and can be used to directly - * query for the data key itself against the key vault namespace. - */ - - /** - * @callback ClientEncryptionCreateDataKeyCallback - * @param {Error} [error] If present, indicates an error that occurred in the creation of the data key - * @param {ClientEncryption~dataKeyId} [dataKeyId] If present, returns the id of the created data key - */ - - /** - * @typedef {object} AWSEncryptionKeyOptions Configuration options for making an AWS encryption key - * @property {string} region The AWS region of the KMS - * @property {string} key The Amazon Resource Name (ARN) to the AWS customer master key (CMK) - * @property {string} [endpoint] An alternate host to send KMS requests to. May include port number - */ - - /** - * @typedef {object} GCPEncryptionKeyOptions Configuration options for making a GCP encryption key - * @property {string} projectId GCP project id - * @property {string} location Location name (e.g. "global") - * @property {string} keyRing Key ring name - * @property {string} keyName Key name - * @property {string} [keyVersion] Key version - * @property {string} [endpoint] KMS URL, defaults to `https://www.googleapis.com/auth/cloudkms` - */ - - /** - * @typedef {object} AzureEncryptionKeyOptions Configuration options for making an Azure encryption key - * @property {string} keyName Key name - * @property {string} keyVaultEndpoint Key vault URL, typically `.vault.azure.net` - * @property {string} [keyVersion] Key version - */ - - /** - * Creates a data key used for explicit encryption and inserts it into the key vault namespace - * - * @param {string} provider The KMS provider used for this data key. Must be `'aws'`, `'azure'`, `'gcp'`, or `'local'` - * @param {object} [options] Options for creating the data key - * @param {AWSEncryptionKeyOptions|AzureEncryptionKeyOptions|GCPEncryptionKeyOptions} [options.masterKey] Idenfities a new KMS-specific key used to encrypt the new data key - * @param {string[]} [options.keyAltNames] An optional list of string alternate names used to reference a key. If a key is created with alternate names, then encryption may refer to the key by the unique alternate name instead of by _id. - * @param {ClientEncryptionCreateDataKeyCallback} [callback] Optional callback to invoke when key is created - * @returns {Promise|void} If no callback is provided, returns a Promise that either resolves with {@link ClientEncryption~dataKeyId the id of the created data key}, or rejects with an error. If a callback is provided, returns nothing. - * @example - * // Using callbacks to create a local key - * clientEncryption.createDataKey('local', (err, dataKey) => { - * if (err) { - * // This means creating the key failed. - * } else { - * // key creation succeeded - * } - * }); - * - * @example - * // Using async/await to create a local key - * const dataKeyId = await clientEncryption.createDataKey('local'); - * - * @example - * // Using async/await to create an aws key - * const dataKeyId = await clientEncryption.createDataKey('aws', { - * masterKey: { - * region: 'us-east-1', - * key: 'xxxxxxxxxxxxxx' // CMK ARN here - * } - * }); - * - * @example - * // Using async/await to create an aws key with a keyAltName - * const dataKeyId = await clientEncryption.createDataKey('aws', { - * masterKey: { - * region: 'us-east-1', - * key: 'xxxxxxxxxxxxxx' // CMK ARN here - * }, - * keyAltNames: [ 'mySpecialKey' ] - * }); - */ - createDataKey(provider, options, callback) { - if (typeof options === 'function') { - callback = options; - options = {}; - } - if (options == null) { - options = {}; - } - - const bson = this._bson; - - const dataKey = Object.assign({ provider }, options.masterKey); - - if (options.keyAltNames && !Array.isArray(options.keyAltNames)) { - throw new TypeError( - `Option "keyAltNames" must be an array of strings, but was of type ${typeof options.keyAltNames}.` - ); - } - - let keyAltNames = undefined; - if (options.keyAltNames && options.keyAltNames.length > 0) { - keyAltNames = options.keyAltNames.map((keyAltName, i) => { - if (typeof keyAltName !== 'string') { - throw new TypeError( - `Option "keyAltNames" must be an array of strings, but item at index ${i} was of type ${typeof keyAltName}` - ); - } - - return bson.serialize({ keyAltName }); - }); - } - - let keyMaterial = undefined; - if (options.keyMaterial) { - keyMaterial = bson.serialize({ keyMaterial: options.keyMaterial }); - } - - const dataKeyBson = bson.serialize(dataKey); - const context = this._mongoCrypt.makeDataKeyContext(dataKeyBson, { - keyAltNames, - keyMaterial - }); - const stateMachine = new StateMachine({ - bson, - proxyOptions: this._proxyOptions, - tlsOptions: this._tlsOptions - }); - - return promiseOrCallback(callback, cb => { - stateMachine.execute(this, context, (err, dataKey) => { - if (err) { - cb(err, null); - return; - } - - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - - this._keyVaultClient - .db(dbName) - .collection(collectionName) - .insertOne(dataKey, { writeConcern: { w: 'majority' } }) - .then( - result => { - return cb(null, result.insertedId); - }, - err => { - cb(err, null); - } - ); - }); - }); - } - - /** - * @typedef {object} RewrapManyDataKeyResult - * @property {BulkWriteResult} [bulkWriteResult] An optional BulkWriteResult, if any keys were matched and attempted to be re-wrapped. - */ - - /** - * Searches the keyvault for any data keys matching the provided filter. If there are matches, rewrapManyDataKey then attempts to re-wrap the data keys using the provided options. - * - * If no matches are found, then no bulk write is performed. - * - * @param {object} filter A valid MongoDB filter. Any documents matching this filter will be re-wrapped. - * @param {object} [options] - * @param {KmsProvider} options.provider The KMS provider to use when re-wrapping the data keys. - * @param {AWSEncryptionKeyOptions | AzureEncryptionKeyOptions | GCPEncryptionKeyOptions} [options.masterKey] - * @returns {Promise} - * - * @example - * // rewrapping all data data keys (using a filter that matches all documents) - * const filter = {}; - * - * const result = await clientEncryption.rewrapManyDataKey(filter); - * if (result.bulkWriteResult != null) { - * // keys were re-wrapped, results will be available in the bulkWrite object. - * } - * - * @example - * // attempting to rewrap all data keys with no matches - * const filter = { _id: new Binary() } // assume _id matches no documents in the database - * const result = await clientEncryption.rewrapManyDataKey(filter); - * - * if (result.bulkWriteResult == null) { - * // no keys matched, `bulkWriteResult` does not exist on the result object - * } - */ - async rewrapManyDataKey(filter, options) { - const bson = this._bson; - - let keyEncryptionKeyBson = undefined; - if (options) { - const keyEncryptionKey = Object.assign({ provider: options.provider }, options.masterKey); - keyEncryptionKeyBson = bson.serialize(keyEncryptionKey); - } else { - // Always make sure `options` is an object below. - options = {}; - } - const filterBson = bson.serialize(filter); - const context = this._mongoCrypt.makeRewrapManyDataKeyContext( - filterBson, - keyEncryptionKeyBson - ); - const stateMachine = new StateMachine({ - bson, - proxyOptions: this._proxyOptions, - tlsOptions: this._tlsOptions - }); - - const execute = promisify(stateMachine.execute.bind(stateMachine)); - - const dataKey = await execute(this, context); - if (!dataKey || dataKey.v.length === 0) { - return {}; - } - - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - const replacements = dataKey.v.map(key => ({ - updateOne: { - filter: { _id: key._id }, - update: { - $set: { - masterKey: key.masterKey, - keyMaterial: key.keyMaterial - }, - $currentDate: { - updateDate: true - } - } - } - })); - - const result = await this._keyVaultClient - .db(dbName) - .collection(collectionName) - .bulkWrite(replacements, { - writeConcern: { w: 'majority' } - }); - - return { bulkWriteResult: result }; - } - - /** - * Deletes the key with the provided id from the keyvault, if it exists. - * - * @param {ClientEncryptionDataKeyId} _id - the id of the document to delete. - * @returns {Promise} Returns a promise that either resolves to a {@link DeleteResult} or rejects with an error. - * - * @example - * // delete a key by _id - * const id = new Binary(); // id is a bson binary subtype 4 object - * const { deletedCount } = await clientEncryption.deleteKey(id); - * - * if (deletedCount != null && deletedCount > 0) { - * // successful deletion - * } - * - */ - async deleteKey(_id) { - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - return await this._keyVaultClient - .db(dbName) - .collection(collectionName) - .deleteOne({ _id }, { writeConcern: { w: 'majority' } }); - } - - /** - * Finds all the keys currently stored in the keyvault. - * - * This method will not throw. - * - * @returns {FindCursor} a FindCursor over all keys in the keyvault. - * @example - * // fetching all keys - * const keys = await clientEncryption.getKeys().toArray(); - */ - getKeys() { - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - return this._keyVaultClient - .db(dbName) - .collection(collectionName) - .find({}, { readConcern: { level: 'majority' } }); - } - - /** - * Finds a key in the keyvault with the specified _id. - * - * @param {ClientEncryptionDataKeyId} _id - the id of the document to delete. - * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents - * match the id. The promise rejects with an error if an error is thrown. - * @example - * // getting a key by id - * const id = new Binary(); // id is a bson binary subtype 4 object - * const key = await clientEncryption.getKey(id); - * if (!key) { - * // key is null if there was no matching key - * } - */ - async getKey(_id) { - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - return await this._keyVaultClient - .db(dbName) - .collection(collectionName) - .findOne({ _id }, { readConcern: { level: 'majority' } }); - } - - /** - * Finds a key in the keyvault which has the specified keyAltName. - * - * @param {string} keyAltName - a keyAltName to search for a key - * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents - * match the keyAltName. The promise rejects with an error if an error is thrown. - * @example - * // get a key by alt name - * const keyAltName = 'keyAltName'; - * const key = await clientEncryption.getKeyByAltName(keyAltName); - * if (!key) { - * // key is null if there is no matching key - * } - */ - async getKeyByAltName(keyAltName) { - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - return await this._keyVaultClient - .db(dbName) - .collection(collectionName) - .findOne({ keyAltNames: keyAltName }, { readConcern: { level: 'majority' } }); - } - - /** - * Adds a keyAltName to a key identified by the provided _id. - * - * This method resolves to/returns the *old* key value (prior to adding the new altKeyName). - * - * @param {ClientEncryptionDataKeyId} _id The id of the document to update. - * @param {string} keyAltName - a keyAltName to search for a key - * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents - * match the id. The promise rejects with an error if an error is thrown. - * @example - * // adding an keyAltName to a data key - * const id = new Binary(); // id is a bson binary subtype 4 object - * const keyAltName = 'keyAltName'; - * const oldKey = await clientEncryption.addKeyAltName(id, keyAltName); - * if (!oldKey) { - * // null is returned if there is no matching document with an id matching the supplied id - * } - */ - async addKeyAltName(_id, keyAltName) { - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - const { value } = await this._keyVaultClient - .db(dbName) - .collection(collectionName) - .findOneAndUpdate( - { _id }, - { $addToSet: { keyAltNames: keyAltName } }, - { writeConcern: { w: 'majority' }, returnDocument: 'before' } - ); - - return value; - } - - /** - * Adds a keyAltName to a key identified by the provided _id. - * - * This method resolves to/returns the *old* key value (prior to removing the new altKeyName). - * - * If the removed keyAltName is the last keyAltName for that key, the `altKeyNames` property is unset from the document. - * - * @param {ClientEncryptionDataKeyId} _id The id of the document to update. - * @param {string} keyAltName - a keyAltName to search for a key - * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents - * match the id. The promise rejects with an error if an error is thrown. - * @example - * // removing a key alt name from a data key - * const id = new Binary(); // id is a bson binary subtype 4 object - * const keyAltName = 'keyAltName'; - * const oldKey = await clientEncryption.removeKeyAltName(id, keyAltName); - * - * if (!oldKey) { - * // null is returned if there is no matching document with an id matching the supplied id - * } - */ - async removeKeyAltName(_id, keyAltName) { - const dbName = databaseNamespace(this._keyVaultNamespace); - const collectionName = collectionNamespace(this._keyVaultNamespace); - const pipeline = [ - { - $set: { - keyAltNames: { - $cond: [ - { - $eq: ['$keyAltNames', [keyAltName]] - }, - '$$REMOVE', - { - $filter: { - input: '$keyAltNames', - cond: { - $ne: ['$$this', keyAltName] - } - } - } - ] - } - } - } - ]; - const { value } = await this._keyVaultClient - .db(dbName) - .collection(collectionName) - .findOneAndUpdate({ _id }, pipeline, { - writeConcern: { w: 'majority' }, - returnDocument: 'before' - }); - - return value; - } - - /** - * A convenience method for creating an encrypted collection. - * This method will create data keys for any encryptedFields that do not have a `keyId` defined - * and then create a new collection with the full set of encryptedFields. - * - * @template {TSchema} - Schema for the collection being created - * @param {Db} db - A Node.js driver Db object with which to create the collection - * @param {string} name - The name of the collection to be created - * @param {object} options - Options for createDataKey and for createCollection - * @param {string} options.provider - KMS provider name - * @param {AWSEncryptionKeyOptions | AzureEncryptionKeyOptions | GCPEncryptionKeyOptions} [options.masterKey] - masterKey to pass to createDataKey - * @param {CreateCollectionOptions} options.createCollectionOptions - options to pass to createCollection, must include `encryptedFields` - * @returns {Promise<{ collection: Collection, encryptedFields: Document }>} - created collection and generated encryptedFields - * @throws {MongoCryptCreateDataKeyError} - If part way through the process a createDataKey invocation fails, an error will be rejected that has the partial `encryptedFields` that were created. - * @throws {MongoCryptCreateEncryptedCollectionError} - If creating the collection fails, an error will be rejected that has the entire `encryptedFields` that were created. - */ - async createEncryptedCollection(db, name, options) { - const { - provider, - masterKey, - createCollectionOptions: { - encryptedFields: { ...encryptedFields }, - ...createCollectionOptions - } - } = options; - - if (Array.isArray(encryptedFields.fields)) { - const createDataKeyPromises = encryptedFields.fields.map(async field => - field == null || typeof field !== 'object' || field.keyId != null - ? field - : { - ...field, - keyId: await this.createDataKey(provider, { masterKey }) - } - ); - - const createDataKeyResolutions = await Promise.allSettled(createDataKeyPromises); - - encryptedFields.fields = createDataKeyResolutions.map((resolution, index) => - resolution.status === 'fulfilled' ? resolution.value : encryptedFields.fields[index] - ); - - const rejection = createDataKeyResolutions.find(({ status }) => status === 'rejected'); - if (rejection != null) { - throw new MongoCryptCreateDataKeyError({ encryptedFields, cause: rejection.reason }); - } - } - - try { - const collection = await db.createCollection(name, { - ...createCollectionOptions, - encryptedFields - }); - return { collection, encryptedFields }; - } catch (cause) { - throw new MongoCryptCreateEncryptedCollectionError({ encryptedFields, cause }); - } - } - - /** - * @callback ClientEncryptionEncryptCallback - * @param {Error} [err] If present, indicates an error that occurred in the process of encryption - * @param {Buffer} [result] If present, is the encrypted result - */ - - /** - * @typedef {object} RangeOptions - * min, max, sparsity, and range must match the values set in the encryptedFields of the destination collection. - * For double and decimal128, min/max/precision must all be set, or all be unset. - * @property {BSONValue} min is required if precision is set. - * @property {BSONValue} max is required if precision is set. - * @property {BSON.Long} sparsity - * @property {number | undefined} precision (may only be set for double or decimal128). - */ - - /** - * @typedef {object} EncryptOptions Options to provide when encrypting data. - * @property {ClientEncryptionDataKeyId} [keyId] The id of the Binary dataKey to use for encryption. - * @property {string} [keyAltName] A unique string name corresponding to an already existing dataKey. - * @property {string} [algorithm] The algorithm to use for encryption. Must be either `'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'`, `'AEAD_AES_256_CBC_HMAC_SHA_512-Random'`, `'Indexed'` or `'Unindexed'` - * @property {bigint | number} [contentionFactor] - the contention factor. - * @property {'equality' | 'rangePreview'} queryType - the query type supported. only the query type `equality` is stable at this time. queryType `rangePreview` is experimental. - * @property {RangeOptions} [rangeOptions] (experimental) The index options for a Queryable Encryption field supporting "rangePreview" queries. - */ - - /** - * Explicitly encrypt a provided value. Note that either `options.keyId` or `options.keyAltName` must - * be specified. Specifying both `options.keyId` and `options.keyAltName` is considered an error. - * - * @param {*} value The value that you wish to serialize. Must be of a type that can be serialized into BSON - * @param {EncryptOptions} options - * @param {ClientEncryptionEncryptCallback} [callback] Optional callback to invoke when value is encrypted - * @returns {Promise|void} If no callback is provided, returns a Promise that either resolves with the encrypted value, or rejects with an error. If a callback is provided, returns nothing. - * - * @example - * // Encryption with callback API - * function encryptMyData(value, callback) { - * clientEncryption.createDataKey('local', (err, keyId) => { - * if (err) { - * return callback(err); - * } - * clientEncryption.encrypt(value, { keyId, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }, callback); - * }); - * } - * - * @example - * // Encryption with async/await api - * async function encryptMyData(value) { - * const keyId = await clientEncryption.createDataKey('local'); - * return clientEncryption.encrypt(value, { keyId, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }); - * } - * - * @example - * // Encryption using a keyAltName - * async function encryptMyData(value) { - * await clientEncryption.createDataKey('local', { keyAltNames: 'mySpecialKey' }); - * return clientEncryption.encrypt(value, { keyAltName: 'mySpecialKey', algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }); - * } - */ - encrypt(value, options, callback) { - return maybeCallback(() => this._encrypt(value, false, options), callback); - } - - /** - * Encrypts a Match Expression or Aggregate Expression to query a range index. - * - * Only supported when queryType is "rangePreview" and algorithm is "RangePreview". - * - * @experimental The Range algorithm is experimental only. It is not intended for production use. It is subject to breaking changes. - * - * @param {object} expression a BSON document of one of the following forms: - * 1. A Match Expression of this form: - * `{$and: [{: {$gt: }}, {: {$lt: }}]}` - * 2. An Aggregate Expression of this form: - * `{$and: [{$gt: [, ]}, {$lt: [, ]}]}` - * - * `$gt` may also be `$gte`. `$lt` may also be `$lte`. - * - * @param {EncryptOptions} options - * @returns {Promise} Returns a Promise that either resolves with the encrypted value or rejects with an error. - */ - async encryptExpression(expression, options) { - return this._encrypt(expression, true, options); - } - - /** - * @callback ClientEncryption~decryptCallback - * @param {Error} [err] If present, indicates an error that occurred in the process of decryption - * @param {object} [result] If present, is the decrypted result - */ - - /** - * Explicitly decrypt a provided encrypted value - * - * @param {Buffer | Binary} value An encrypted value - * @param {ClientEncryption~decryptCallback} callback Optional callback to invoke when value is decrypted - * @returns {Promise|void} If no callback is provided, returns a Promise that either resolves with the decrypted value, or rejects with an error. If a callback is provided, returns nothing. - * - * @example - * // Decrypting value with callback API - * function decryptMyValue(value, callback) { - * clientEncryption.decrypt(value, callback); - * } - * - * @example - * // Decrypting value with async/await API - * async function decryptMyValue(value) { - * return clientEncryption.decrypt(value); - * } - */ - decrypt(value, callback) { - const bson = this._bson; - const valueBuffer = bson.serialize({ v: value }); - const context = this._mongoCrypt.makeExplicitDecryptionContext(valueBuffer); - - const stateMachine = new StateMachine({ - bson, - proxyOptions: this._proxyOptions, - tlsOptions: this._tlsOptions - }); - - return promiseOrCallback(callback, cb => { - stateMachine.execute(this, context, (err, result) => { - if (err) { - cb(err, null); - return; - } - - cb(null, result.v); - }); - }); - } - - /** - * Ask the user for KMS credentials. - * - * This returns anything that looks like the kmsProviders original input - * option. It can be empty, and any provider specified here will override - * the original ones. - */ - async askForKMSCredentials() { - return this._onKmsProviderRefresh - ? this._onKmsProviderRefresh() - : loadCredentials(this._kmsProviders); - } - - static get libmongocryptVersion() { - return mc.MongoCrypt.libmongocryptVersion; - } - - /** - * A helper that perform explicit encryption of values and expressions. - * Explicitly encrypt a provided value. Note that either `options.keyId` or `options.keyAltName` must - * be specified. Specifying both `options.keyId` and `options.keyAltName` is considered an error. - * - * @param {*} value The value that you wish to encrypt. Must be of a type that can be serialized into BSON - * @param {boolean} expressionMode - a boolean that indicates whether or not to encrypt the value as an expression - * @param {EncryptOptions} options - * @returns the raw result of the call to stateMachine.execute(). When expressionMode is set to true, the return - * value will be a bson document. When false, the value will be a BSON Binary. - * - * @ignore - * - */ - async _encrypt(value, expressionMode, options) { - const bson = this._bson; - const valueBuffer = bson.serialize({ v: value }); - const contextOptions = Object.assign({}, options, { expressionMode }); - if (options.keyId) { - contextOptions.keyId = options.keyId.buffer; - } - if (options.keyAltName) { - const keyAltName = options.keyAltName; - if (options.keyId) { - throw new TypeError(`"options" cannot contain both "keyId" and "keyAltName"`); - } - const keyAltNameType = typeof keyAltName; - if (keyAltNameType !== 'string') { - throw new TypeError( - `"options.keyAltName" must be of type string, but was of type ${keyAltNameType}` - ); - } - - contextOptions.keyAltName = bson.serialize({ keyAltName }); - } - - if ('rangeOptions' in options) { - contextOptions.rangeOptions = bson.serialize(options.rangeOptions); - } - - const stateMachine = new StateMachine({ - bson, - proxyOptions: this._proxyOptions, - tlsOptions: this._tlsOptions - }); - const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions); - - const result = await stateMachine.executeAsync(this, context); - return result.v; - } - } - - return { ClientEncryption }; -}; diff --git a/encryption/lib/index.js b/encryption/lib/index.js deleted file mode 100644 index b2f760b113f..00000000000 --- a/encryption/lib/index.js +++ /dev/null @@ -1,73 +0,0 @@ -'use strict'; - -let defaultModule; -function loadDefaultModule() { - if (!defaultModule) { - defaultModule = extension(require('mongodb')); - } - - return defaultModule; -} - -const { - MongoCryptError, - MongoCryptCreateEncryptedCollectionError, - MongoCryptCreateDataKeyError, - MongoCryptAzureKMSRequestError, - MongoCryptKMSRequestNetworkTimeoutError -} = require('./errors'); - -const { fetchAzureKMSToken } = require('./providers/index'); - -function extension(mongodb) { - const modules = { mongodb }; - - modules.stateMachine = require('./stateMachine')(modules); - modules.autoEncrypter = require('./autoEncrypter')(modules); - modules.clientEncryption = require('./clientEncryption')(modules); - - const exports = { - AutoEncrypter: modules.autoEncrypter.AutoEncrypter, - ClientEncryption: modules.clientEncryption.ClientEncryption, - MongoCryptError, - MongoCryptCreateEncryptedCollectionError, - MongoCryptCreateDataKeyError, - MongoCryptAzureKMSRequestError, - MongoCryptKMSRequestNetworkTimeoutError - }; - - Object.defineProperty(exports, '___azureKMSProseTestExports', { - enumerable: false, - configurable: false, - value: fetchAzureKMSToken - }); - - return exports; -} - -module.exports = { - extension, - MongoCryptError, - MongoCryptCreateEncryptedCollectionError, - MongoCryptCreateDataKeyError, - MongoCryptAzureKMSRequestError, - MongoCryptKMSRequestNetworkTimeoutError, - get AutoEncrypter() { - const m = loadDefaultModule(); - delete module.exports.AutoEncrypter; - module.exports.AutoEncrypter = m.AutoEncrypter; - return m.AutoEncrypter; - }, - get ClientEncryption() { - const m = loadDefaultModule(); - delete module.exports.ClientEncryption; - module.exports.ClientEncryption = m.ClientEncryption; - return m.ClientEncryption; - } -}; - -Object.defineProperty(module.exports, '___azureKMSProseTestExports', { - enumerable: false, - configurable: false, - value: fetchAzureKMSToken -}); diff --git a/encryption/lib/stateMachine.js b/encryption/lib/stateMachine.js deleted file mode 100644 index 30215c3ecf2..00000000000 --- a/encryption/lib/stateMachine.js +++ /dev/null @@ -1,492 +0,0 @@ -'use strict'; - -const { promisify } = require('util'); - -module.exports = function (modules) { - const tls = require('tls'); - const net = require('net'); - const fs = require('fs'); - const { once } = require('events'); - const { SocksClient } = require('socks'); - - // Try first to import 4.x name, fallback to 3.x name - const MongoNetworkTimeoutError = - modules.mongodb.MongoNetworkTimeoutError || modules.mongodb.MongoTimeoutError; - - const common = require('./common'); - const debug = common.debug; - const databaseNamespace = common.databaseNamespace; - const collectionNamespace = common.collectionNamespace; - const { MongoCryptError } = require('./errors'); - const { BufferPool } = require('./buffer_pool'); - - // libmongocrypt states - const MONGOCRYPT_CTX_ERROR = 0; - const MONGOCRYPT_CTX_NEED_MONGO_COLLINFO = 1; - const MONGOCRYPT_CTX_NEED_MONGO_MARKINGS = 2; - const MONGOCRYPT_CTX_NEED_MONGO_KEYS = 3; - const MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS = 7; - const MONGOCRYPT_CTX_NEED_KMS = 4; - const MONGOCRYPT_CTX_READY = 5; - const MONGOCRYPT_CTX_DONE = 6; - - const HTTPS_PORT = 443; - - const stateToString = new Map([ - [MONGOCRYPT_CTX_ERROR, 'MONGOCRYPT_CTX_ERROR'], - [MONGOCRYPT_CTX_NEED_MONGO_COLLINFO, 'MONGOCRYPT_CTX_NEED_MONGO_COLLINFO'], - [MONGOCRYPT_CTX_NEED_MONGO_MARKINGS, 'MONGOCRYPT_CTX_NEED_MONGO_MARKINGS'], - [MONGOCRYPT_CTX_NEED_MONGO_KEYS, 'MONGOCRYPT_CTX_NEED_MONGO_KEYS'], - [MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS, 'MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS'], - [MONGOCRYPT_CTX_NEED_KMS, 'MONGOCRYPT_CTX_NEED_KMS'], - [MONGOCRYPT_CTX_READY, 'MONGOCRYPT_CTX_READY'], - [MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE'] - ]); - - const INSECURE_TLS_OPTIONS = [ - 'tlsInsecure', - 'tlsAllowInvalidCertificates', - 'tlsAllowInvalidHostnames', - 'tlsDisableOCSPEndpointCheck', - 'tlsDisableCertificateRevocationCheck' - ]; - - /** - * @ignore - * @callback StateMachine~executeCallback - * @param {Error} [err] If present, indicates that the execute call failed with the given error - * @param {object} [result] If present, is the result of executing the state machine. - * @returns {void} - */ - - /** - * @ignore - * @callback StateMachine~fetchCollectionInfoCallback - * @param {Error} [err] If present, indicates that fetching the collection info failed with the given error - * @param {object} [result] If present, is the fetched collection info for the first collection to match the given filter - * @returns {void} - */ - - /** - * @ignore - * @callback StateMachine~markCommandCallback - * @param {Error} [err] If present, indicates that marking the command failed with the given error - * @param {Buffer} [result] If present, is the marked command serialized into bson - * @returns {void} - */ - - /** - * @ignore - * @callback StateMachine~fetchKeysCallback - * @param {Error} [err] If present, indicates that fetching the keys failed with the given error - * @param {object[]} [result] If present, is all the keys from the keyVault collection that matched the given filter - */ - - /** - * @ignore - * An internal class that executes across a MongoCryptContext until either - * a finishing state or an error is reached. Do not instantiate directly. - * @class StateMachine - */ - class StateMachine { - constructor(options) { - this.options = options || {}; - this.bson = options.bson; - - this.executeAsync = promisify((autoEncrypter, context, callback) => - this.execute(autoEncrypter, context, callback) - ); - } - - /** - * @ignore - * Executes the state machine according to the specification - * @param {AutoEncrypter|ClientEncryption} autoEncrypter The JS encryption object - * @param {object} context The C++ context object returned from the bindings - * @param {StateMachine~executeCallback} callback Invoked with the result/error of executing the state machine - * @returns {void} - */ - execute(autoEncrypter, context, callback) { - const bson = this.bson; - const keyVaultNamespace = autoEncrypter._keyVaultNamespace; - const keyVaultClient = autoEncrypter._keyVaultClient; - const metaDataClient = autoEncrypter._metaDataClient; - const mongocryptdClient = autoEncrypter._mongocryptdClient; - const mongocryptdManager = autoEncrypter._mongocryptdManager; - - debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`); - switch (context.state) { - case MONGOCRYPT_CTX_NEED_MONGO_COLLINFO: { - const filter = bson.deserialize(context.nextMongoOperation()); - this.fetchCollectionInfo(metaDataClient, context.ns, filter, (err, collInfo) => { - if (err) { - return callback(err, null); - } - - if (collInfo) { - context.addMongoOperationResponse(collInfo); - } - - context.finishMongoOperation(); - this.execute(autoEncrypter, context, callback); - }); - - return; - } - - case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: { - const command = context.nextMongoOperation(); - this.markCommand(mongocryptdClient, context.ns, command, (err, markedCommand) => { - if (err) { - // If we are not bypassing spawning, then we should retry once on a MongoTimeoutError (server selection error) - if ( - err instanceof MongoNetworkTimeoutError && - mongocryptdManager && - !mongocryptdManager.bypassSpawn - ) { - mongocryptdManager.spawn(() => { - // TODO: should we be shadowing the variables here? - this.markCommand(mongocryptdClient, context.ns, command, (err, markedCommand) => { - if (err) return callback(err, null); - - context.addMongoOperationResponse(markedCommand); - context.finishMongoOperation(); - - this.execute(autoEncrypter, context, callback); - }); - }); - return; - } - return callback(err, null); - } - context.addMongoOperationResponse(markedCommand); - context.finishMongoOperation(); - - this.execute(autoEncrypter, context, callback); - }); - - return; - } - - case MONGOCRYPT_CTX_NEED_MONGO_KEYS: { - const filter = context.nextMongoOperation(); - this.fetchKeys(keyVaultClient, keyVaultNamespace, filter, (err, keys) => { - if (err) return callback(err, null); - keys.forEach(key => { - context.addMongoOperationResponse(bson.serialize(key)); - }); - - context.finishMongoOperation(); - this.execute(autoEncrypter, context, callback); - }); - - return; - } - - case MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS: { - autoEncrypter - .askForKMSCredentials() - .then(kmsProviders => { - context.provideKMSProviders( - !Buffer.isBuffer(kmsProviders) ? bson.serialize(kmsProviders) : kmsProviders - ); - this.execute(autoEncrypter, context, callback); - }) - .catch(err => { - callback(err, null); - }); - - return; - } - - case MONGOCRYPT_CTX_NEED_KMS: { - const promises = []; - - let request; - while ((request = context.nextKMSRequest())) { - promises.push(this.kmsRequest(request)); - } - - Promise.all(promises) - .then(() => { - context.finishKMSRequests(); - this.execute(autoEncrypter, context, callback); - }) - .catch(err => { - callback(err, null); - }); - - return; - } - - // terminal states - case MONGOCRYPT_CTX_READY: { - const finalizedContext = context.finalize(); - // TODO: Maybe rework the logic here so that instead of doing - // the callback here, finalize stores the result, and then - // we wait to MONGOCRYPT_CTX_DONE to do the callback - if (context.state === MONGOCRYPT_CTX_ERROR) { - const message = context.status.message || 'Finalization error'; - callback(new MongoCryptError(message)); - return; - } - callback(null, bson.deserialize(finalizedContext, this.options)); - return; - } - case MONGOCRYPT_CTX_ERROR: { - const message = context.status.message; - callback(new MongoCryptError(message)); - return; - } - - case MONGOCRYPT_CTX_DONE: - callback(); - return; - - default: - callback(new MongoCryptError(`Unknown state: ${context.state}`)); - return; - } - } - - /** - * @ignore - * Handles the request to the KMS service. Exposed for testing purposes. Do not directly invoke. - * @param {*} kmsContext A C++ KMS context returned from the bindings - * @returns {Promise} A promise that resolves when the KMS reply has be fully parsed - */ - kmsRequest(request) { - const parsedUrl = request.endpoint.split(':'); - const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT; - const options = { host: parsedUrl[0], servername: parsedUrl[0], port }; - const message = request.message; - - // TODO(NODE-3959): We can adopt `for-await on(socket, 'data')` with logic to control abort - // eslint-disable-next-line no-async-promise-executor - return new Promise(async (resolve, reject) => { - const buffer = new BufferPool(); - - let socket; - let rawSocket; - - function destroySockets() { - for (const sock of [socket, rawSocket]) { - if (sock) { - sock.removeAllListeners(); - sock.destroy(); - } - } - } - - function ontimeout() { - destroySockets(); - reject(new MongoCryptError('KMS request timed out')); - } - - function onerror(err) { - destroySockets(); - const mcError = new MongoCryptError('KMS request failed'); - mcError.originalError = err; - reject(mcError); - } - - if (this.options.proxyOptions && this.options.proxyOptions.proxyHost) { - rawSocket = net.connect({ - host: this.options.proxyOptions.proxyHost, - port: this.options.proxyOptions.proxyPort || 1080 - }); - - rawSocket.on('timeout', ontimeout); - rawSocket.on('error', onerror); - try { - await once(rawSocket, 'connect'); - options.socket = ( - await SocksClient.createConnection({ - existing_socket: rawSocket, - command: 'connect', - destination: { host: options.host, port: options.port }, - proxy: { - // host and port are ignored because we pass existing_socket - host: 'iLoveJavaScript', - port: 0, - type: 5, - userId: this.options.proxyOptions.proxyUsername, - password: this.options.proxyOptions.proxyPassword - } - }) - ).socket; - } catch (err) { - return onerror(err); - } - } - - const tlsOptions = this.options.tlsOptions; - if (tlsOptions) { - const kmsProvider = request.kmsProvider; - const providerTlsOptions = tlsOptions[kmsProvider]; - if (providerTlsOptions) { - const error = this.validateTlsOptions(kmsProvider, providerTlsOptions); - if (error) reject(error); - this.setTlsOptions(providerTlsOptions, options); - } - } - socket = tls.connect(options, () => { - socket.write(message); - }); - - socket.once('timeout', ontimeout); - socket.once('error', onerror); - - socket.on('data', data => { - buffer.append(data); - while (request.bytesNeeded > 0 && buffer.length) { - const bytesNeeded = Math.min(request.bytesNeeded, buffer.length); - request.addResponse(buffer.read(bytesNeeded)); - } - - if (request.bytesNeeded <= 0) { - // There's no need for any more activity on this socket at this point. - destroySockets(); - resolve(); - } - }); - }); - } - - /** - * @ignore - * Validates the provided TLS options are secure. - * - * @param {string} kmsProvider The KMS provider name. - * @param {ClientEncryptionTLSOptions} tlsOptions The client TLS options for the provider. - * - * @returns {Error} If any option is invalid. - */ - validateTlsOptions(kmsProvider, tlsOptions) { - const tlsOptionNames = Object.keys(tlsOptions); - for (const option of INSECURE_TLS_OPTIONS) { - if (tlsOptionNames.includes(option)) { - return new MongoCryptError( - `Insecure TLS options prohibited for ${kmsProvider}: ${option}` - ); - } - } - } - - /** - * @ignore - * Sets only the valid secure TLS options. - * - * @param {ClientEncryptionTLSOptions} tlsOptions The client TLS options for the provider. - * @param {Object} options The existing connection options. - */ - setTlsOptions(tlsOptions, options) { - if (tlsOptions.tlsCertificateKeyFile) { - const cert = fs.readFileSync(tlsOptions.tlsCertificateKeyFile); - options.cert = options.key = cert; - } - if (tlsOptions.tlsCAFile) { - options.ca = fs.readFileSync(tlsOptions.tlsCAFile); - } - if (tlsOptions.tlsCertificateKeyFilePassword) { - options.passphrase = tlsOptions.tlsCertificateKeyFilePassword; - } - } - - /** - * @ignore - * Fetches collection info for a provided namespace, when libmongocrypt - * enters the `MONGOCRYPT_CTX_NEED_MONGO_COLLINFO` state. The result is - * used to inform libmongocrypt of the schema associated with this - * namespace. Exposed for testing purposes. Do not directly invoke. - * - * @param {MongoClient} client A MongoClient connected to the topology - * @param {string} ns The namespace to list collections from - * @param {object} filter A filter for the listCollections command - * @param {StateMachine~fetchCollectionInfoCallback} callback Invoked with the info of the requested collection, or with an error - */ - fetchCollectionInfo(client, ns, filter, callback) { - const bson = this.bson; - const dbName = databaseNamespace(ns); - - client - .db(dbName) - .listCollections(filter, { - promoteLongs: false, - promoteValues: false - }) - .toArray() - .then( - collections => { - const info = collections.length > 0 ? bson.serialize(collections[0]) : null; - return callback(null, info); - }, - err => { - callback(err, null); - } - ); - } - - /** - * @ignore - * Calls to the mongocryptd to provide markings for a command. - * Exposed for testing purposes. Do not directly invoke. - * @param {MongoClient} client A MongoClient connected to a mongocryptd - * @param {string} ns The namespace (database.collection) the command is being executed on - * @param {object} command The command to execute. - * @param {StateMachine~markCommandCallback} callback Invoked with the serialized and marked bson command, or with an error - * @returns {void} - */ - markCommand(client, ns, command, callback) { - const bson = this.bson; - const options = { promoteLongs: false, promoteValues: false }; - const dbName = databaseNamespace(ns); - const rawCommand = bson.deserialize(command, options); - - client - .db(dbName) - .command(rawCommand, options) - .then( - response => { - return callback(null, bson.serialize(response, this.options)); - }, - err => { - callback(err, null); - } - ); - } - - /** - * @ignore - * Requests keys from the keyVault collection on the topology. - * Exposed for testing purposes. Do not directly invoke. - * @param {MongoClient} client A MongoClient connected to the topology - * @param {string} keyVaultNamespace The namespace (database.collection) of the keyVault Collection - * @param {object} filter The filter for the find query against the keyVault Collection - * @param {StateMachine~fetchKeysCallback} callback Invoked with the found keys, or with an error - * @returns {void} - */ - fetchKeys(client, keyVaultNamespace, filter, callback) { - const bson = this.bson; - const dbName = databaseNamespace(keyVaultNamespace); - const collectionName = collectionNamespace(keyVaultNamespace); - filter = bson.deserialize(filter); - - client - .db(dbName) - .collection(collectionName, { readConcern: { level: 'majority' } }) - .find(filter) - .toArray() - .then( - keys => { - return callback(null, keys); - }, - err => { - callback(err, null); - } - ); - } - } - - return { StateMachine }; -}; diff --git a/encryption/test/autoEncrypter.test.js b/encryption/test/autoEncrypter.test.js deleted file mode 100644 index dbff3111fe6..00000000000 --- a/encryption/test/autoEncrypter.test.js +++ /dev/null @@ -1,950 +0,0 @@ -'use strict'; - -const fs = require('fs'); -const path = require('path'); -const sinon = require('sinon'); -const mongodb = require('mongodb'); -const BSON = mongodb.BSON; -const EJSON = BSON.EJSON; -const requirements = require('./requirements.helper'); -const MongoNetworkTimeoutError = mongodb.MongoNetworkTimeoutError || mongodb.MongoTimeoutError; -const MongoError = mongodb.MongoError; -const stateMachine = require('../lib/stateMachine')({ mongodb }); -const StateMachine = stateMachine.StateMachine; -const MongocryptdManager = require('../lib/mongocryptdManager').MongocryptdManager; - -const { expect } = require('chai'); - -const sharedLibrarySuffix = - process.platform === 'win32' ? 'dll' : process.platform === 'darwin' ? 'dylib' : 'so'; -let sharedLibraryStub = path.resolve( - __dirname, - '..', - '..', - '..', - `mongo_crypt_v1.${sharedLibrarySuffix}` -); -if (!fs.existsSync(sharedLibraryStub)) { - sharedLibraryStub = path.resolve( - __dirname, - '..', - 'deps', - 'tmp', - 'libmongocrypt-build', - ...(process.platform === 'win32' ? ['RelWithDebInfo'] : []), - `mongo_crypt_v1.${sharedLibrarySuffix}` - ); -} - -function readExtendedJsonToBuffer(path) { - const ejson = EJSON.parse(fs.readFileSync(path, 'utf8')); - return BSON.serialize(ejson); -} - -function readHttpResponse(path) { - let data = fs.readFileSync(path, 'utf8'); - data = data.split('\n').join('\r\n'); - return Buffer.from(data, 'utf8'); -} - -const TEST_COMMAND = JSON.parse(fs.readFileSync(`${__dirname}/data/cmd.json`)); -const MOCK_COLLINFO_RESPONSE = readExtendedJsonToBuffer(`${__dirname}/data/collection-info.json`); -const MOCK_MONGOCRYPTD_RESPONSE = readExtendedJsonToBuffer( - `${__dirname}/data/mongocryptd-reply.json` -); -const MOCK_KEYDOCUMENT_RESPONSE = readExtendedJsonToBuffer(`${__dirname}/data/key-document.json`); -const MOCK_KMS_DECRYPT_REPLY = readHttpResponse(`${__dirname}/data/kms-decrypt-reply.txt`); - -class MockClient { - constructor() { - this.topology = { - bson: BSON - }; - } -} - -const originalAccessKeyId = process.env.AWS_ACCESS_KEY_ID; -const originalSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; - -const AutoEncrypter = require('../lib/autoEncrypter')({ mongodb, stateMachine }).AutoEncrypter; -describe('AutoEncrypter', function () { - this.timeout(12000); - let ENABLE_LOG_TEST = false; - let sandbox = sinon.createSandbox(); - beforeEach(() => { - sandbox.restore(); - sandbox.stub(StateMachine.prototype, 'kmsRequest').callsFake(request => { - request.addResponse(MOCK_KMS_DECRYPT_REPLY); - return Promise.resolve(); - }); - - sandbox - .stub(StateMachine.prototype, 'fetchCollectionInfo') - .callsFake((client, ns, filter, callback) => { - callback(null, MOCK_COLLINFO_RESPONSE); - }); - - sandbox - .stub(StateMachine.prototype, 'markCommand') - .callsFake((client, ns, command, callback) => { - if (ENABLE_LOG_TEST) { - const response = BSON.deserialize(MOCK_MONGOCRYPTD_RESPONSE); - response.schemaRequiresEncryption = false; - - ENABLE_LOG_TEST = false; // disable test after run - callback(null, BSON.serialize(response)); - return; - } - - callback(null, MOCK_MONGOCRYPTD_RESPONSE); - }); - - sandbox.stub(StateMachine.prototype, 'fetchKeys').callsFake((client, ns, filter, callback) => { - // mock data is already serialized, our action deals with the result of a cursor - const deserializedKey = BSON.deserialize(MOCK_KEYDOCUMENT_RESPONSE); - callback(null, [deserializedKey]); - }); - }); - - afterEach(() => { - sandbox.restore(); - }); - - describe('#constructor', function () { - context('when mongodb exports BSON (driver >= 4.9.0)', function () { - context('when a bson option is provided', function () { - const bson = Object.assign({}, BSON); - const encrypter = new AutoEncrypter( - {}, - { - bson: bson, - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - - it('uses the bson option', function () { - expect(encrypter._bson).to.equal(bson); - }); - }); - - context('when a bson option is not provided', function () { - const encrypter = new AutoEncrypter( - {}, - { - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - - it('uses the mongodb exported BSON', function () { - expect(encrypter._bson).to.equal(BSON); - }); - }); - - it('never uses bson from the topology', function () { - expect(() => { - new AutoEncrypter( - {}, - { - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - }).not.to.throw(); - }); - }); - - context('when mongodb does not export BSON (driver < 4.9.0)', function () { - context('when a bson option is provided', function () { - const bson = Object.assign({}, BSON); - const encrypter = new AutoEncrypter( - {}, - { - bson: bson, - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - - it('uses the bson option', function () { - expect(encrypter._bson).to.equal(bson); - }); - }); - - context('when a bson option is not provided', function () { - const mongoNoBson = { ...mongodb, BSON: undefined }; - const AutoEncrypterNoBson = require('../lib/autoEncrypter')({ - mongodb: mongoNoBson, - stateMachine - }).AutoEncrypter; - - context('when the client has a topology', function () { - const client = new MockClient(); - const encrypter = new AutoEncrypterNoBson(client, { - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - }); - - it('uses the bson on the topology', function () { - expect(encrypter._bson).to.equal(client.topology.bson); - }); - }); - - context('when the client does not have a topology', function () { - it('raises an error', function () { - expect(() => { - new AutoEncrypterNoBson({}, {}); - }).to.throw(/bson/); - }); - }); - }); - }); - - context('when using mongocryptd', function () { - const client = new MockClient(); - const autoEncrypterOptions = { - mongocryptdBypassSpawn: true, - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }; - const autoEncrypter = new AutoEncrypter(client, autoEncrypterOptions); - - it('instantiates a mongo client on the auto encrypter', function () { - expect(autoEncrypter) - .to.have.property('_mongocryptdClient') - .to.be.instanceOf(mongodb.MongoClient); - }); - - it('sets the 3x legacy client options on the mongo client', function () { - expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); - const options = autoEncrypter._mongocryptdClient.s.options; - expect(options).to.have.property('useUnifiedTopology', true); - expect(options).to.have.property('useNewUrlParser', true); - }); - - it('sets serverSelectionTimeoutMS to 10000ms', function () { - expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); - const options = autoEncrypter._mongocryptdClient.s.options; - expect(options).to.have.property('serverSelectionTimeoutMS', 10000); - }); - - context('when mongocryptdURI is not specified', () => { - it('sets the ip address family to ipv4', function () { - expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); - const options = autoEncrypter._mongocryptdClient.s.options; - expect(options).to.have.property('family', 4); - }); - }); - - context('when mongocryptdURI is specified', () => { - it('does not set the ip address family to ipv4', function () { - const autoEncrypter = new AutoEncrypter(client, { - ...autoEncrypterOptions, - extraOptions: { mongocryptdURI: MongocryptdManager.DEFAULT_MONGOCRYPTD_URI } - }); - - expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); - const options = autoEncrypter._mongocryptdClient.s.options; - expect(options).not.to.have.property('family', 4); - }); - }); - }); - }); - - it('should support `bypassAutoEncryption`', function (done) { - const client = new MockClient(); - const autoEncrypter = new AutoEncrypter(client, { - bypassAutoEncryption: true, - mongocryptdBypassSpawn: true, - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - - autoEncrypter.encrypt('test.test', { test: 'command' }, (err, encrypted) => { - expect(err).to.not.exist; - expect(encrypted).to.eql({ test: 'command' }); - done(); - }); - }); - - describe('state machine', function () { - it('should decrypt mock data', function (done) { - const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - mc.decrypt(input, (err, decrypted) => { - if (err) return done(err); - expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); - expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - expect(decrypted.filter).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - done(); - }); - }); - - it('should decrypt mock data and mark decrypted items if enabled for testing', function (done) { - const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); - const nestedInput = readExtendedJsonToBuffer( - `${__dirname}/data/encrypted-document-nested.json` - ); - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - mc[Symbol.for('@@mdb.decorateDecryptionResult')] = true; - mc.decrypt(input, (err, decrypted) => { - if (err) return done(err); - expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); - expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - expect(decrypted.filter[Symbol.for('@@mdb.decryptedKeys')]).to.eql(['ssn']); - - // The same, but with an object containing different data types as the input - mc.decrypt({ a: [null, 1, { c: new BSON.Binary('foo', 1) }] }, (err, decrypted) => { - if (err) return done(err); - expect(decrypted).to.eql({ a: [null, 1, { c: new BSON.Binary('foo', 1) }] }); - expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - - // The same, but with nested data inside the decrypted input - mc.decrypt(nestedInput, (err, decrypted) => { - if (err) return done(err); - expect(decrypted).to.eql({ nested: { x: { y: 1234 } } }); - expect(decrypted[Symbol.for('@@mdb.decryptedKeys')]).to.eql(['nested']); - expect(decrypted.nested).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - expect(decrypted.nested.x).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - expect(decrypted.nested.x.y).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); - done(); - }); - }); - }); - }); - - it('should decrypt mock data with per-context KMS credentials', function (done) { - const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: {} - }, - async onKmsProviderRefresh() { - return { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }; - } - }); - mc.decrypt(input, (err, decrypted) => { - if (err) return done(err); - expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); - done(); - }); - }); - - context('when no refresh function is provided', function () { - const accessKey = 'example'; - const secretKey = 'example'; - - before(function () { - if (!requirements.credentialProvidersInstalled.aws) { - this.currentTest.skipReason = 'Cannot refresh credentials without sdk provider'; - this.currentTest.skip(); - return; - } - // After the entire suite runs, set the env back for the rest of the test run. - process.env.AWS_ACCESS_KEY_ID = accessKey; - process.env.AWS_SECRET_ACCESS_KEY = secretKey; - }); - - after(function () { - // After the entire suite runs, set the env back for the rest of the test run. - process.env.AWS_ACCESS_KEY_ID = originalAccessKeyId; - process.env.AWS_SECRET_ACCESS_KEY = originalSecretAccessKey; - }); - - it('should decrypt mock data with KMS credentials from the environment', function (done) { - const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: {} - } - }); - mc.decrypt(input, (err, decrypted) => { - if (err) return done(err); - expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); - done(); - }); - }); - }); - - context('when no refresh function is provided and no optional sdk', function () { - const accessKey = 'example'; - const secretKey = 'example'; - - before(function () { - if (requirements.credentialProvidersInstalled.aws) { - this.currentTest.skipReason = 'With optional sdk installed credentials would be loaded.'; - this.currentTest.skip(); - return; - } - // After the entire suite runs, set the env back for the rest of the test run. - process.env.AWS_ACCESS_KEY_ID = accessKey; - process.env.AWS_SECRET_ACCESS_KEY = secretKey; - }); - - after(function () { - // After the entire suite runs, set the env back for the rest of the test run. - process.env.AWS_ACCESS_KEY_ID = originalAccessKeyId; - process.env.AWS_SECRET_ACCESS_KEY = originalSecretAccessKey; - }); - - it('errors without the optional sdk credential provider', function (done) { - const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: {} - } - }); - mc.decrypt(input, err => { - expect(err.message).to.equal( - 'client not configured with KMS provider necessary to decrypt' - ); - done(); - }); - }); - }); - - it('should encrypt mock data', function (done) { - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - - mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { - if (err) return done(err); - const expected = EJSON.parse( - JSON.stringify({ - find: 'test', - filter: { - ssn: { - $binary: { - base64: - 'AWFhYWFhYWFhYWFhYWFhYWECRTOW9yZzNDn5dGwuqsrJQNLtgMEKaujhs9aRWRp+7Yo3JK8N8jC8P0Xjll6C1CwLsE/iP5wjOMhVv1KMMyOCSCrHorXRsb2IKPtzl2lKTqQ=', - subType: '6' - } - } - } - }) - ); - - expect(encrypted).to.containSubset(expected); - done(); - }); - }); - - it('should encrypt mock data with per-context KMS credentials', function (done) { - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: {} - }, - async onKmsProviderRefresh() { - return { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }; - } - }); - - mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { - if (err) return done(err); - const expected = EJSON.parse( - JSON.stringify({ - find: 'test', - filter: { - ssn: { - $binary: { - base64: - 'AWFhYWFhYWFhYWFhYWFhYWECRTOW9yZzNDn5dGwuqsrJQNLtgMEKaujhs9aRWRp+7Yo3JK8N8jC8P0Xjll6C1CwLsE/iP5wjOMhVv1KMMyOCSCrHorXRsb2IKPtzl2lKTqQ=', - subType: '6' - } - } - } - }) - ); - - expect(encrypted).to.containSubset(expected); - done(); - }); - }); - - // TODO(NODE-4089): Enable test once https://github.com/mongodb/libmongocrypt/pull/263 is done - it.skip('should encrypt mock data when using the crypt_shared library', function (done) { - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: {} - }, - async onKmsProviderRefresh() { - return { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }; - }, - extraOptions: { - cryptSharedLibPath: sharedLibraryStub - } - }); - - expect(mc).to.not.have.property('_mongocryptdManager'); - expect(mc).to.not.have.property('_mongocryptdClient'); - - mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { - if (err) return done(err); - const expected = EJSON.parse( - JSON.stringify({ - find: 'test', - filter: { - ssn: { - $binary: { - base64: - 'AWFhYWFhYWFhYWFhYWFhYWECRTOW9yZzNDn5dGwuqsrJQNLtgMEKaujhs9aRWRp+7Yo3JK8N8jC8P0Xjll6C1CwLsE/iP5wjOMhVv1KMMyOCSCrHorXRsb2IKPtzl2lKTqQ=', - subType: '6' - } - } - } - }) - ); - - expect(encrypted).to.containSubset(expected); - done(); - }); - }); - }); - - describe('logging', function () { - it('should allow registration of a log handler', function (done) { - ENABLE_LOG_TEST = true; - - let loggerCalled = false; - const logger = (level, message) => { - if (loggerCalled) return; - - loggerCalled = true; - expect(level).to.be.oneOf([2, 3]); - expect(message).to.not.be.empty; - }; - - const client = new MockClient(); - const mc = new AutoEncrypter(client, { - logger, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - - mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { - if (err) return done(err); - const expected = EJSON.parse( - JSON.stringify({ - find: 'test', - filter: { - ssn: '457-55-5462' - } - }) - ); - - expect(encrypted).to.containSubset(expected); - done(); - }); - }); - }); - - describe('autoSpawn', function () { - beforeEach(function () { - if (requirements.SKIP_LIVE_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_LIVE_TESTS=${requirements.SKIP_LIVE_TESTS}`; - this.currentTest.skip(); - return; - } - }); - afterEach(function (done) { - if (this.mc) { - this.mc.teardown(false, err => { - this.mc = undefined; - done(err); - }); - } else { - done(); - } - }); - - it('should autoSpawn a mongocryptd on init by default', function (done) { - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - - expect(this.mc).to.have.property('cryptSharedLibVersionInfo', null); - - const localMcdm = this.mc._mongocryptdManager; - sandbox.spy(localMcdm, 'spawn'); - - this.mc.init(err => { - if (err) return done(err); - expect(localMcdm.spawn).to.have.been.calledOnce; - done(); - }); - }); - - it('should not attempt to kick off mongocryptd on a normal error', function (done) { - let called = false; - StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { - if (!called) { - called = true; - callback(new Error('msg')); - return; - } - - callback(null, MOCK_MONGOCRYPTD_RESPONSE); - }); - - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - expect(this.mc).to.have.property('cryptSharedLibVersionInfo', null); - - const localMcdm = this.mc._mongocryptdManager; - this.mc.init(err => { - if (err) return done(err); - - sandbox.spy(localMcdm, 'spawn'); - - this.mc.encrypt('test.test', TEST_COMMAND, err => { - expect(localMcdm.spawn).to.not.have.been.called; - expect(err).to.be.an.instanceOf(Error); - done(); - }); - }); - }); - - it('should restore the mongocryptd and retry once if a MongoNetworkTimeoutError is experienced', function (done) { - let called = false; - StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { - if (!called) { - called = true; - callback(new MongoNetworkTimeoutError('msg')); - return; - } - - callback(null, MOCK_MONGOCRYPTD_RESPONSE); - }); - - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - expect(this.mc).to.have.property('cryptSharedLibVersionInfo', null); - - const localMcdm = this.mc._mongocryptdManager; - this.mc.init(err => { - if (err) return done(err); - - sandbox.spy(localMcdm, 'spawn'); - - this.mc.encrypt('test.test', TEST_COMMAND, err => { - expect(localMcdm.spawn).to.have.been.calledOnce; - expect(err).to.not.exist; - done(); - }); - }); - }); - - it('should propagate error if MongoNetworkTimeoutError is experienced twice in a row', function (done) { - let counter = 2; - StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { - if (counter) { - counter -= 1; - callback(new MongoNetworkTimeoutError('msg')); - return; - } - - callback(null, MOCK_MONGOCRYPTD_RESPONSE); - }); - - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - } - }); - expect(this.mc).to.have.property('cryptSharedLibVersionInfo', null); - - const localMcdm = this.mc._mongocryptdManager; - this.mc.init(err => { - if (err) return done(err); - - sandbox.spy(localMcdm, 'spawn'); - - this.mc.encrypt('test.test', TEST_COMMAND, err => { - expect(localMcdm.spawn).to.have.been.calledOnce; - expect(err).to.be.an.instanceof(MongoNetworkTimeoutError); - done(); - }); - }); - }); - - it('should return a useful message if mongocryptd fails to autospawn', function (done) { - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - }, - extraOptions: { - mongocryptdURI: 'mongodb://something.invalid:27020/' - } - }); - expect(this.mc).to.have.property('cryptSharedLibVersionInfo', null); - - sandbox.stub(MongocryptdManager.prototype, 'spawn').callsFake(callback => { - callback(); - }); - - this.mc.init(err => { - expect(err).to.exist; - expect(err).to.be.instanceOf(MongoError); - done(); - }); - }); - }); - - describe('noAutoSpawn', function () { - beforeEach('start MongocryptdManager', function (done) { - if (requirements.SKIP_LIVE_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_LIVE_TESTS=${requirements.SKIP_LIVE_TESTS}`; - this.skip(); - } - - this.mcdm = new MongocryptdManager({}); - this.mcdm.spawn(done); - }); - - afterEach(function (done) { - if (this.mc) { - this.mc.teardown(false, err => { - this.mc = undefined; - done(err); - }); - } else { - done(); - } - }); - - ['mongocryptdBypassSpawn', 'bypassAutoEncryption', 'bypassQueryAnalysis'].forEach(opt => { - const encryptionOptions = { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - }, - extraOptions: { - mongocryptdBypassSpawn: opt === 'mongocryptdBypassSpawn' - }, - bypassAutoEncryption: opt === 'bypassAutoEncryption', - bypassQueryAnalysis: opt === 'bypassQueryAnalysis' - }; - - it(`should not spawn mongocryptd on startup if ${opt} is true`, function (done) { - const client = new MockClient(); - this.mc = new AutoEncrypter(client, encryptionOptions); - - const localMcdm = this.mc._mongocryptdManager || { spawn: () => {} }; - sandbox.spy(localMcdm, 'spawn'); - - this.mc.init(err => { - expect(err).to.not.exist; - expect(localMcdm.spawn).to.have.a.callCount(0); - done(); - }); - }); - }); - - it('should not spawn a mongocryptd or retry on a server selection error if mongocryptdBypassSpawn: true', function (done) { - let called = false; - const timeoutError = new MongoNetworkTimeoutError('msg'); - StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { - if (!called) { - called = true; - callback(timeoutError); - return; - } - - callback(null, MOCK_MONGOCRYPTD_RESPONSE); - }); - - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - }, - extraOptions: { - mongocryptdBypassSpawn: true - } - }); - - const localMcdm = this.mc._mongocryptdManager; - sandbox.spy(localMcdm, 'spawn'); - - this.mc.init(err => { - expect(err).to.not.exist; - expect(localMcdm.spawn).to.not.have.been.called; - - this.mc.encrypt('test.test', TEST_COMMAND, (err, response) => { - expect(localMcdm.spawn).to.not.have.been.called; - expect(response).to.not.exist; - expect(err).to.equal(timeoutError); - done(); - }); - }); - }); - }); - - describe('crypt_shared library', function () { - it('should fail if no library can be found in the search path and cryptSharedLibRequired is set', function () { - // NB: This test has to be run before the tests/without having previously - // loaded a CSFLE shared library below to get the right error path. - const client = new MockClient(); - try { - new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - }, - extraOptions: { - cryptSharedLibSearchPaths: ['/nonexistent'], - cryptSharedLibRequired: true - } - }); - expect.fail('missed exception'); - } catch (err) { - expect(err.message).to.include( - '`cryptSharedLibRequired` set but no crypt_shared library loaded' - ); - } - }); - - it('should load a shared library by specifying its path', function (done) { - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - }, - extraOptions: { - cryptSharedLibPath: sharedLibraryStub - } - }); - - expect(this.mc).to.not.have.property('_mongocryptdManager'); - expect(this.mc).to.not.have.property('_mongocryptdClient'); - expect(this.mc).to.have.deep.property('cryptSharedLibVersionInfo', { - // eslint-disable-next-line no-undef - version: BigInt(0x000600020001000), - versionStr: 'stubbed-crypt_shared' - }); - - this.mc.teardown(true, done); - }); - - it('should load a shared library by specifying a search path', function (done) { - const client = new MockClient(); - this.mc = new AutoEncrypter(client, { - keyVaultNamespace: 'admin.datakeys', - logger: () => {}, - kmsProviders: { - aws: { accessKeyId: 'example', secretAccessKey: 'example' }, - local: { key: Buffer.alloc(96) } - }, - extraOptions: { - cryptSharedLibSearchPaths: [path.dirname(sharedLibraryStub)] - } - }); - - expect(this.mc).to.not.have.property('_mongocryptdManager'); - expect(this.mc).to.not.have.property('_mongocryptdClient'); - expect(this.mc).to.have.deep.property('cryptSharedLibVersionInfo', { - // eslint-disable-next-line no-undef - version: BigInt(0x000600020001000), - versionStr: 'stubbed-crypt_shared' - }); - - this.mc.teardown(true, done); - }); - }); - - it('should provide the libmongocrypt version', function () { - expect(AutoEncrypter.libmongocryptVersion).to.be.a('string'); - }); -}); diff --git a/encryption/test/buffer_pool.test.js b/encryption/test/buffer_pool.test.js deleted file mode 100644 index 973e4f74e6b..00000000000 --- a/encryption/test/buffer_pool.test.js +++ /dev/null @@ -1,91 +0,0 @@ -'use strict'; - -const { BufferPool } = require('../lib/buffer_pool'); -const { expect } = require('chai'); - -describe('new BufferPool()', function () { - it('should report the correct length', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1])); - buffer.append(Buffer.from([2, 3])); - buffer.append(Buffer.from([2, 3])); - expect(buffer).property('length').to.equal(6); - }); - - it('return an empty buffer if too many bytes requested', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1, 2, 3])); - const data = buffer.read(6); - expect(data).to.have.length(0); - expect(buffer).property('length').to.equal(4); - }); - - context('peek', function () { - it('exact size', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1])); - const data = buffer.peek(2); - expect(data).to.eql(Buffer.from([0, 1])); - expect(buffer).property('length').to.equal(2); - }); - - it('within first buffer', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1, 2, 3])); - const data = buffer.peek(2); - expect(data).to.eql(Buffer.from([0, 1])); - expect(buffer).property('length').to.equal(4); - }); - - it('across multiple buffers', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1])); - buffer.append(Buffer.from([2, 3])); - buffer.append(Buffer.from([4, 5])); - expect(buffer).property('length').to.equal(6); - const data = buffer.peek(5); - expect(data).to.eql(Buffer.from([0, 1, 2, 3, 4])); - expect(buffer).property('length').to.equal(6); - }); - }); - - context('read', function () { - it('should throw an error if a negative size is requested', function () { - const buffer = new BufferPool(); - expect(() => buffer.read(-1)).to.throw(/Argument "size" must be a non-negative number/); - }); - - it('should throw an error if a non-number size is requested', function () { - const buffer = new BufferPool(); - expect(() => buffer.read('256')).to.throw(/Argument "size" must be a non-negative number/); - }); - - it('exact size', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1])); - const data = buffer.read(2); - expect(data).to.eql(Buffer.from([0, 1])); - expect(buffer).property('length').to.equal(0); - }); - - it('within first buffer', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1, 2, 3])); - const data = buffer.read(2); - expect(data).to.eql(Buffer.from([0, 1])); - expect(buffer).property('length').to.equal(2); - }); - - it('across multiple buffers', function () { - const buffer = new BufferPool(); - buffer.append(Buffer.from([0, 1])); - buffer.append(Buffer.from([2, 3])); - buffer.append(Buffer.from([4, 5])); - expect(buffer).property('length').to.equal(6); - const data = buffer.read(5); - expect(data).to.eql(Buffer.from([0, 1, 2, 3, 4])); - expect(buffer).property('length').to.equal(1); - expect(buffer.read(1)).to.eql(Buffer.from([5])); - }); - }); -}); diff --git a/encryption/test/clientEncryption.test.js b/encryption/test/clientEncryption.test.js deleted file mode 100644 index 837bb85d491..00000000000 --- a/encryption/test/clientEncryption.test.js +++ /dev/null @@ -1,1093 +0,0 @@ -'use strict'; -const fs = require('fs'); -const { expect } = require('chai'); -const sinon = require('sinon'); -const mongodb = require('mongodb'); -const BSON = mongodb.BSON; -const MongoClient = mongodb.MongoClient; -const cryptoCallbacks = require('../lib/cryptoCallbacks'); -const stateMachine = require('../lib/stateMachine')({ mongodb }); -const StateMachine = stateMachine.StateMachine; -const { Binary, EJSON, deserialize } = BSON; -const { - MongoCryptCreateEncryptedCollectionError, - MongoCryptCreateDataKeyError -} = require('../lib/errors'); - -function readHttpResponse(path) { - let data = fs.readFileSync(path, 'utf8').toString(); - data = data.split('\n').join('\r\n'); - return Buffer.from(data, 'utf8'); -} - -const ClientEncryption = require('../lib/clientEncryption')({ - mongodb, - stateMachine -}).ClientEncryption; - -class MockClient { - constructor() { - this.topology = { - bson: BSON - }; - } - db(dbName) { - return { - async createCollection(name, options) { - return { namespace: `${dbName}.${name}`, options }; - } - }; - } -} - -const requirements = require('./requirements.helper'); - -describe('ClientEncryption', function () { - this.timeout(12000); - /** @type {MongoClient} */ - let client; - - function throwIfNotNsNotFoundError(err) { - if (!err.message.match(/ns not found/)) { - throw err; - } - } - - async function setup() { - client = new MongoClient(process.env.MONGODB_URI || 'mongodb://localhost:27017/test'); - await client.connect(); - try { - await client.db('client').collection('encryption').drop(); - } catch (err) { - throwIfNotNsNotFoundError(err); - } - } - - function teardown() { - if (requirements.SKIP_LIVE_TESTS) { - return Promise.resolve(); - } - - return client.close(); - } - - describe('#constructor', function () { - context('when mongodb exports BSON (driver >= 4.9.0)', function () { - context('when a bson option is provided', function () { - const bson = Object.assign({}, BSON); - const encrypter = new ClientEncryption( - {}, - { - bson: bson, - keyVaultNamespace: 'client.encryption', - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - - it('uses the bson option', function () { - expect(encrypter._bson).to.equal(bson); - }); - }); - - context('when a bson option is not provided', function () { - const encrypter = new ClientEncryption( - {}, - { - keyVaultNamespace: 'client.encryption', - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - - it('uses the mongodb exported BSON', function () { - expect(encrypter._bson).to.equal(BSON); - }); - }); - - it('never uses bson from the topology', function () { - expect(() => { - new ClientEncryption( - {}, - { - keyVaultNamespace: 'client.encryption', - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - }).not.to.throw(); - }); - }); - - context('when mongodb does not export BSON (driver < 4.9.0)', function () { - context('when a bson option is provided', function () { - const bson = Object.assign({}, BSON); - const encrypter = new ClientEncryption( - {}, - { - bson: bson, - keyVaultNamespace: 'client.encryption', - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - } - ); - - it('uses the bson option', function () { - expect(encrypter._bson).to.equal(bson); - }); - }); - - context('when a bson option is not provided', function () { - const mongoNoBson = { ...mongodb, BSON: undefined }; - const ClientEncryptionNoBson = require('../lib/clientEncryption')({ - mongodb: mongoNoBson, - stateMachine - }).ClientEncryption; - - context('when the client has a topology', function () { - const client = new MockClient(); - const encrypter = new ClientEncryptionNoBson(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { - local: { key: Buffer.alloc(96) } - } - }); - - it('uses the bson on the topology', function () { - expect(encrypter._bson).to.equal(client.topology.bson); - }); - }); - - context('when the client does not have a topology', function () { - it('raises an error', function () { - expect(() => { - new ClientEncryptionNoBson({}, {}); - }).to.throw(/bson/); - }); - }); - }); - }); - }); - - describe('stubbed stateMachine', function () { - let sandbox = sinon.createSandbox(); - - after(() => sandbox.restore()); - before(() => { - // stubbed out for AWS unit testing below - const MOCK_KMS_ENCRYPT_REPLY = readHttpResponse(`${__dirname}/data/kms-encrypt-reply.txt`); - sandbox.stub(StateMachine.prototype, 'kmsRequest').callsFake(request => { - request.addResponse(MOCK_KMS_ENCRYPT_REPLY); - return Promise.resolve(); - }); - }); - - beforeEach(function () { - if (requirements.SKIP_LIVE_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_LIVE_TESTS=${requirements.SKIP_LIVE_TESTS}`; - this.test.skip(); - return; - } - - return setup(); - }); - - afterEach(function () { - return teardown(); - }); - - [ - { - name: 'local', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }, - { - name: 'aws', - kmsProviders: { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }, - options: { masterKey: { region: 'region', key: 'cmk' } } - } - ].forEach(providerTest => { - it(`should create a data key with the "${providerTest.name}" KMS provider`, async function () { - const providerName = providerTest.name; - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: providerTest.kmsProviders - }); - - const dataKeyOptions = providerTest.options || {}; - - const dataKey = await encryption.createDataKey(providerName, dataKeyOptions); - expect(dataKey).property('_bsontype', 'Binary'); - - const doc = await client.db('client').collection('encryption').findOne({ _id: dataKey }); - expect(doc).to.have.property('masterKey'); - expect(doc.masterKey).property('provider', providerName); - }); - - it(`should create a data key with the "${providerTest.name}" KMS provider (fixed key material)`, async function () { - const providerName = providerTest.name; - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: providerTest.kmsProviders - }); - - const dataKeyOptions = { - ...providerTest.options, - keyMaterial: new BSON.Binary(Buffer.alloc(96)) - }; - - const dataKey = await encryption.createDataKey(providerName, dataKeyOptions); - expect(dataKey).property('_bsontype', 'Binary'); - - const doc = await client.db('client').collection('encryption').findOne({ _id: dataKey }); - expect(doc).to.have.property('masterKey'); - expect(doc.masterKey).property('provider', providerName); - }); - }); - - it(`should create a data key with the local KMS provider (fixed key material, fixed key UUID)`, async function () { - // 'Custom Key Material Test' prose spec test: - const keyVaultColl = client.db('client').collection('encryption'); - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { - local: { - key: 'A'.repeat(128) // the value here is not actually relevant - } - } - }); - - const dataKeyOptions = { - keyMaterial: new BSON.Binary( - Buffer.from( - 'xPTAjBRG5JiPm+d3fj6XLi2q5DMXUS/f1f+SMAlhhwkhDRL0kr8r9GDLIGTAGlvC+HVjSIgdL+RKwZCvpXSyxTICWSXTUYsWYPyu3IoHbuBZdmw2faM3WhcRIgbMReU5', - 'base64' - ) - ) - }; - const dataKey = await encryption.createDataKey('local', dataKeyOptions); - expect(dataKey._bsontype).to.equal('Binary'); - - // Remove and re-insert with a fixed UUID to guarantee consistent output - const doc = ( - await keyVaultColl.findOneAndDelete({ _id: dataKey }, { writeConcern: { w: 'majority' } }) - ).value; - doc._id = new BSON.Binary(Buffer.alloc(16), 4); - await keyVaultColl.insertOne(doc, { writeConcern: { w: 'majority' } }); - - const encrypted = await encryption.encrypt('test', { - keyId: doc._id, - algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' - }); - expect(encrypted._bsontype).to.equal('Binary'); - expect(encrypted.toString('base64')).to.equal( - 'AQAAAAAAAAAAAAAAAAAAAAACz0ZOLuuhEYi807ZXTdhbqhLaS2/t9wLifJnnNYwiw79d75QYIZ6M/aYC1h9nCzCjZ7pGUpAuNnkUhnIXM3PjrA==' - ); - }); - - it('should fail to create a data key if keyMaterial is wrong', function (done) { - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: 'A'.repeat(128) } } - }); - - const dataKeyOptions = { - keyMaterial: new BSON.Binary(Buffer.alloc(97)) - }; - try { - encryption.createDataKey('local', dataKeyOptions); - expect.fail('missed exception'); - } catch (err) { - expect(err.message).to.equal('keyMaterial should have length 96, but has length 97'); - done(); - } - }); - - it('should explicitly encrypt and decrypt with the "local" KMS provider', function (done) { - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - encryption.createDataKey('local', (err, dataKey) => { - expect(err).to.not.exist; - - const encryptOptions = { - keyId: dataKey, - algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' - }; - - encryption.encrypt('hello', encryptOptions, (err, encrypted) => { - expect(err).to.not.exist; - expect(encrypted._bsontype).to.equal('Binary'); - expect(encrypted.sub_type).to.equal(6); - - encryption.decrypt(encrypted, (err, decrypted) => { - expect(err).to.not.exist; - expect(decrypted).to.equal('hello'); - done(); - }); - }); - }); - }); - - it('should explicitly encrypt and decrypt with the "local" KMS provider (promise)', function () { - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - return encryption - .createDataKey('local') - .then(dataKey => { - const encryptOptions = { - keyId: dataKey, - algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' - }; - - return encryption.encrypt('hello', encryptOptions); - }) - .then(encrypted => { - expect(encrypted._bsontype).to.equal('Binary'); - expect(encrypted.sub_type).to.equal(6); - - return encryption.decrypt(encrypted); - }) - .then(decrypted => { - expect(decrypted).to.equal('hello'); - }); - }); - - it('should explicitly encrypt and decrypt with a re-wrapped local key', function () { - // Create new ClientEncryption instances to make sure - // that we are actually using the rewrapped keys and not - // something that has been cached. - const newClientEncryption = () => - new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: 'A'.repeat(128) } } - }); - let encrypted; - - return newClientEncryption() - .createDataKey('local') - .then(dataKey => { - const encryptOptions = { - keyId: dataKey, - algorithm: 'Indexed', - contentionFactor: 0 - }; - - return newClientEncryption().encrypt('hello', encryptOptions); - }) - .then(_encrypted => { - encrypted = _encrypted; - expect(encrypted._bsontype).to.equal('Binary'); - expect(encrypted.sub_type).to.equal(6); - }) - .then(() => { - return newClientEncryption().rewrapManyDataKey({}); - }) - .then(rewrapManyDataKeyResult => { - expect(rewrapManyDataKeyResult.bulkWriteResult.result.nModified).to.equal(1); - return newClientEncryption().decrypt(encrypted); - }) - .then(decrypted => { - expect(decrypted).to.equal('hello'); - }); - }); - - it('should not perform updates if no keys match', function () { - const clientEncryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: 'A'.repeat(128) } } - }); - - return clientEncryption.rewrapManyDataKey({ _id: 12345 }).then(rewrapManyDataKeyResult => { - expect(rewrapManyDataKeyResult.bulkWriteResult).to.equal(undefined); - }); - }); - - it.skip('should explicitly encrypt and decrypt with a re-wrapped local key (explicit session/transaction)', function () { - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: 'A'.repeat(128) } } - }); - let encrypted; - let rewrapManyDataKeyResult; - - return encryption - .createDataKey('local') - .then(dataKey => { - const encryptOptions = { - keyId: dataKey, - algorithm: 'Indexed', - contentionFactor: 0 - }; - - return encryption.encrypt('hello', encryptOptions); - }) - .then(_encrypted => { - encrypted = _encrypted; - }) - .then(() => { - // withSession does not forward the callback's return value, hence - // the slightly awkward 'rewrapManyDataKeyResult' passing here - return client.withSession(session => { - return session.withTransaction(() => { - expect(session.transaction.isStarting).to.equal(true); - expect(session.transaction.isActive).to.equal(true); - rewrapManyDataKeyResult = encryption.rewrapManyDataKey( - {}, - { provider: 'local', session } - ); - return rewrapManyDataKeyResult.then(() => { - // Verify that the 'session' argument was actually used - expect(session.transaction.isStarting).to.equal(false); - expect(session.transaction.isActive).to.equal(true); - }); - }); - }); - }) - .then(() => { - return rewrapManyDataKeyResult; - }) - .then(rewrapManyDataKeyResult => { - expect(rewrapManyDataKeyResult.bulkWriteResult.result.nModified).to.equal(1); - return encryption.decrypt(encrypted); - }) - .then(decrypted => { - expect(decrypted).to.equal('hello'); - }); - }).skipReason = 'TODO(DRIVERS-2389): add explicit session support to key management API'; - - // TODO(NODE-3371): resolve KMS JSON response does not include string 'Plaintext'. HTTP status=200 error - it.skip('should explicitly encrypt and decrypt with the "aws" KMS provider', function (done) { - const encryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { aws: { accessKeyId: 'example', secretAccessKey: 'example' } } - }); - - const dataKeyOptions = { - masterKey: { region: 'region', key: 'cmk' } - }; - - encryption.createDataKey('aws', dataKeyOptions, (err, dataKey) => { - expect(err).to.not.exist; - - const encryptOptions = { - keyId: dataKey, - algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' - }; - - encryption.encrypt('hello', encryptOptions, (err, encrypted) => { - expect(err).to.not.exist; - expect(encrypted).to.have.property('v'); - expect(encrypted.v._bsontype).to.equal('Binary'); - expect(encrypted.v.sub_type).to.equal(6); - - encryption.decrypt(encrypted, (err, decrypted) => { - expect(err).to.not.exist; - expect(decrypted).to.equal('hello'); - done(); - }); - }); - }); - }).skipReason = - "TODO(NODE-3371): resolve KMS JSON response does not include string 'Plaintext'. HTTP status=200 error"; - }); - - describe('ClientEncryptionKeyAltNames', function () { - const kmsProviders = requirements.awsKmsProviders; - const dataKeyOptions = requirements.awsDataKeyOptions; - beforeEach(function () { - if (requirements.SKIP_AWS_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_AWS_TESTS=${requirements.SKIP_AWS_TESTS}`; - this.currentTest.skip(); - return; - } - - return setup().then(() => { - this.client = client; - this.collection = client.db('client').collection('encryption'); - this.encryption = new ClientEncryption(this.client, { - keyVaultNamespace: 'client.encryption', - kmsProviders - }); - }); - }); - - afterEach(function () { - return teardown().then(() => { - this.encryption = undefined; - this.collection = undefined; - this.client = undefined; - }); - }); - - function makeOptions(keyAltNames) { - expect(dataKeyOptions.masterKey).to.be.an('object'); - expect(dataKeyOptions.masterKey.key).to.be.a('string'); - expect(dataKeyOptions.masterKey.region).to.be.a('string'); - - return { - masterKey: { - key: dataKeyOptions.masterKey.key, - region: dataKeyOptions.masterKey.region - }, - keyAltNames - }; - } - - describe('errors', function () { - [42, 'hello', { keyAltNames: 'foobar' }, /foobar/].forEach(val => { - it(`should fail if typeof keyAltNames = ${typeof val}`, function () { - const options = makeOptions(val); - expect(() => this.encryption.createDataKey('aws', options, () => undefined)).to.throw( - TypeError - ); - }); - }); - - [undefined, null, 42, { keyAltNames: 'foobar' }, ['foobar'], /foobar/].forEach(val => { - it(`should fail if typeof keyAltNames[x] = ${typeof val}`, function () { - const options = makeOptions([val]); - expect(() => this.encryption.createDataKey('aws', options, () => undefined)).to.throw( - TypeError - ); - }); - }); - }); - - it('should create a key with keyAltNames', function () { - let dataKey; - const options = makeOptions(['foobar']); - return this.encryption - .createDataKey('aws', options) - .then(_dataKey => (dataKey = _dataKey)) - .then(() => this.collection.findOne({ keyAltNames: 'foobar' })) - .then(document => { - expect(document).to.be.an('object'); - expect(document).to.have.property('keyAltNames').that.includes.members(['foobar']); - expect(document).to.have.property('_id').that.deep.equals(dataKey); - }); - }); - - it('should create a key with multiple keyAltNames', function () { - let dataKey; - return this.encryption - .createDataKey('aws', makeOptions(['foobar', 'fizzbuzz'])) - .then(_dataKey => (dataKey = _dataKey)) - .then(() => - Promise.all([ - this.collection.findOne({ keyAltNames: 'foobar' }), - this.collection.findOne({ keyAltNames: 'fizzbuzz' }) - ]) - ) - .then(docs => { - expect(docs).to.have.lengthOf(2); - const doc1 = docs[0]; - const doc2 = docs[1]; - expect(doc1).to.be.an('object'); - expect(doc2).to.be.an('object'); - expect(doc1) - .to.have.property('keyAltNames') - .that.includes.members(['foobar', 'fizzbuzz']); - expect(doc1).to.have.property('_id').that.deep.equals(dataKey); - expect(doc2) - .to.have.property('keyAltNames') - .that.includes.members(['foobar', 'fizzbuzz']); - expect(doc2).to.have.property('_id').that.deep.equals(dataKey); - }); - }); - - it('should be able to reference a key with `keyAltName` during encryption', function () { - let keyId; - const keyAltName = 'mySpecialKey'; - const algorithm = 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'; - - const valueToEncrypt = 'foobar'; - - return this.encryption - .createDataKey('aws', makeOptions([keyAltName])) - .then(_dataKey => (keyId = _dataKey)) - .then(() => this.encryption.encrypt(valueToEncrypt, { keyId, algorithm })) - .then(encryptedValue => { - return this.encryption - .encrypt(valueToEncrypt, { keyAltName, algorithm }) - .then(encryptedValue2 => { - expect(encryptedValue).to.deep.equal(encryptedValue2); - }); - }); - }); - }); - - context('with stubbed key material and fixed random source', function () { - let sandbox = sinon.createSandbox(); - - afterEach(() => { - sandbox.restore(); - }); - beforeEach(() => { - const rndData = Buffer.from( - '\x4d\x06\x95\x64\xf5\xa0\x5e\x9e\x35\x23\xb9\x8f\x57\x5a\xcb\x15', - 'latin1' - ); - let rndPos = 0; - sandbox.stub(cryptoCallbacks, 'randomHook').callsFake((buffer, count) => { - if (rndPos + count > rndData) { - return new Error('Out of fake random data'); - } - buffer.set(rndData.subarray(rndPos, rndPos + count)); - rndPos += count; - return count; - }); - - // stubbed out for AWS unit testing below - sandbox.stub(StateMachine.prototype, 'fetchKeys').callsFake((client, ns, filter, cb) => { - filter = deserialize(filter); - const keyIds = filter.$or[0]._id.$in.map(key => key.toString('hex')); - const fileNames = keyIds.map( - keyId => `${__dirname}/../../../test/data/keys/${keyId.toUpperCase()}-local-document.json` - ); - const contents = fileNames.map(filename => EJSON.parse(fs.readFileSync(filename))); - cb(null, contents); - }); - }); - - // This exactly matches _test_encrypt_fle2_explicit from the C tests - it('should explicitly encrypt and decrypt with the "local" KMS provider (FLE2, exact result)', function () { - const encryption = new ClientEncryption(new MockClient(), { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - const encryptOptions = { - keyId: new Binary(Buffer.from('ABCDEFAB123498761234123456789012', 'hex'), 4), - algorithm: 'Unindexed' - }; - - return encryption - .encrypt('value123', encryptOptions) - .then(encrypted => { - expect(encrypted._bsontype).to.equal('Binary'); - expect(encrypted.sub_type).to.equal(6); - return encryption.decrypt(encrypted); - }) - .then(decrypted => { - expect(decrypted).to.equal('value123'); - }); - }); - }); - - describe('encrypt()', function () { - let clientEncryption; - let completeOptions; - let dataKey; - - beforeEach(async function () { - if (requirements.SKIP_LIVE_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_LIVE_TESTS=${requirements.SKIP_LIVE_TESTS}`; - this.test.skip(); - return; - } - - await setup(); - clientEncryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - dataKey = await clientEncryption.createDataKey('local', { - name: 'local', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - completeOptions = { - algorithm: 'RangePreview', - contentionFactor: 0, - rangeOptions: { - min: new BSON.Long(0), - max: new BSON.Long(10), - sparsity: new BSON.Long(1) - }, - keyId: dataKey - }; - }); - - afterEach(() => teardown()); - - context('when expressionMode is incorrectly provided as an argument', function () { - it('overrides the provided option with the correct value for expression mode', async function () { - const optionsWithExpressionMode = { ...completeOptions, expressionMode: true }; - const result = await clientEncryption.encrypt( - new mongodb.Long(0), - optionsWithExpressionMode - ); - - expect(result).to.be.instanceof(Binary); - }); - }); - }); - - describe('encryptExpression()', function () { - let clientEncryption; - let completeOptions; - let dataKey; - const expression = { - $and: [{ someField: { $gt: 1 } }] - }; - - beforeEach(async function () { - if (requirements.SKIP_LIVE_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_LIVE_TESTS=${requirements.SKIP_LIVE_TESTS}`; - this.test.skip(); - return; - } - - await setup(); - clientEncryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - dataKey = await clientEncryption.createDataKey('local', { - name: 'local', - kmsProviders: { local: { key: Buffer.alloc(96) } } - }); - - completeOptions = { - algorithm: 'RangePreview', - queryType: 'rangePreview', - contentionFactor: 0, - rangeOptions: { - min: new BSON.Int32(0), - max: new BSON.Int32(10), - sparsity: new BSON.Long(1) - }, - keyId: dataKey - }; - }); - - afterEach(() => teardown()); - - it('throws if rangeOptions is not provided', async function () { - expect(delete completeOptions.rangeOptions).to.be.true; - const errorOrResult = await clientEncryption - .encryptExpression(expression, completeOptions) - .catch(e => e); - - expect(errorOrResult).to.be.instanceof(TypeError); - }); - - it('throws if algorithm is not provided', async function () { - expect(delete completeOptions.algorithm).to.be.true; - const errorOrResult = await clientEncryption - .encryptExpression(expression, completeOptions) - .catch(e => e); - - expect(errorOrResult).to.be.instanceof(TypeError); - }); - - it(`throws if algorithm does not equal 'rangePreview'`, async function () { - completeOptions['algorithm'] = 'equality'; - const errorOrResult = await clientEncryption - .encryptExpression(expression, completeOptions) - .catch(e => e); - - expect(errorOrResult).to.be.instanceof(TypeError); - }); - - it(`does not throw if algorithm has different casing than 'rangePreview'`, async function () { - completeOptions['algorithm'] = 'rAnGePrEvIeW'; - const errorOrResult = await clientEncryption - .encryptExpression(expression, completeOptions) - .catch(e => e); - - expect(errorOrResult).not.to.be.instanceof(Error); - }); - - context('when expressionMode is incorrectly provided as an argument', function () { - it('overrides the provided option with the correct value for expression mode', async function () { - const optionsWithExpressionMode = { ...completeOptions, expressionMode: false }; - const result = await clientEncryption.encryptExpression( - expression, - optionsWithExpressionMode - ); - - expect(result).not.to.be.instanceof(Binary); - }); - }); - }); - - it('should provide the libmongocrypt version', function () { - expect(ClientEncryption.libmongocryptVersion).to.be.a('string'); - }); - - describe('createEncryptedCollection()', () => { - /** @type {InstanceType} */ - let clientEncryption; - const client = new MockClient(); - let db; - const collectionName = 'secure'; - - beforeEach(async function () { - clientEncryption = new ClientEncryption(client, { - keyVaultNamespace: 'client.encryption', - kmsProviders: { local: { key: Buffer.alloc(96, 0) } } - }); - - db = client.db('createEncryptedCollectionDb'); - }); - - afterEach(async () => { - sinon.restore(); - }); - - context('validates input', () => { - it('throws TypeError if options are omitted', async () => { - const error = await clientEncryption - .createEncryptedCollection(db, collectionName) - .catch(error => error); - expect(error).to.be.instanceOf(TypeError, /provider/); - }); - - it('throws TypeError if options.createCollectionOptions are omitted', async () => { - const error = await clientEncryption - .createEncryptedCollection(db, collectionName, {}) - .catch(error => error); - expect(error).to.be.instanceOf(TypeError, /encryptedFields/); - }); - - it('throws TypeError if options.createCollectionOptions.encryptedFields are omitted', async () => { - const error = await clientEncryption - .createEncryptedCollection(db, collectionName, { createCollectionOptions: {} }) - .catch(error => error); - expect(error).to.be.instanceOf(TypeError, /Cannot read properties/); - }); - }); - - context('when options.encryptedFields.fields is not an array', () => { - it('does not generate any encryption keys', async () => { - const createCollectionSpy = sinon.spy(db, 'createCollection'); - const createDataKeySpy = sinon.spy(clientEncryption, 'createDataKey'); - await clientEncryption.createEncryptedCollection(db, collectionName, { - createCollectionOptions: { encryptedFields: { fields: 'not an array' } } - }); - - expect(createDataKeySpy.callCount).to.equal(0); - const options = createCollectionSpy.getCall(0).args[1]; - expect(options).to.deep.equal({ encryptedFields: { fields: 'not an array' } }); - }); - }); - - context('when options.encryptedFields.fields elements are not objects', () => { - it('they are passed along to createCollection', async () => { - const createCollectionSpy = sinon.spy(db, 'createCollection'); - const keyId = new Binary(Buffer.alloc(16, 0)); - const createDataKeyStub = sinon.stub(clientEncryption, 'createDataKey').resolves(keyId); - await clientEncryption.createEncryptedCollection(db, collectionName, { - createCollectionOptions: { - encryptedFields: { fields: ['not an array', { keyId: null }, { keyId: {} }] } - } - }); - - expect(createDataKeyStub.callCount).to.equal(1); - const options = createCollectionSpy.getCall(0).args[1]; - expect(options).to.deep.equal({ - encryptedFields: { fields: ['not an array', { keyId: keyId }, { keyId: {} }] } - }); - }); - }); - - it('only passes options.masterKey to createDataKey', async () => { - const masterKey = Symbol('key'); - const createDataKey = sinon - .stub(clientEncryption, 'createDataKey') - .resolves(new Binary(Buffer.alloc(16, 0))); - const result = await clientEncryption.createEncryptedCollection(db, collectionName, { - provider: 'aws', - createCollectionOptions: { encryptedFields: { fields: [{}] } }, - masterKey - }); - expect(result).to.have.property('collection'); - expect(createDataKey).to.have.been.calledOnceWithExactly('aws', { masterKey }); - }); - - context('when createDataKey rejects', () => { - const customErrorEvil = new Error('evil!'); - const customErrorGood = new Error('good!'); - const keyId = new Binary(Buffer.alloc(16, 0), 4); - const createCollectionOptions = { - encryptedFields: { fields: [{}, {}, { keyId: 'cool id!' }, {}] } - }; - const createDataKeyRejection = async () => { - const stub = sinon.stub(clientEncryption, 'createDataKey'); - stub.onCall(0).resolves(keyId); - stub.onCall(1).rejects(customErrorEvil); - stub.onCall(2).rejects(customErrorGood); - stub.onCall(4).resolves(keyId); - - const error = await clientEncryption - .createEncryptedCollection(db, collectionName, { - provider: 'local', - createCollectionOptions - }) - .catch(error => error); - - // At least make sure the function did not succeed - expect(error).to.be.instanceOf(Error); - - return error; - }; - - it('throws MongoCryptCreateDataKeyError', async () => { - const error = await createDataKeyRejection(); - expect(error).to.be.instanceOf(MongoCryptCreateDataKeyError); - }); - - it('thrown error has a cause set to the first error that was thrown from createDataKey', async () => { - const error = await createDataKeyRejection(); - expect(error.cause).to.equal(customErrorEvil); - expect(error.message).to.include(customErrorEvil.message); - }); - - it('thrown error contains partially filled encryptedFields.fields', async () => { - const error = await createDataKeyRejection(); - expect(error.encryptedFields).property('fields').that.is.an('array'); - expect(error.encryptedFields.fields).to.have.lengthOf( - createCollectionOptions.encryptedFields.fields.length - ); - expect(error.encryptedFields.fields).to.have.nested.property('[0].keyId', keyId); - expect(error.encryptedFields.fields).to.not.have.nested.property('[1].keyId'); - expect(error.encryptedFields.fields).to.have.nested.property('[2].keyId', 'cool id!'); - }); - }); - - context('when createCollection rejects', () => { - const customError = new Error('evil!'); - const keyId = new Binary(Buffer.alloc(16, 0), 4); - const createCollectionRejection = async () => { - const stubCreateDataKey = sinon.stub(clientEncryption, 'createDataKey'); - stubCreateDataKey.onCall(0).resolves(keyId); - stubCreateDataKey.onCall(1).resolves(keyId); - stubCreateDataKey.onCall(2).resolves(keyId); - - sinon.stub(db, 'createCollection').rejects(customError); - - const createCollectionOptions = { - encryptedFields: { fields: [{}, {}, { keyId: 'cool id!' }] } - }; - const error = await clientEncryption - .createEncryptedCollection(db, collectionName, { - provider: 'local', - createCollectionOptions - }) - .catch(error => error); - - // At least make sure the function did not succeed - expect(error).to.be.instanceOf(Error); - - return error; - }; - - it('throws MongoCryptCreateEncryptedCollectionError', async () => { - const error = await createCollectionRejection(); - expect(error).to.be.instanceOf(MongoCryptCreateEncryptedCollectionError); - }); - - it('thrown error has a cause set to the error that was thrown from createCollection', async () => { - const error = await createCollectionRejection(); - expect(error.cause).to.equal(customError); - expect(error.message).to.include(customError.message); - }); - - it('thrown error contains filled encryptedFields.fields', async () => { - const error = await createCollectionRejection(); - expect(error.encryptedFields).property('fields').that.is.an('array'); - expect(error.encryptedFields.fields).to.have.nested.property('[0].keyId', keyId); - expect(error.encryptedFields.fields).to.have.nested.property('[1].keyId', keyId); - expect(error.encryptedFields.fields).to.have.nested.property('[2].keyId', 'cool id!'); - }); - }); - - context('when there are nullish keyIds in the encryptedFields.fields array', function () { - it('does not mutate the input fields array when generating data keys', async () => { - const encryptedFields = Object.freeze({ - escCollection: 'esc', - eccCollection: 'ecc', - ecocCollection: 'ecoc', - fields: Object.freeze([ - Object.freeze({ keyId: false }), - Object.freeze({ - keyId: null, - path: 'name', - bsonType: 'int', - queries: Object.freeze({ contentionFactor: 0 }) - }), - null - ]) - }); - - const keyId = new Binary(Buffer.alloc(16, 0), 4); - sinon.stub(clientEncryption, 'createDataKey').resolves(keyId); - - const { collection, encryptedFields: resultEncryptedFields } = - await clientEncryption.createEncryptedCollection(db, collectionName, { - provider: 'local', - createCollectionOptions: { - encryptedFields - } - }); - - expect(collection).to.have.property('namespace', 'createEncryptedCollectionDb.secure'); - expect(encryptedFields, 'original encryptedFields should be unmodified').nested.property( - 'fields[0].keyId', - false - ); - expect( - resultEncryptedFields, - 'encryptedFields created by helper should have replaced nullish keyId' - ).nested.property('fields[1].keyId', keyId); - expect(encryptedFields, 'original encryptedFields should be unmodified').nested.property( - 'fields[2]', - null - ); - }); - - it('generates dataKeys for all null keyIds in the fields array', async () => { - const encryptedFields = Object.freeze({ - escCollection: 'esc', - eccCollection: 'ecc', - ecocCollection: 'ecoc', - fields: Object.freeze([ - Object.freeze({ keyId: null }), - Object.freeze({ keyId: null }), - Object.freeze({ keyId: null }) - ]) - }); - - const keyId = new Binary(Buffer.alloc(16, 0), 4); - sinon.stub(clientEncryption, 'createDataKey').resolves(keyId); - - const { collection, encryptedFields: resultEncryptedFields } = - await clientEncryption.createEncryptedCollection(db, collectionName, { - provider: 'local', - createCollectionOptions: { - encryptedFields - } - }); - - expect(collection).to.have.property('namespace', 'createEncryptedCollectionDb.secure'); - expect(resultEncryptedFields.fields).to.have.lengthOf(3); - expect(resultEncryptedFields.fields.filter(({ keyId }) => keyId === null)).to.have.lengthOf( - 0 - ); - }); - }); - }); -}); diff --git a/encryption/test/data/README.md b/encryption/test/data/README.md deleted file mode 100644 index c7f47ca8b40..00000000000 --- a/encryption/test/data/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# libmongocrypt example data # - -This directory contains a simple example of mocked responses to test libmongocrypt and driver wrappers. Data for other scenarios and edge cases is in the `data` directory. - -The HTTP reply file, kms-decrypt-reply.txt, has regular newline endings \n that MUST be replaced by \r\n endings when reading the file for testing. \ No newline at end of file diff --git a/encryption/test/index.test.js b/encryption/test/index.test.js deleted file mode 100644 index 188a236e3e0..00000000000 --- a/encryption/test/index.test.js +++ /dev/null @@ -1,45 +0,0 @@ -'use strict'; - -const { expect } = require('chai'); -const mongodbClientEncryption = require('../lib/index'); -const { fetchAzureKMSToken } = require('../lib/providers'); - -// Update this as you add exports, helps double check we don't accidentally remove something -// since not all tests import from the root public export -const EXPECTED_EXPORTS = [ - 'extension', - 'MongoCryptError', - 'MongoCryptCreateEncryptedCollectionError', - 'MongoCryptCreateDataKeyError', - 'MongoCryptAzureKMSRequestError', - 'MongoCryptKMSRequestNetworkTimeoutError', - 'AutoEncrypter', - 'ClientEncryption' -]; - -describe('mongodb-client-encryption entrypoint', () => { - it('should export all and only the expected keys in expected_exports', () => { - expect(mongodbClientEncryption).to.have.all.keys(EXPECTED_EXPORTS); - }); - - it('extension returns an object equal in shape to the default except for extension', () => { - const extensionResult = mongodbClientEncryption.extension(require('mongodb')); - const expectedExports = EXPECTED_EXPORTS.filter(exp => exp !== 'extension'); - const exportsDefault = Object.keys(mongodbClientEncryption).filter(exp => exp !== 'extension'); - expect(extensionResult).to.have.all.keys(expectedExports); - expect(extensionResult).to.have.all.keys(exportsDefault); - }); - - context('exports for driver testing', () => { - it('exports `fetchAzureKMSToken` in a symbol property', () => { - expect(mongodbClientEncryption).to.have.property( - '___azureKMSProseTestExports', - fetchAzureKMSToken - ); - }); - it('extension exports `fetchAzureKMSToken` in a symbol property', () => { - const extensionResult = mongodbClientEncryption.extension(require('mongodb')); - expect(extensionResult).to.have.property('___azureKMSProseTestExports', fetchAzureKMSToken); - }); - }); -}); diff --git a/encryption/test/release.test.js b/encryption/test/release.test.js deleted file mode 100644 index caaecf544cb..00000000000 --- a/encryption/test/release.test.js +++ /dev/null @@ -1,66 +0,0 @@ -'use strict'; -const { expect } = require('chai'); -const tar = require('tar'); -const cp = require('child_process'); -const fs = require('fs'); -const pkg = require('../package.json'); - -const packFile = `mongodb-client-encryption-${pkg.version}.tgz`; - -const REQUIRED_FILES = [ - 'package/binding.gyp', - 'package/CHANGELOG.md', - 'package/index.d.ts', - 'package/lib/index.js', - 'package/lib/autoEncrypter.js', - 'package/lib/buffer_pool.js', - 'package/lib/clientEncryption.js', - 'package/lib/common.js', - 'package/lib/providers/index.js', - 'package/lib/providers/gcp.js', - 'package/lib/providers/aws.js', - 'package/lib/providers/azure.js', - 'package/lib/providers/utils.js', - 'package/lib/cryptoCallbacks.js', - 'package/lib/errors.js', - 'package/lib/mongocryptdManager.js', - 'package/lib/stateMachine.js', - 'package/LICENSE', - 'package/package.json', - 'package/README.md', - 'package/src/mongocrypt.cc', - 'package/src/mongocrypt.h' -]; - -describe(`Release ${packFile}`, function () { - this.timeout(5000); - - let tarFileList; - before(() => { - expect(fs.existsSync(packFile)).to.equal(false); - cp.execSync('npm pack', { stdio: 'ignore' }); - tarFileList = []; - tar.list({ - file: packFile, - sync: true, - onentry(entry) { - tarFileList.push(entry.path); - } - }); - }); - - after(() => { - fs.unlinkSync(packFile); - }); - - for (const requiredFile of REQUIRED_FILES) { - it(`should contain ${requiredFile}`, () => { - expect(tarFileList).to.includes(requiredFile); - }); - } - - it('should not have extraneous files', () => { - const unexpectedFileList = tarFileList.filter(f => !REQUIRED_FILES.some(r => r === f)); - expect(unexpectedFileList).to.have.lengthOf(0, `Extra files: ${unexpectedFileList.join(', ')}`); - }); -}); diff --git a/encryption/test/tools/chai-addons.js b/encryption/test/tools/chai-addons.js deleted file mode 100644 index 68dd475d0ad..00000000000 --- a/encryption/test/tools/chai-addons.js +++ /dev/null @@ -1,8 +0,0 @@ -'use strict'; - -// configure chai -const chai = require('chai'); -chai.use(require('sinon-chai')); -chai.use(require('chai-subset')); - -chai.config.truncateThreshold = 0; diff --git a/encryption/test/tools/mongodb_reporter.js b/encryption/test/tools/mongodb_reporter.js deleted file mode 100644 index 9e8461c1f15..00000000000 --- a/encryption/test/tools/mongodb_reporter.js +++ /dev/null @@ -1,325 +0,0 @@ -//@ts-check -'use strict'; -const mocha = require('mocha'); -const chalk = require('chalk'); - -chalk.level = 3; - -const { - EVENT_RUN_BEGIN, - EVENT_RUN_END, - EVENT_TEST_FAIL, - EVENT_TEST_PASS, - EVENT_SUITE_BEGIN, - EVENT_SUITE_END, - EVENT_TEST_PENDING, - EVENT_TEST_BEGIN, - EVENT_TEST_END -} = mocha.Runner.constants; - -const fs = require('fs'); -const os = require('os'); - -/** - * @typedef {object} MongoMochaSuiteExtension - * @property {Date} timestamp - suite start date - * @property {string} stdout - capture of stdout - * @property {string} stderr - capture of stderr - * @property {MongoMochaTest} test - capture of stderr - * @typedef {object} MongoMochaTestExtension - * @property {Date} startTime - test start date - * @property {Date} endTime - test end date - * @property {number} elapsedTime - difference between end and start - * @property {Error} [error] - The possible error from a test - * @property {true} [skipped] - Set if test was skipped - * @typedef {MongoMochaSuiteExtension & Mocha.Suite} MongoMochaSuite - * @typedef {MongoMochaTestExtension & Mocha.Test} MongoMochaTest - */ - -// Turn this on if you have to debug this custom reporter! -let REPORT_TO_STDIO = false; - -function captureStream(stream) { - var oldWrite = stream.write; - var buf = ''; - stream.write = function (chunk) { - buf += chunk.toString(); // chunk is a String or Buffer - oldWrite.apply(stream, arguments); - }; - - return { - unhook: function unhook() { - stream.write = oldWrite; - return buf; - }, - captured: function () { - return buf; - } - }; -} - -/** - * @param {Mocha.Runner} runner - * @this {any} - */ -class MongoDBMochaReporter extends mocha.reporters.Spec { - constructor(runner) { - super(runner); - /** @type {Map} */ - this.suites = new Map(); - this.xunitWritten = false; - runner.on(EVENT_RUN_BEGIN, () => this.start()); - runner.on(EVENT_RUN_END, () => this.end()); - runner.on(EVENT_SUITE_BEGIN, suite => this.onSuite(suite)); - runner.on(EVENT_TEST_BEGIN, test => this.onTest(test)); - runner.on(EVENT_TEST_PASS, test => this.pass(test)); - runner.on(EVENT_TEST_FAIL, (test, error) => this.fail(test, error)); - runner.on(EVENT_TEST_PENDING, test => this.pending(test)); - runner.on(EVENT_SUITE_END, suite => this.suiteEnd(suite)); - runner.on(EVENT_TEST_END, test => this.testEnd(test)); - - process.on('SIGINT', () => this.end(true)); - } - start() {} - - end(ctrlC) { - try { - if (ctrlC) console.log('emergency exit!'); - const output = { testSuites: [] }; - - for (const [id, [className, { suite }]] of [...this.suites.entries()].entries()) { - let totalSuiteTime = 0; - let testCases = []; - let failureCount = 0; - - const tests = /** @type {MongoMochaTest[]}*/ (suite.tests); - for (const test of tests) { - let time = test.elapsedTime / 1000; - time = Number.isNaN(time) ? 0 : time; - - totalSuiteTime += time; - failureCount += test.state === 'failed' ? 1 : 0; - - /** @type {string | Date | number} */ - let startTime = test.startTime; - startTime = startTime ? startTime.toISOString() : 0; - - /** @type {string | Date | number} */ - let endTime = test.endTime; - endTime = endTime ? endTime.toISOString() : 0; - - let error = test.error; - let failure = error - ? { - type: error.constructor.name, - message: error.message, - stack: error.stack - } - : undefined; - - let skipped = !!test.skipped; - - testCases.push({ - name: test.title, - className, - time, - startTime, - endTime, - skipped, - failure - }); - } - - /** @type {string | Date | number} */ - let timestamp = suite.timestamp; - timestamp = timestamp ? timestamp.toISOString().split('.')[0] : ''; - - output.testSuites.push({ - package: suite.file.includes('integration') ? 'Integration' : 'Unit', - id, - name: className, - timestamp, - hostname: os.hostname(), - tests: suite.tests.length, - failures: failureCount, - errors: '0', - time: totalSuiteTime, - testCases, - stdout: suite.stdout, - stderr: suite.stderr - }); - } - - if (!this.xunitWritten) { - fs.writeFileSync('xunit.xml', outputToXML(output), { encoding: 'utf8' }); - } - this.xunitWritten = true; - console.log(chalk.bold('wrote xunit.xml')); - } catch (error) { - console.error(chalk.red(`Failed to output xunit report! ${error}`)); - } finally { - if (ctrlC) process.exit(1); - } - } - - /** - * @param {MongoMochaSuite} suite - */ - onSuite(suite) { - if (suite.root) return; - if (!this.suites.has(suite.fullTitle())) { - suite.timestamp = new Date(); - this.suites.set(suite.fullTitle(), { - suite, - stdout: captureStream(process.stdout), - stderr: captureStream(process.stderr) - }); - } else { - console.warn(`${chalk.yellow('WARNING:')} ${suite.fullTitle()} started twice`); - } - } - - /** - * @param {MongoMochaSuite} suite - */ - suiteEnd(suite) { - if (suite.root) return; - const currentSuite = this.suites.get(suite.fullTitle()); - if (!currentSuite) { - console.error('Suite never started >:('); - process.exit(1); - } - if (currentSuite.stdout || currentSuite.stderr) { - suite.stdout = currentSuite.stdout.unhook(); - suite.stderr = currentSuite.stderr.unhook(); - delete currentSuite.stdout; - delete currentSuite.stderr; - } - } - - /** - * @param {MongoMochaTest} test - */ - onTest(test) { - test.startTime = new Date(); - } - - /** - * @param {MongoMochaTest} test - */ - testEnd(test) { - test.endTime = new Date(); - test.elapsedTime = Number(test.endTime) - Number(test.startTime); - } - - /** - * @param {MongoMochaTest} test - */ - pass(test) { - if (REPORT_TO_STDIO) console.log(chalk.green(`✔ ${test.fullTitle()}`)); - } - - /** - * @param {MongoMochaTest} test - * @param {Error} error - */ - fail(test, error) { - if (REPORT_TO_STDIO) console.log(chalk.red(`⨯ ${test.fullTitle()} -- ${error.message}`)); - test.error = error; - } - - /** - * @param {MongoMochaTest & {skipReason?: string}} test - */ - pending(test) { - if (REPORT_TO_STDIO) console.log(chalk.cyan(`↬ ${test.fullTitle()}`)); - if (typeof test.skipReason === 'string') { - console.log(chalk.cyan(`${' '.repeat(test.titlePath().length + 1)}↬ ${test.skipReason}`)); - } - test.skipped = true; - } -} - -module.exports = MongoDBMochaReporter; - -function replaceIllegalXMLCharacters(string) { - // prettier-ignore - return String(string) - .split('"').join('"') - .split('<').join('﹤') - .split('>').join('﹥') - .split('&').join('﹠'); -} - -const ANSI_ESCAPE_REGEX = - // eslint-disable-next-line no-control-regex - /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; -function outputToXML(output) { - function cdata(str) { - return `') - .join('\\]\\]\\>')}]]>`; - } - - function makeTag(name, attributes, selfClose, content) { - const attributesString = Object.entries(attributes || {}) - .map(([k, v]) => `${k}="${replaceIllegalXMLCharacters(v)}"`) - .join(' '); - let tag = `<${name}${attributesString ? ' ' + attributesString : ''}`; - if (selfClose) return tag + '/>\n'; - else tag += '>'; - if (content) return tag + content + ``; - return tag; - } - - let s = - '\n\n\n'; - - for (const suite of output.testSuites) { - s += makeTag('testsuite', { - package: suite.package, - id: suite.id, - name: suite.name, - timestamp: suite.timestamp, - hostname: suite.hostname, - tests: suite.tests, - failures: suite.failures, - errors: suite.errors, - time: suite.time - }); - s += '\n\t' + makeTag('properties') + '\n'; // can put metadata here? - for (const test of suite.testCases) { - s += - '\t' + - makeTag( - 'testcase', - { - name: test.name, - classname: test.className, - time: test.time, - start: test.startTime, - end: test.endTime - }, - !test.failure && !test.skipped - ); - if (test.failure) { - s += - '\n\t\t' + - makeTag('failure', { type: test.failure.type }, false, cdata(test.failure.stack)) + - '\n'; - s += `\t\n`; - } - if (test.skipped) { - s += makeTag('skipped', {}, true); - s += `\t\n`; - } - } - s += '\t' + makeTag('system-out', {}, false, cdata(suite.stdout)) + '\n'; - s += '\t' + makeTag('system-err', {}, false, cdata(suite.stderr)) + '\n'; - s += `\n`; - } - - return s + '\n'; -} diff --git a/encryption/test/types/index.test-d.ts b/encryption/test/types/index.test-d.ts deleted file mode 100644 index ae0b6b92a8e..00000000000 --- a/encryption/test/types/index.test-d.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { expectAssignable, expectError, expectType, expectNotType, expectNotAssignable } from 'tsd'; -import { RangeOptions, AWSEncryptionKeyOptions, AzureEncryptionKeyOptions, ClientEncryption, GCPEncryptionKeyOptions, ClientEncryptionEncryptOptions, KMSProviders } from '../..'; - -type RequiredCreateEncryptedCollectionSettings = Parameters< - ClientEncryption['createEncryptedCollection'] ->[2]; - -expectError({}); -expectError({ - provider: 'blah!', - createCollectionOptions: { encryptedFields: {} } -}); -expectError({ - provider: 'aws', - createCollectionOptions: {} -}); -expectError({ - provider: 'aws', - createCollectionOptions: { encryptedFields: null } -}); - -expectAssignable({ - provider: 'aws', - createCollectionOptions: { encryptedFields: {} } -}); -expectAssignable({ - provider: 'aws', - createCollectionOptions: { encryptedFields: {} }, - masterKey: { } as AWSEncryptionKeyOptions | AzureEncryptionKeyOptions | GCPEncryptionKeyOptions -}); - -{ - // NODE-5041 - incorrect spelling of rangeOpts in typescript definitions - const options = {} as ClientEncryptionEncryptOptions; - expectType(options.rangeOptions) -} - -{ - // KMSProviders - // aws - expectAssignable({ accessKeyId: '', secretAccessKey: '' }); - expectAssignable({ accessKeyId: '', secretAccessKey: '', sessionToken: undefined }); - expectAssignable({ accessKeyId: '', secretAccessKey: '', sessionToken: '' }); - // automatic - expectAssignable({}); - - // azure - expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a' }); - expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a' }); - expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a', identityPlatformEndpoint: undefined }); - expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a', identityPlatformEndpoint: '' }); - expectAssignable({ accessToken: 'a' }); - expectAssignable({}); - - // gcp - expectAssignable({ email: 'a', privateKey: 'a' }); - expectAssignable({ email: 'a', privateKey: 'a', endpoint: undefined }); - expectAssignable({ email: 'a', privateKey: 'a', endpoint: 'a' }); - expectAssignable({ accessToken: 'a' }); - // automatic - expectAssignable({}); - -} diff --git a/global.d.ts b/global.d.ts index 7c8dc818ccd..b293bece26b 100644 --- a/global.d.ts +++ b/global.d.ts @@ -1,4 +1,4 @@ -import { OneOrMore } from './src/mongo_types'; +import { type OneOrMore } from './src/mongo_types'; import type { TestConfiguration } from './test/tools/runner/config'; declare global { @@ -13,6 +13,7 @@ declare global { auth?: 'enabled' | 'disabled'; idmsMockServer?: true; nodejs?: string; + predicate?: (test?: Mocha.Test) => true | string; }; sessions?: { diff --git a/package-lock.json b/package-lock.json index 05084a792b8..fd9f2123793 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46,6 +46,7 @@ "js-yaml": "^4.1.0", "mocha": "^10.2.0", "mocha-sinon": "^2.1.2", + "mongodb-client-encryption": "^2.8.0", "mongodb-legacy": "^5.0.0", "nyc": "^15.1.0", "prettier": "^2.8.8", @@ -62,7 +63,7 @@ "yargs": "^17.7.2" }, "engines": { - "node": ">=14.20.1" + "node": ">=16.20.1" }, "optionalDependencies": { "saslprep": "^1.0.3" @@ -70,6 +71,7 @@ "peerDependencies": { "@aws-sdk/credential-providers": "^3.201.0", "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=2.3.0 <3", "snappy": "^7.2.2" @@ -81,6 +83,9 @@ "@mongodb-js/zstd": { "optional": true }, + "gcp-metadata": { + "optional": true + }, "kerberos": { "optional": true }, @@ -3360,6 +3365,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/before-after-hook": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-2.2.3.tgz", @@ -3375,6 +3400,26 @@ "node": ">=8" } }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", @@ -3485,6 +3530,30 @@ "node": ">=14.20.1" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -3674,6 +3743,12 @@ "node": ">= 6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -3859,6 +3934,21 @@ "node": ">=0.10.0" } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/deep-eql": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", @@ -3871,6 +3961,15 @@ "node": ">=6" } }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3933,6 +4032,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.1.tgz", + "integrity": "sha512-463v3ZeIrcWtdgIg6vI6XUncguvr2TnGl4SzDXinkt9mSLpBJKXT3mW6xT3VQdDN11+WVs29pgvivTc4Lp8v+w==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/diff": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-5.0.0.tgz", @@ -4002,6 +4110,15 @@ "node": ">= 0.8" } }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dev": true, + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -4523,6 +4640,15 @@ "node": ">= 0.6" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.18.2", "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", @@ -4670,6 +4796,12 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true + }, "node_modules/fill-range": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", @@ -4836,6 +4968,12 @@ } ] }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true + }, "node_modules/fs-extra": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", @@ -4969,6 +5107,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true + }, "node_modules/glob": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz", @@ -5269,6 +5413,26 @@ "node": ">=0.10.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", @@ -5337,6 +5501,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true + }, "node_modules/internal-slot": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz", @@ -6158,6 +6328,18 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -6202,6 +6384,12 @@ "node": ">= 6" } }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true + }, "node_modules/mocha": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/mocha/-/mocha-10.2.0.tgz", @@ -6359,6 +6547,35 @@ } } }, + "node_modules/mongodb-client-encryption": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/mongodb-client-encryption/-/mongodb-client-encryption-2.8.0.tgz", + "integrity": "sha512-wIcaETX0Acis9hJkUf2SvtPMq/F1G2gxZXgp8QAe2yJzL+cIUpii8Yv4i3LIeZVwYuYSue8F6/e4pHaE21On7A==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "bindings": "^1.5.0", + "node-addon-api": "^4.3.0", + "prebuild-install": "^7.1.1", + "socks": "^2.7.1" + }, + "engines": { + "node": ">=12.9.0" + }, + "peerDependencies": { + "@aws-sdk/credential-providers": "^3.186.0", + "gcp-metadata": "^5.2.0", + "mongodb": ">=3.4.0" + }, + "peerDependenciesMeta": { + "@aws-sdk/credential-providers": { + "optional": true + }, + "gcp-metadata": { + "optional": true + } + } + }, "node_modules/mongodb-connection-string-url": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-2.6.0.tgz", @@ -6407,6 +6624,12 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-1.0.2.tgz", + "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==", + "dev": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -6465,6 +6688,24 @@ "isarray": "0.0.1" } }, + "node_modules/node-abi": { + "version": "3.45.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.45.0.tgz", + "integrity": "sha512-iwXuFrMAcFVi/ZoZiqq8BzAdsLw9kxDfTC0HMyjXfSL/6CSDAGD5UmR7azrAgWV1zKYq7dUUMj4owusBWKLsiQ==", + "dev": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-addon-api": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-4.3.0.tgz", + "integrity": "sha512-73sE9+3UaLYYFmDsFZnqCInzPyh3MqIwZO9cw58yIqAZhONrrabrYyYe3TuIqtIiOuTXVhsGau8hcrhhwSsDIQ==", + "dev": true + }, "node_modules/node-fetch": { "version": "2.6.11", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.11.tgz", @@ -7058,6 +7299,32 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/prebuild-install": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", + "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "dev": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^1.0.1", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -7145,6 +7412,16 @@ "node": ">= 0.10" } }, + "node_modules/pump": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", + "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "dev": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", @@ -7230,12 +7507,50 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", "dev": true }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -7586,6 +7901,51 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sinon": { "version": "15.0.4", "resolved": "https://registry.npmjs.org/sinon/-/sinon-15.0.4.tgz", @@ -7774,6 +8134,15 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -7931,6 +8300,34 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/tar-fs": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", + "integrity": "sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==", + "dev": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/test-exclude": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", @@ -8320,6 +8717,18 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "dev": true }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -8520,6 +8929,12 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index 5d4f13320c8..1328c34f913 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "peerDependencies": { "@aws-sdk/credential-providers": "^3.201.0", "@mongodb-js/zstd": "^1.1.0", + "gcp-metadata": "^5.2.0", "kerberos": "^2.0.1", "mongodb-client-encryption": ">=2.3.0 <3", "snappy": "^7.2.2" @@ -54,6 +55,9 @@ }, "mongodb-client-encryption": { "optional": true + }, + "gcp-metadata": { + "optional": true } }, "devDependencies": { @@ -89,6 +93,7 @@ "js-yaml": "^4.1.0", "mocha": "^10.2.0", "mocha-sinon": "^2.1.2", + "mongodb-client-encryption": "^2.8.0", "mongodb-legacy": "^5.0.0", "nyc": "^15.1.0", "prettier": "^2.8.8", diff --git a/src/client-side-encryption/autoEncrypter.js b/src/client-side-encryption/autoEncrypter.js new file mode 100644 index 00000000000..d6fafe0e5d3 --- /dev/null +++ b/src/client-side-encryption/autoEncrypter.js @@ -0,0 +1,428 @@ +import { databaseNamespace } from './common'; +import { StateMachine } from './stateMachine'; +import { MongocryptdManager } from './mongocryptdManager'; +import {MongoClient} from '../mongo_client'; +import {MongoError} from '../error'; +import { loadCredentials } from './providers'; +import * as cryptoCallbacks from './cryptoCallbacks'; +import { serialize, deserialize } from '../bson'; +import { getMongoDBClientEncryption } from '../deps'; + +/** + * Configuration options for a automatic client encryption. + * + * @typedef {Object} AutoEncrypter~AutoEncryptionOptions + * @property {MongoClient} [keyVaultClient] A `MongoClient` used to fetch keys from a key vault + * @property {string} [keyVaultNamespace] The namespace where keys are stored in the key vault + * @property {KMSProviders} [kmsProviders] Configuration options that are used by specific KMS providers during key generation, encryption, and decryption. + * @property {object} [schemaMap] A map of namespaces to a local JSON schema for encryption + * @property {boolean} [bypassAutoEncryption] Allows the user to bypass auto encryption, maintaining implicit decryption + * @property {AutoEncrypter~logger} [options.logger] An optional hook to catch logging messages from the underlying encryption engine + * @property {AutoEncrypter~AutoEncryptionExtraOptions} [extraOptions] Extra options related to the mongocryptd process + */ + +/** + * Extra options related to the mongocryptd process + * \* _Available in MongoDB 6.0 or higher._ + * @typedef {object} AutoEncrypter~AutoEncryptionExtraOptions + * @property {string} [mongocryptdURI] A local process the driver communicates with to determine how to encrypt values in a command. Defaults to "mongodb://%2Fvar%2Fmongocryptd.sock" if domain sockets are available or "mongodb://localhost:27020" otherwise + * @property {boolean} [mongocryptdBypassSpawn=false] If true, autoEncryption will not attempt to spawn a mongocryptd before connecting + * @property {string} [mongocryptdSpawnPath] The path to the mongocryptd executable on the system + * @property {string[]} [mongocryptdSpawnArgs] Command line arguments to use when auto-spawning a mongocryptd + * @property {string} [cryptSharedLibPath] Full path to a MongoDB Crypt shared library on the system. If specified, autoEncryption will not attempt to spawn a mongocryptd, but makes use of the shared library file specified. Note that the path must point to the shared libary file itself, not the folder which contains it \* + * @property {boolean} [cryptSharedLibRequired] If true, never use mongocryptd and fail when the MongoDB Crypt shared libary cannot be loaded. Defaults to true if [cryptSharedLibPath] is specified and false otherwise \* + */ + +/** + * @callback AutoEncrypter~logger + * @description A callback that is invoked with logging information from + * the underlying C++ Bindings. + * @param {AutoEncrypter~logLevel} level The level of logging. + * @param {string} message The message to log + */ + +/** + * @name AutoEncrypter~logLevel + * @enum {number} + * @description + * The level of severity of the log message + * + * | Value | Level | + * |-------|-------| + * | 0 | Fatal Error | + * | 1 | Error | + * | 2 | Warning | + * | 3 | Info | + * | 4 | Trace | + */ + +/** + * @internal An internal class to be used by the driver for auto encryption + * **NOTE**: Not meant to be instantiated directly, this is for internal use only. + */ +export class AutoEncrypter { + /** + * Create an AutoEncrypter + * + * **Note**: Do not instantiate this class directly. Rather, supply the relevant options to a MongoClient + * + * **Note**: Supplying `options.schemaMap` provides more security than relying on JSON Schemas obtained from the server. + * It protects against a malicious server advertising a false JSON Schema, which could trick the client into sending unencrypted data that should be encrypted. + * Schemas supplied in the schemaMap only apply to configuring automatic encryption for Client-Side Field Level Encryption. + * Other validation rules in the JSON schema will not be enforced by the driver and will result in an error. + * @param {MongoClient} client The client autoEncryption is enabled on + * @param {AutoEncrypter~AutoEncryptionOptions} [options] Optional settings + * + * @example Create an AutoEncrypter that makes use of mongocryptd + * // Enabling autoEncryption via a MongoClient using mongocryptd + * const { MongoClient } = require('mongodb'); + * const client = new MongoClient(URL, { + * autoEncryption: { + * kmsProviders: { + * aws: { + * accessKeyId: AWS_ACCESS_KEY, + * secretAccessKey: AWS_SECRET_KEY + * } + * } + * } + * }); + * + * await client.connect(); + * // From here on, the client will be encrypting / decrypting automatically + * @example Create an AutoEncrypter that makes use of libmongocrypt's CSFLE shared library + * // Enabling autoEncryption via a MongoClient using CSFLE shared library + * const { MongoClient } = require('mongodb'); + * const client = new MongoClient(URL, { + * autoEncryption: { + * kmsProviders: { + * aws: {} + * }, + * extraOptions: { + * cryptSharedLibPath: '/path/to/local/crypt/shared/lib', + * cryptSharedLibRequired: true + * } + * } + * }); + * + * await client.connect(); + * // From here on, the client will be encrypting / decrypting automatically + */ + constructor(client, options) { + this._client = client; + this._bypassEncryption = options.bypassAutoEncryption === true; + + this._keyVaultNamespace = options.keyVaultNamespace || 'admin.datakeys'; + this._keyVaultClient = options.keyVaultClient || client; + this._metaDataClient = options.metadataClient || client; + this._proxyOptions = options.proxyOptions || {}; + this._tlsOptions = options.tlsOptions || {}; + this._onKmsProviderRefresh = options.onKmsProviderRefresh; + this._kmsProviders = options.kmsProviders || {}; + + const mongoCryptOptions = {}; + if (options.schemaMap) { + mongoCryptOptions.schemaMap = Buffer.isBuffer(options.schemaMap) + ? options.schemaMap + : serialize(options.schemaMap); + } + + if (options.encryptedFieldsMap) { + mongoCryptOptions.encryptedFieldsMap = Buffer.isBuffer(options.encryptedFieldsMap) + ? options.encryptedFieldsMap + : serialize(options.encryptedFieldsMap); + } + + mongoCryptOptions.kmsProviders = !Buffer.isBuffer(this._kmsProviders) + ? serialize(this._kmsProviders) + : this._kmsProviders; + + if (options.logger) { + mongoCryptOptions.logger = options.logger; + } + + if (options.extraOptions && options.extraOptions.cryptSharedLibPath) { + mongoCryptOptions.cryptSharedLibPath = options.extraOptions.cryptSharedLibPath; + } + + if (options.bypassQueryAnalysis) { + mongoCryptOptions.bypassQueryAnalysis = options.bypassQueryAnalysis; + } + + this._bypassMongocryptdAndCryptShared = this._bypassEncryption || options.bypassQueryAnalysis; + + if (options.extraOptions && options.extraOptions.cryptSharedLibSearchPaths) { + // Only for driver testing + mongoCryptOptions.cryptSharedLibSearchPaths = + options.extraOptions.cryptSharedLibSearchPaths; + } else if (!this._bypassMongocryptdAndCryptShared) { + mongoCryptOptions.cryptSharedLibSearchPaths = ['$SYSTEM']; + } + + Object.assign(mongoCryptOptions, { cryptoCallbacks }); + const { MongoCrypt } = getMongoDBClientEncryption(); + this._mongocrypt = new MongoCrypt(mongoCryptOptions); + this._contextCounter = 0; + + if ( + options.extraOptions && + options.extraOptions.cryptSharedLibRequired && + !this.cryptSharedLibVersionInfo + ) { + throw new MongoError('`cryptSharedLibRequired` set but no crypt_shared library loaded'); + } + + // Only instantiate mongocryptd manager/client once we know for sure + // that we are not using the CSFLE shared library. + if (!this._bypassMongocryptdAndCryptShared && !this.cryptSharedLibVersionInfo) { + this._mongocryptdManager = new MongocryptdManager(options.extraOptions); + const clientOptions = { + serverSelectionTimeoutMS: 10000 + }; + + if ( + options.extraOptions == null || + typeof options.extraOptions.mongocryptdURI !== 'string' + ) { + clientOptions.family = 4; + } + + this._mongocryptdClient = new MongoClient(this._mongocryptdManager.uri, clientOptions); + } + } + + /** + * @ignore + * @param {Function} callback Invoked when the mongocryptd client either successfully connects or errors + */ + init(callback) { + if (this._bypassMongocryptdAndCryptShared || this.cryptSharedLibVersionInfo) { + return callback(); + } + const _callback = (err, res) => { + if ( + err && + err.message && + (err.message.match(/timed out after/) || err.message.match(/ENOTFOUND/)) + ) { + callback( + new MongoError( + 'Unable to connect to `mongocryptd`, please make sure it is running or in your PATH for auto-spawn' + ) + ); + return; + } + + callback(err, res); + }; + + if (this._mongocryptdManager.bypassSpawn) { + return this._mongocryptdClient.connect().then( + result => { + return _callback(null, result); + }, + error => { + _callback(error, null); + } + ); + } + + this._mongocryptdManager.spawn(() => { + this._mongocryptdClient.connect().then( + result => { + return _callback(null, result); + }, + error => { + _callback(error, null); + } + ); + }); + } + + /** + * @ignore + * @param {Function} callback Invoked when the mongocryptd client either successfully disconnects or errors + */ + teardown(force, callback) { + if (this._mongocryptdClient) { + this._mongocryptdClient.close(force).then( + result => { + return callback(null, result); + }, + error => { + callback(error); + } + ); + } else { + callback(); + } + } + + /** + * @ignore + * Encrypt a command for a given namespace. + * + * @param {string} ns The namespace for this encryption context + * @param {object} cmd The command to encrypt + * @param {Function} callback + */ + encrypt(ns, cmd, options, callback) { + if (typeof ns !== 'string') { + throw new TypeError('Parameter `ns` must be a string'); + } + + if (typeof cmd !== 'object') { + throw new TypeError('Parameter `cmd` must be an object'); + } + + if (typeof options === 'function' && callback == null) { + callback = options; + options = {}; + } + + // If `bypassAutoEncryption` has been specified, don't encrypt + if (this._bypassEncryption) { + callback(undefined, cmd); + return; + } + + const commandBuffer = Buffer.isBuffer(cmd) ? cmd : serialize(cmd, options); + + let context; + try { + context = this._mongocrypt.makeEncryptionContext(databaseNamespace(ns), commandBuffer); + } catch (err) { + callback(err, null); + return; + } + + // TODO: should these be accessors from the addon? + context.id = this._contextCounter++; + context.ns = ns; + context.document = cmd; + + const stateMachine = new StateMachine({ + ...options, + promoteValues: false, + promoteLongs: false, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); + stateMachine.execute(this, context, callback); + } + + /** + * @ignore + * Decrypt a command response + * + * @param {Buffer} buffer + * @param {Function} callback + */ + decrypt(response, options, callback) { + if (typeof options === 'function' && callback == null) { + callback = options; + options = {}; + } + + const buffer = Buffer.isBuffer(response) ? response : serialize(response, options); + + let context; + try { + context = this._mongocrypt.makeDecryptionContext(buffer); + } catch (err) { + callback(err, null); + return; + } + + // TODO: should this be an accessor from the addon? + context.id = this._contextCounter++; + + const stateMachine = new StateMachine({ + ...options, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); + + const decorateResult = this[Symbol.for('@@mdb.decorateDecryptionResult')]; + stateMachine.execute(this, context, function (err, result) { + // Only for testing/internal usage + if (!err && result && decorateResult) { + err = decorateDecryptionResult(result, response); + if (err) return callback(err); + } + callback(err, result); + }); + } + + /** + * Ask the user for KMS credentials. + * + * This returns anything that looks like the kmsProviders original input + * option. It can be empty, and any provider specified here will override + * the original ones. + */ + async askForKMSCredentials() { + return this._onKmsProviderRefresh + ? this._onKmsProviderRefresh() + : loadCredentials(this._kmsProviders); + } + + /** + * Return the current libmongocrypt's CSFLE shared library version + * as `{ version: bigint, versionStr: string }`, or `null` if no CSFLE + * shared library was loaded. + */ + get cryptSharedLibVersionInfo() { + return this._mongocrypt.cryptSharedLibVersionInfo; + } + + static get libmongocryptVersion() { + const { MongoCrypt } = getMongoDBClientEncryption(); + return MongoCrypt.libmongocryptVersion; + } +} + +/** + * Recurse through the (identically-shaped) `decrypted` and `original` + * objects and attach a `decryptedKeys` property on each sub-object that + * contained encrypted fields. Because we only call this on BSON responses, + * we do not need to worry about circular references. + * + * @internal + * @ignore + */ +function decorateDecryptionResult(decrypted, original, isTopLevelDecorateCall = true) { + const decryptedKeys = Symbol.for('@@mdb.decryptedKeys'); + if (isTopLevelDecorateCall) { + // The original value could have been either a JS object or a BSON buffer + if (Buffer.isBuffer(original)) { + original = deserialize(original); + } + if (Buffer.isBuffer(decrypted)) { + return new Error('Expected result of decryption to be deserialized BSON object'); + } + } + + if (!decrypted || typeof decrypted !== 'object') return; + for (const k of Object.keys(decrypted)) { + const originalValue = original[k]; + + // An object was decrypted by libmongocrypt if and only if it was + // a BSON Binary object with subtype 6. + if (originalValue && originalValue._bsontype === 'Binary' && originalValue.sub_type === 6) { + if (!decrypted[decryptedKeys]) { + Object.defineProperty(decrypted, decryptedKeys, { + value: [], + configurable: true, + enumerable: false, + writable: false + }); + } + decrypted[decryptedKeys].push(k); + // Do not recurse into this decrypted value. It could be a subdocument/array, + // in which case there is no original value associated with its subfields. + continue; + } + + decorateDecryptionResult(decrypted[k], originalValue, false); + } +} diff --git a/encryption/lib/buffer_pool.js b/src/client-side-encryption/buffer_pool.js similarity index 97% rename from encryption/lib/buffer_pool.js rename to src/client-side-encryption/buffer_pool.js index 23a21fed774..a1164ab1a31 100644 --- a/encryption/lib/buffer_pool.js +++ b/src/client-side-encryption/buffer_pool.js @@ -1,5 +1,3 @@ -'use strict'; - /** * @internal * @ignore @@ -17,7 +15,7 @@ const kLength = Symbol('length'); * @internal * @ignore */ -class BufferPool { +export class BufferPool { // [kBuffers]: Buffer[]; // [kLength]: number; @@ -119,5 +117,3 @@ class BufferPool { return result; } } - -module.exports = { BufferPool }; diff --git a/src/client-side-encryption/clientEncryption.js b/src/client-side-encryption/clientEncryption.js new file mode 100644 index 00000000000..5c6e734a09c --- /dev/null +++ b/src/client-side-encryption/clientEncryption.js @@ -0,0 +1,803 @@ + +import { databaseNamespace, collectionNamespace, promiseOrCallback, maybeCallback } from './common'; +import { StateMachine } from './stateMachine'; +import { + MongoCryptCreateEncryptedCollectionError, + MongoCryptCreateDataKeyError +} from './errors'; +import { loadCredentials } from './providers/index'; +import * as cryptoCallbacks from './cryptoCallbacks'; +import { promisify } from 'util'; +import { serialize, deserialize } from '../bson'; +import { getMongoDBClientEncryption } from '../deps'; + +/** @typedef {*} BSONValue - any serializable BSON value */ +/** @typedef {BSON.Long} Long A 64 bit integer, represented by the js-bson Long type.*/ + +/** + * @typedef {object} KMSProviders Configuration options that are used by specific KMS providers during key generation, encryption, and decryption. + * @property {object} [aws] Configuration options for using 'aws' as your KMS provider + * @property {string} [aws.accessKeyId] The access key used for the AWS KMS provider + * @property {string} [aws.secretAccessKey] The secret access key used for the AWS KMS provider + * @property {object} [local] Configuration options for using 'local' as your KMS provider + * @property {Buffer} [local.key] The master key used to encrypt/decrypt data keys. A 96-byte long Buffer. + * @property {object} [azure] Configuration options for using 'azure' as your KMS provider + * @property {string} [azure.tenantId] The tenant ID identifies the organization for the account + * @property {string} [azure.clientId] The client ID to authenticate a registered application + * @property {string} [azure.clientSecret] The client secret to authenticate a registered application + * @property {string} [azure.identityPlatformEndpoint] If present, a host with optional port. E.g. "example.com" or "example.com:443". This is optional, and only needed if customer is using a non-commercial Azure instance (e.g. a government or China account, which use different URLs). Defaults to "login.microsoftonline.com" + * @property {object} [gcp] Configuration options for using 'gcp' as your KMS provider + * @property {string} [gcp.email] The service account email to authenticate + * @property {string|Binary} [gcp.privateKey] A PKCS#8 encrypted key. This can either be a base64 string or a binary representation + * @property {string} [gcp.endpoint] If present, a host with optional port. E.g. "example.com" or "example.com:443". Defaults to "oauth2.googleapis.com" + */ + +/** + * @typedef {object} DataKey A data key as stored in the database. + * @property {UUID} _id A unique identifier for the key. + * @property {number} version A numeric identifier for the schema version of this document. Implicitly 0 if unset. + * @property {string[]} [keyAltNames] Alternate names to search for keys by. Used for a per-document key scenario in support of GDPR scenarios. + * @property {Binary} keyMaterial Encrypted data key material, BinData type General. + * @property {Date} creationDate The datetime the wrapped data key material was imported into the Key Database. + * @property {Date} updateDate The datetime the wrapped data key material was last modified. On initial import, this value will be set to creationDate. + * @property {number} status 0 = enabled, 1 = disabled + * @property {object} masterKey the encrypted master key + */ + +/** + * @typedef {string} KmsProvider A string containing the name of a kms provider. Valid options are 'aws', 'azure', 'gcp', 'kmip', or 'local' + */ + +/** + * @typedef {object} ClientSession The ClientSession class from the MongoDB Node driver (see https://mongodb.github.io/node-mongodb-native/4.8/classes/ClientSession.html) + */ + +/** + * @typedef {object} DeleteResult The result of a delete operation from the MongoDB Node driver (see https://mongodb.github.io/node-mongodb-native/4.8/interfaces/DeleteResult.html) + * @property {boolean} acknowledged Indicates whether this write result was acknowledged. If not, then all other members of this result will be undefined. + * @property {number} deletedCount The number of documents that were deleted + */ + +/** + * @typedef {object} BulkWriteResult The BulkWriteResult class from the MongoDB Node driver (https://mongodb.github.io/node-mongodb-native/4.8/classes/BulkWriteResult.html) + */ + +/** + * @typedef {object} FindCursor The FindCursor class from the MongoDB Node driver (see https://mongodb.github.io/node-mongodb-native/4.8/classes/FindCursor.html) + */ + +/** + * The public interface for explicit in-use encryption + */ +export class ClientEncryption { + /** + * Create a new encryption instance + * + * @param {MongoClient} client The client used for encryption + * @param {object} options Additional settings + * @param {string} options.keyVaultNamespace The namespace of the key vault, used to store encryption keys + * @param {object} options.tlsOptions An object that maps KMS provider names to TLS options. + * @param {MongoClient} [options.keyVaultClient] A `MongoClient` used to fetch keys from a key vault. Defaults to `client` + * @param {KMSProviders} [options.kmsProviders] options for specific KMS providers to use + * + * @example + * new ClientEncryption(mongoClient, { + * keyVaultNamespace: 'client.encryption', + * kmsProviders: { + * local: { + * key: masterKey // The master key used for encryption/decryption. A 96-byte long Buffer + * } + * } + * }); + * + * @example + * new ClientEncryption(mongoClient, { + * keyVaultNamespace: 'client.encryption', + * kmsProviders: { + * aws: { + * accessKeyId: AWS_ACCESS_KEY, + * secretAccessKey: AWS_SECRET_KEY + * } + * } + * }); + */ + constructor(client, options) { + this._client = client; + this._proxyOptions = options.proxyOptions; + this._tlsOptions = options.tlsOptions; + this._kmsProviders = options.kmsProviders || {}; + + if (options.keyVaultNamespace == null) { + throw new TypeError('Missing required option `keyVaultNamespace`'); + } + + const mongoCryptOptions = { ...options, cryptoCallbacks }; + + mongoCryptOptions.kmsProviders = !Buffer.isBuffer(this._kmsProviders) + ? serialize(this._kmsProviders) + : this._kmsProviders; + + this._onKmsProviderRefresh = options.onKmsProviderRefresh; + this._keyVaultNamespace = options.keyVaultNamespace; + this._keyVaultClient = options.keyVaultClient || client; + const { MongoCrypt } = getMongoDBClientEncryption(); + this._mongoCrypt = new MongoCrypt(mongoCryptOptions); + } + + /** + * @typedef {Binary} ClientEncryptionDataKeyId + * The id of an existing dataKey. Is a bson Binary value. + * Can be used for {@link ClientEncryption.encrypt}, and can be used to directly + * query for the data key itself against the key vault namespace. + */ + + /** + * @callback ClientEncryptionCreateDataKeyCallback + * @param {Error} [error] If present, indicates an error that occurred in the creation of the data key + * @param {ClientEncryption~dataKeyId} [dataKeyId] If present, returns the id of the created data key + */ + + /** + * @typedef {object} AWSEncryptionKeyOptions Configuration options for making an AWS encryption key + * @property {string} region The AWS region of the KMS + * @property {string} key The Amazon Resource Name (ARN) to the AWS customer master key (CMK) + * @property {string} [endpoint] An alternate host to send KMS requests to. May include port number + */ + + /** + * @typedef {object} GCPEncryptionKeyOptions Configuration options for making a GCP encryption key + * @property {string} projectId GCP project id + * @property {string} location Location name (e.g. "global") + * @property {string} keyRing Key ring name + * @property {string} keyName Key name + * @property {string} [keyVersion] Key version + * @property {string} [endpoint] KMS URL, defaults to `https://www.googleapis.com/auth/cloudkms` + */ + + /** + * @typedef {object} AzureEncryptionKeyOptions Configuration options for making an Azure encryption key + * @property {string} keyName Key name + * @property {string} keyVaultEndpoint Key vault URL, typically `.vault.azure.net` + * @property {string} [keyVersion] Key version + */ + + /** + * Creates a data key used for explicit encryption and inserts it into the key vault namespace + * + * @param {string} provider The KMS provider used for this data key. Must be `'aws'`, `'azure'`, `'gcp'`, or `'local'` + * @param {object} [options] Options for creating the data key + * @param {AWSEncryptionKeyOptions|AzureEncryptionKeyOptions|GCPEncryptionKeyOptions} [options.masterKey] Idenfities a new KMS-specific key used to encrypt the new data key + * @param {string[]} [options.keyAltNames] An optional list of string alternate names used to reference a key. If a key is created with alternate names, then encryption may refer to the key by the unique alternate name instead of by _id. + * @param {ClientEncryptionCreateDataKeyCallback} [callback] Optional callback to invoke when key is created + * @returns {Promise|void} If no callback is provided, returns a Promise that either resolves with {@link ClientEncryption~dataKeyId the id of the created data key}, or rejects with an error. If a callback is provided, returns nothing. + * @example + * // Using callbacks to create a local key + * clientEncryption.createDataKey('local', (err, dataKey) => { + * if (err) { + * // This means creating the key failed. + * } else { + * // key creation succeeded + * } + * }); + * + * @example + * // Using async/await to create a local key + * const dataKeyId = await clientEncryption.createDataKey('local'); + * + * @example + * // Using async/await to create an aws key + * const dataKeyId = await clientEncryption.createDataKey('aws', { + * masterKey: { + * region: 'us-east-1', + * key: 'xxxxxxxxxxxxxx' // CMK ARN here + * } + * }); + * + * @example + * // Using async/await to create an aws key with a keyAltName + * const dataKeyId = await clientEncryption.createDataKey('aws', { + * masterKey: { + * region: 'us-east-1', + * key: 'xxxxxxxxxxxxxx' // CMK ARN here + * }, + * keyAltNames: [ 'mySpecialKey' ] + * }); + */ + createDataKey(provider, options, callback) { + if (typeof options === 'function') { + callback = options; + options = {}; + } + if (options == null) { + options = {}; + } + + const dataKey = Object.assign({ provider }, options.masterKey); + + if (options.keyAltNames && !Array.isArray(options.keyAltNames)) { + throw new TypeError( + `Option "keyAltNames" must be an array of strings, but was of type ${typeof options.keyAltNames}.` + ); + } + + let keyAltNames = undefined; + if (options.keyAltNames && options.keyAltNames.length > 0) { + keyAltNames = options.keyAltNames.map((keyAltName, i) => { + if (typeof keyAltName !== 'string') { + throw new TypeError( + `Option "keyAltNames" must be an array of strings, but item at index ${i} was of type ${typeof keyAltName}` + ); + } + + return serialize({ keyAltName }); + }); + } + + let keyMaterial = undefined; + if (options.keyMaterial) { + keyMaterial = serialize({ keyMaterial: options.keyMaterial }); + } + + const dataKeyBson = serialize(dataKey); + const context = this._mongoCrypt.makeDataKeyContext(dataKeyBson, { + keyAltNames, + keyMaterial + }); + const stateMachine = new StateMachine({ + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); + + return promiseOrCallback(callback, cb => { + stateMachine.execute(this, context, (err, dataKey) => { + if (err) { + cb(err, null); + return; + } + + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + + this._keyVaultClient + .db(dbName) + .collection(collectionName) + .insertOne(dataKey, { writeConcern: { w: 'majority' } }) + .then( + result => { + return cb(null, result.insertedId); + }, + err => { + cb(err, null); + } + ); + }); + }); + } + + /** + * @typedef {object} RewrapManyDataKeyResult + * @property {BulkWriteResult} [bulkWriteResult] An optional BulkWriteResult, if any keys were matched and attempted to be re-wrapped. + */ + + /** + * Searches the keyvault for any data keys matching the provided filter. If there are matches, rewrapManyDataKey then attempts to re-wrap the data keys using the provided options. + * + * If no matches are found, then no bulk write is performed. + * + * @param {object} filter A valid MongoDB filter. Any documents matching this filter will be re-wrapped. + * @param {object} [options] + * @param {KmsProvider} options.provider The KMS provider to use when re-wrapping the data keys. + * @param {AWSEncryptionKeyOptions | AzureEncryptionKeyOptions | GCPEncryptionKeyOptions} [options.masterKey] + * @returns {Promise} + * + * @example + * // rewrapping all data data keys (using a filter that matches all documents) + * const filter = {}; + * + * const result = await clientEncryption.rewrapManyDataKey(filter); + * if (result.bulkWriteResult != null) { + * // keys were re-wrapped, results will be available in the bulkWrite object. + * } + * + * @example + * // attempting to rewrap all data keys with no matches + * const filter = { _id: new Binary() } // assume _id matches no documents in the database + * const result = await clientEncryption.rewrapManyDataKey(filter); + * + * if (result.bulkWriteResult == null) { + * // no keys matched, `bulkWriteResult` does not exist on the result object + * } + */ + async rewrapManyDataKey(filter, options) { + let keyEncryptionKeyBson = undefined; + if (options) { + const keyEncryptionKey = Object.assign({ provider: options.provider }, options.masterKey); + keyEncryptionKeyBson = serialize(keyEncryptionKey); + } else { + // Always make sure `options` is an object below. + options = {}; + } + const filterBson = serialize(filter); + const context = this._mongoCrypt.makeRewrapManyDataKeyContext( + filterBson, + keyEncryptionKeyBson + ); + const stateMachine = new StateMachine({ + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); + + const execute = promisify(stateMachine.execute.bind(stateMachine)); + + const dataKey = await execute(this, context); + if (!dataKey || dataKey.v.length === 0) { + return {}; + } + + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + const replacements = dataKey.v.map(key => ({ + updateOne: { + filter: { _id: key._id }, + update: { + $set: { + masterKey: key.masterKey, + keyMaterial: key.keyMaterial + }, + $currentDate: { + updateDate: true + } + } + } + })); + + const result = await this._keyVaultClient + .db(dbName) + .collection(collectionName) + .bulkWrite(replacements, { + writeConcern: { w: 'majority' } + }); + + return { bulkWriteResult: result }; + } + + /** + * Deletes the key with the provided id from the keyvault, if it exists. + * + * @param {ClientEncryptionDataKeyId} _id - the id of the document to delete. + * @returns {Promise} Returns a promise that either resolves to a {@link DeleteResult} or rejects with an error. + * + * @example + * // delete a key by _id + * const id = new Binary(); // id is a bson binary subtype 4 object + * const { deletedCount } = await clientEncryption.deleteKey(id); + * + * if (deletedCount != null && deletedCount > 0) { + * // successful deletion + * } + * + */ + async deleteKey(_id) { + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + return await this._keyVaultClient + .db(dbName) + .collection(collectionName) + .deleteOne({ _id }, { writeConcern: { w: 'majority' } }); + } + + /** + * Finds all the keys currently stored in the keyvault. + * + * This method will not throw. + * + * @returns {FindCursor} a FindCursor over all keys in the keyvault. + * @example + * // fetching all keys + * const keys = await clientEncryption.getKeys().toArray(); + */ + getKeys() { + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + return this._keyVaultClient + .db(dbName) + .collection(collectionName) + .find({}, { readConcern: { level: 'majority' } }); + } + + /** + * Finds a key in the keyvault with the specified _id. + * + * @param {ClientEncryptionDataKeyId} _id - the id of the document to delete. + * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents + * match the id. The promise rejects with an error if an error is thrown. + * @example + * // getting a key by id + * const id = new Binary(); // id is a bson binary subtype 4 object + * const key = await clientEncryption.getKey(id); + * if (!key) { + * // key is null if there was no matching key + * } + */ + async getKey(_id) { + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + return await this._keyVaultClient + .db(dbName) + .collection(collectionName) + .findOne({ _id }, { readConcern: { level: 'majority' } }); + } + + /** + * Finds a key in the keyvault which has the specified keyAltName. + * + * @param {string} keyAltName - a keyAltName to search for a key + * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents + * match the keyAltName. The promise rejects with an error if an error is thrown. + * @example + * // get a key by alt name + * const keyAltName = 'keyAltName'; + * const key = await clientEncryption.getKeyByAltName(keyAltName); + * if (!key) { + * // key is null if there is no matching key + * } + */ + async getKeyByAltName(keyAltName) { + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + return await this._keyVaultClient + .db(dbName) + .collection(collectionName) + .findOne({ keyAltNames: keyAltName }, { readConcern: { level: 'majority' } }); + } + + /** + * Adds a keyAltName to a key identified by the provided _id. + * + * This method resolves to/returns the *old* key value (prior to adding the new altKeyName). + * + * @param {ClientEncryptionDataKeyId} _id The id of the document to update. + * @param {string} keyAltName - a keyAltName to search for a key + * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents + * match the id. The promise rejects with an error if an error is thrown. + * @example + * // adding an keyAltName to a data key + * const id = new Binary(); // id is a bson binary subtype 4 object + * const keyAltName = 'keyAltName'; + * const oldKey = await clientEncryption.addKeyAltName(id, keyAltName); + * if (!oldKey) { + * // null is returned if there is no matching document with an id matching the supplied id + * } + */ + async addKeyAltName(_id, keyAltName) { + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + const { value } = await this._keyVaultClient + .db(dbName) + .collection(collectionName) + .findOneAndUpdate( + { _id }, + { $addToSet: { keyAltNames: keyAltName } }, + { writeConcern: { w: 'majority' }, returnDocument: 'before' } + ); + + return value; + } + + /** + * Adds a keyAltName to a key identified by the provided _id. + * + * This method resolves to/returns the *old* key value (prior to removing the new altKeyName). + * + * If the removed keyAltName is the last keyAltName for that key, the `altKeyNames` property is unset from the document. + * + * @param {ClientEncryptionDataKeyId} _id The id of the document to update. + * @param {string} keyAltName - a keyAltName to search for a key + * @returns {Promise} Returns a promise that either resolves to a {@link DataKey} if a document matches the key or null if no documents + * match the id. The promise rejects with an error if an error is thrown. + * @example + * // removing a key alt name from a data key + * const id = new Binary(); // id is a bson binary subtype 4 object + * const keyAltName = 'keyAltName'; + * const oldKey = await clientEncryption.removeKeyAltName(id, keyAltName); + * + * if (!oldKey) { + * // null is returned if there is no matching document with an id matching the supplied id + * } + */ + async removeKeyAltName(_id, keyAltName) { + const dbName = databaseNamespace(this._keyVaultNamespace); + const collectionName = collectionNamespace(this._keyVaultNamespace); + const pipeline = [ + { + $set: { + keyAltNames: { + $cond: [ + { + $eq: ['$keyAltNames', [keyAltName]] + }, + '$$REMOVE', + { + $filter: { + input: '$keyAltNames', + cond: { + $ne: ['$$this', keyAltName] + } + } + } + ] + } + } + } + ]; + const { value } = await this._keyVaultClient + .db(dbName) + .collection(collectionName) + .findOneAndUpdate({ _id }, pipeline, { + writeConcern: { w: 'majority' }, + returnDocument: 'before' + }); + + return value; + } + + /** + * A convenience method for creating an encrypted collection. + * This method will create data keys for any encryptedFields that do not have a `keyId` defined + * and then create a new collection with the full set of encryptedFields. + * + * @template {TSchema} - Schema for the collection being created + * @param {Db} db - A Node.js driver Db object with which to create the collection + * @param {string} name - The name of the collection to be created + * @param {object} options - Options for createDataKey and for createCollection + * @param {string} options.provider - KMS provider name + * @param {AWSEncryptionKeyOptions | AzureEncryptionKeyOptions | GCPEncryptionKeyOptions} [options.masterKey] - masterKey to pass to createDataKey + * @param {CreateCollectionOptions} options.createCollectionOptions - options to pass to createCollection, must include `encryptedFields` + * @returns {Promise<{ collection: Collection, encryptedFields: Document }>} - created collection and generated encryptedFields + * @throws {MongoCryptCreateDataKeyError} - If part way through the process a createDataKey invocation fails, an error will be rejected that has the partial `encryptedFields` that were created. + * @throws {MongoCryptCreateEncryptedCollectionError} - If creating the collection fails, an error will be rejected that has the entire `encryptedFields` that were created. + */ + async createEncryptedCollection(db, name, options) { + const { + provider, + masterKey, + createCollectionOptions: { + encryptedFields: { ...encryptedFields }, + ...createCollectionOptions + } + } = options; + + if (Array.isArray(encryptedFields.fields)) { + const createDataKeyPromises = encryptedFields.fields.map(async field => + field == null || typeof field !== 'object' || field.keyId != null + ? field + : { + ...field, + keyId: await this.createDataKey(provider, { masterKey }) + } + ); + + const createDataKeyResolutions = await Promise.allSettled(createDataKeyPromises); + + encryptedFields.fields = createDataKeyResolutions.map((resolution, index) => + resolution.status === 'fulfilled' ? resolution.value : encryptedFields.fields[index] + ); + + const rejection = createDataKeyResolutions.find(({ status }) => status === 'rejected'); + if (rejection != null) { + throw new MongoCryptCreateDataKeyError({ encryptedFields, cause: rejection.reason }); + } + } + + try { + const collection = await db.createCollection(name, { + ...createCollectionOptions, + encryptedFields + }); + return { collection, encryptedFields }; + } catch (cause) { + throw new MongoCryptCreateEncryptedCollectionError({ encryptedFields, cause }); + } + } + + /** + * @callback ClientEncryptionEncryptCallback + * @param {Error} [err] If present, indicates an error that occurred in the process of encryption + * @param {Buffer} [result] If present, is the encrypted result + */ + + /** + * @typedef {object} RangeOptions + * min, max, sparsity, and range must match the values set in the encryptedFields of the destination collection. + * For double and decimal128, min/max/precision must all be set, or all be unset. + * @property {BSONValue} min is required if precision is set. + * @property {BSONValue} max is required if precision is set. + * @property {BSON.Long} sparsity + * @property {number | undefined} precision (may only be set for double or decimal128). + */ + + /** + * @typedef {object} EncryptOptions Options to provide when encrypting data. + * @property {ClientEncryptionDataKeyId} [keyId] The id of the Binary dataKey to use for encryption. + * @property {string} [keyAltName] A unique string name corresponding to an already existing dataKey. + * @property {string} [algorithm] The algorithm to use for encryption. Must be either `'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'`, `'AEAD_AES_256_CBC_HMAC_SHA_512-Random'`, `'Indexed'` or `'Unindexed'` + * @property {bigint | number} [contentionFactor] - the contention factor. + * @property {'equality' | 'rangePreview'} queryType - the query type supported. only the query type `equality` is stable at this time. queryType `rangePreview` is experimental. + * @property {RangeOptions} [rangeOptions] (experimental) The index options for a Queryable Encryption field supporting "rangePreview" queries. + */ + + /** + * Explicitly encrypt a provided value. Note that either `options.keyId` or `options.keyAltName` must + * be specified. Specifying both `options.keyId` and `options.keyAltName` is considered an error. + * + * @param {*} value The value that you wish to serialize. Must be of a type that can be serialized into BSON + * @param {EncryptOptions} options + * @param {ClientEncryptionEncryptCallback} [callback] Optional callback to invoke when value is encrypted + * @returns {Promise|void} If no callback is provided, returns a Promise that either resolves with the encrypted value, or rejects with an error. If a callback is provided, returns nothing. + * + * @example + * // Encryption with callback API + * function encryptMyData(value, callback) { + * clientEncryption.createDataKey('local', (err, keyId) => { + * if (err) { + * return callback(err); + * } + * clientEncryption.encrypt(value, { keyId, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }, callback); + * }); + * } + * + * @example + * // Encryption with async/await api + * async function encryptMyData(value) { + * const keyId = await clientEncryption.createDataKey('local'); + * return clientEncryption.encrypt(value, { keyId, algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }); + * } + * + * @example + * // Encryption using a keyAltName + * async function encryptMyData(value) { + * await clientEncryption.createDataKey('local', { keyAltNames: 'mySpecialKey' }); + * return clientEncryption.encrypt(value, { keyAltName: 'mySpecialKey', algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' }); + * } + */ + encrypt(value, options, callback) { + return maybeCallback(() => this._encrypt(value, false, options), callback); + } + + /** + * Encrypts a Match Expression or Aggregate Expression to query a range index. + * + * Only supported when queryType is "rangePreview" and algorithm is "RangePreview". + * + * @experimental The Range algorithm is experimental only. It is not intended for production use. It is subject to breaking changes. + * + * @param {object} expression a BSON document of one of the following forms: + * 1. A Match Expression of this form: + * `{$and: [{: {$gt: }}, {: {$lt: }}]}` + * 2. An Aggregate Expression of this form: + * `{$and: [{$gt: [, ]}, {$lt: [, ]}]}` + * + * `$gt` may also be `$gte`. `$lt` may also be `$lte`. + * + * @param {EncryptOptions} options + * @returns {Promise} Returns a Promise that either resolves with the encrypted value or rejects with an error. + */ + async encryptExpression(expression, options) { + return this._encrypt(expression, true, options); + } + + /** + * @callback ClientEncryption~decryptCallback + * @param {Error} [err] If present, indicates an error that occurred in the process of decryption + * @param {object} [result] If present, is the decrypted result + */ + + /** + * Explicitly decrypt a provided encrypted value + * + * @param {Buffer | Binary} value An encrypted value + * @param {ClientEncryption~decryptCallback} callback Optional callback to invoke when value is decrypted + * @returns {Promise|void} If no callback is provided, returns a Promise that either resolves with the decrypted value, or rejects with an error. If a callback is provided, returns nothing. + * + * @example + * // Decrypting value with callback API + * function decryptMyValue(value, callback) { + * clientEncryption.decrypt(value, callback); + * } + * + * @example + * // Decrypting value with async/await API + * async function decryptMyValue(value) { + * return clientEncryption.decrypt(value); + * } + */ + decrypt(value, callback) { + const valueBuffer = serialize({ v: value }); + const context = this._mongoCrypt.makeExplicitDecryptionContext(valueBuffer); + + const stateMachine = new StateMachine({ + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); + + return promiseOrCallback(callback, cb => { + stateMachine.execute(this, context, (err, result) => { + if (err) { + cb(err, null); + return; + } + + cb(null, result.v); + }); + }); + } + + /** + * Ask the user for KMS credentials. + * + * This returns anything that looks like the kmsProviders original input + * option. It can be empty, and any provider specified here will override + * the original ones. + */ + async askForKMSCredentials() { + return this._onKmsProviderRefresh + ? this._onKmsProviderRefresh() + : loadCredentials(this._kmsProviders); + } + + static get libmongocryptVersion() { + const { MongoCrypt } = getMongoDBClientEncryption(); + return MongoCrypt.libmongocryptVersion; + } + + /** + * A helper that perform explicit encryption of values and expressions. + * Explicitly encrypt a provided value. Note that either `options.keyId` or `options.keyAltName` must + * be specified. Specifying both `options.keyId` and `options.keyAltName` is considered an error. + * + * @param {*} value The value that you wish to encrypt. Must be of a type that can be serialized into BSON + * @param {boolean} expressionMode - a boolean that indicates whether or not to encrypt the value as an expression + * @param {EncryptOptions} options + * @returns the raw result of the call to stateMachine.execute(). When expressionMode is set to true, the return + * value will be a bson document. When false, the value will be a BSON Binary. + * + * @ignore + * + */ + async _encrypt(value, expressionMode, options) { + const valueBuffer = serialize({ v: value }); + const contextOptions = Object.assign({}, options, { expressionMode }); + if (options.keyId) { + contextOptions.keyId = options.keyId.buffer; + } + if (options.keyAltName) { + const keyAltName = options.keyAltName; + if (options.keyId) { + throw new TypeError(`"options" cannot contain both "keyId" and "keyAltName"`); + } + const keyAltNameType = typeof keyAltName; + if (keyAltNameType !== 'string') { + throw new TypeError( + `"options.keyAltName" must be of type string, but was of type ${keyAltNameType}` + ); + } + + contextOptions.keyAltName = serialize({ keyAltName }); + } + + if ('rangeOptions' in options) { + contextOptions.rangeOptions = serialize(options.rangeOptions); + } + + const stateMachine = new StateMachine({ + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); + const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions); + + const result = await stateMachine.executeAsync(this, context); + return result.v; + } + + +} diff --git a/encryption/lib/common.js b/src/client-side-encryption/common.js similarity index 87% rename from encryption/lib/common.js rename to src/client-side-encryption/common.js index 468b8c05082..0685530727f 100644 --- a/encryption/lib/common.js +++ b/src/client-side-encryption/common.js @@ -1,11 +1,9 @@ -'use strict'; - /** * @ignore * Helper function for logging. Enabled by setting the environment flag MONGODB_CRYPT_DEBUG. * @param {*} msg Anything you want to be logged. */ -function debug(msg) { +export function debug(msg) { if (process.env.MONGODB_CRYPT_DEBUG) { // eslint-disable-next-line no-console console.error(msg); @@ -18,7 +16,7 @@ function debug(msg) { * @param {string} ns A string in the format of a namespace (database.collection) * @returns {string} The database portion of the namespace */ -function databaseNamespace(ns) { +export function databaseNamespace(ns) { return ns.split('.')[0]; } /** @@ -27,11 +25,11 @@ function databaseNamespace(ns) { * @param {string} ns A string in the format of a namespace (database.collection) * @returns {string} The collection portion of the namespace */ -function collectionNamespace(ns) { +export function collectionNamespace(ns) { return ns.split('.').slice(1).join('.'); } -function maybeCallback(promiseFn, callback) { +export function maybeCallback(promiseFn, callback) { const promise = promiseFn(); if (callback == null) { return promise; @@ -54,7 +52,7 @@ function maybeCallback(promiseFn, callback) { * @param {Function} fn A function that takes a callback * @returns {Promise|void} Returns nothing if a callback is supplied, else returns a Promise. */ -function promiseOrCallback(callback, fn) { +export function promiseOrCallback(callback, fn) { if (typeof callback === 'function') { fn(function (err) { if (err != null) { @@ -88,11 +86,3 @@ function promiseOrCallback(callback, fn) { }); }); } - -module.exports = { - debug, - databaseNamespace, - collectionNamespace, - promiseOrCallback, - maybeCallback -}; diff --git a/encryption/lib/cryptoCallbacks.js b/src/client-side-encryption/cryptoCallbacks.js similarity index 64% rename from encryption/lib/cryptoCallbacks.js rename to src/client-side-encryption/cryptoCallbacks.js index a036ff80821..30ac77fe336 100644 --- a/encryption/lib/cryptoCallbacks.js +++ b/src/client-side-encryption/cryptoCallbacks.js @@ -1,7 +1,6 @@ -'use strict'; -const crypto = require('crypto'); +import * as crypto from 'crypto'; -function makeAES256Hook(method, mode) { +export function makeAES256Hook(method, mode) { return function (key, iv, input, output) { let result; @@ -22,7 +21,7 @@ function makeAES256Hook(method, mode) { }; } -function randomHook(buffer, count) { +export function randomHook(buffer, count) { try { crypto.randomFillSync(buffer, 0, count); } catch (e) { @@ -31,7 +30,7 @@ function randomHook(buffer, count) { return count; } -function sha256Hook(input, output) { +export function sha256Hook(input, output) { let result; try { result = crypto.createHash('sha256').update(input).digest(); @@ -43,7 +42,7 @@ function sha256Hook(input, output) { return result.length; } -function makeHmacHook(algorithm) { +export function makeHmacHook(algorithm) { return (key, input, output) => { let result; try { @@ -57,7 +56,7 @@ function makeHmacHook(algorithm) { }; } -function signRsaSha256Hook(key, input, output) { +export function signRsaSha256Hook(key, input, output) { let result; try { const signer = crypto.createSign('sha256WithRSAEncryption'); @@ -74,14 +73,10 @@ function signRsaSha256Hook(key, input, output) { return result.length; } -module.exports = { - aes256CbcEncryptHook: makeAES256Hook('createCipheriv', 'aes-256-cbc'), - aes256CbcDecryptHook: makeAES256Hook('createDecipheriv', 'aes-256-cbc'), - aes256CtrEncryptHook: makeAES256Hook('createCipheriv', 'aes-256-ctr'), - aes256CtrDecryptHook: makeAES256Hook('createDecipheriv', 'aes-256-ctr'), - randomHook, - hmacSha512Hook: makeHmacHook('sha512'), - hmacSha256Hook: makeHmacHook('sha256'), - sha256Hook, - signRsaSha256Hook -}; + +export const aes256CbcEncryptHook = makeAES256Hook('createCipheriv', 'aes-256-cbc'); +export const aes256CbcDecryptHook = makeAES256Hook('createDecipheriv', 'aes-256-cbc'); +export const aes256CtrEncryptHook = makeAES256Hook('createCipheriv', 'aes-256-ctr'); +export const aes256CtrDecryptHook = makeAES256Hook('createDecipheriv', 'aes-256-ctr'); +export const hmacSha512Hook = makeHmacHook('sha512'); +export const hmacSha256Hook = makeHmacHook('sha256'); diff --git a/encryption/lib/errors.js b/src/client-side-encryption/errors.js similarity index 71% rename from encryption/lib/errors.js rename to src/client-side-encryption/errors.js index 577930aae59..bb0a72ff625 100644 --- a/encryption/lib/errors.js +++ b/src/client-side-encryption/errors.js @@ -1,10 +1,8 @@ -'use strict'; - /** * @class * An error indicating that something went wrong specifically with MongoDB Client Encryption */ -class MongoCryptError extends Error { +export class MongoCryptError extends Error { constructor(message, options = {}) { super(message); if (options.cause != null) { @@ -21,7 +19,7 @@ class MongoCryptError extends Error { * @class * An error indicating that `ClientEncryption.createEncryptedCollection()` failed to create data keys */ -class MongoCryptCreateDataKeyError extends MongoCryptError { +export class MongoCryptCreateDataKeyError extends MongoCryptError { constructor({ encryptedFields, cause }) { super(`Unable to complete creating data keys: ${cause.message}`, { cause }); this.encryptedFields = encryptedFields; @@ -36,7 +34,7 @@ class MongoCryptCreateDataKeyError extends MongoCryptError { * @class * An error indicating that `ClientEncryption.createEncryptedCollection()` failed to create a collection */ -class MongoCryptCreateEncryptedCollectionError extends MongoCryptError { +export class MongoCryptCreateEncryptedCollectionError extends MongoCryptError { constructor({ encryptedFields, cause }) { super(`Unable to create collection: ${cause.message}`, { cause }); this.encryptedFields = encryptedFields; @@ -51,7 +49,7 @@ class MongoCryptCreateEncryptedCollectionError extends MongoCryptError { * @class * An error indicating that mongodb-client-encryption failed to auto-refresh Azure KMS credentials. */ -class MongoCryptAzureKMSRequestError extends MongoCryptError { +export class MongoCryptAzureKMSRequestError extends MongoCryptError { /** * @param {string} message * @param {object | undefined} body @@ -62,12 +60,4 @@ class MongoCryptAzureKMSRequestError extends MongoCryptError { } } -class MongoCryptKMSRequestNetworkTimeoutError extends MongoCryptError {} - -module.exports = { - MongoCryptError, - MongoCryptKMSRequestNetworkTimeoutError, - MongoCryptAzureKMSRequestError, - MongoCryptCreateDataKeyError, - MongoCryptCreateEncryptedCollectionError -}; +export class MongoCryptKMSRequestNetworkTimeoutError extends MongoCryptError {} diff --git a/encryption/lib/mongocryptdManager.js b/src/client-side-encryption/mongocryptdManager.js similarity index 89% rename from encryption/lib/mongocryptdManager.js rename to src/client-side-encryption/mongocryptdManager.js index 076c584c9b7..3bde6df2f15 100644 --- a/encryption/lib/mongocryptdManager.js +++ b/src/client-side-encryption/mongocryptdManager.js @@ -1,12 +1,12 @@ -'use strict'; - -const spawn = require('child_process').spawn; +import { spawn } from 'child_process' /** - * @ignore + * @internal * An internal class that handles spawning a mongocryptd. */ -class MongocryptdManager { +export class MongocryptdManager { + static DEFAULT_MONGOCRYPTD_URI = 'mongodb://localhost:27020'; + /** * @ignore * Creates a new Mongocryptd Manager @@ -60,7 +60,3 @@ class MongocryptdManager { process.nextTick(callback); } } - -MongocryptdManager.DEFAULT_MONGOCRYPTD_URI = 'mongodb://localhost:27020'; - -module.exports = { MongocryptdManager }; diff --git a/encryption/lib/providers/aws.js b/src/client-side-encryption/providers/aws.js similarity index 86% rename from encryption/lib/providers/aws.js rename to src/client-side-encryption/providers/aws.js index b71c2b1d96d..05b0fddb373 100644 --- a/encryption/lib/providers/aws.js +++ b/src/client-side-encryption/providers/aws.js @@ -1,8 +1,6 @@ -'use strict'; - let awsCredentialProviders = null; /** @ignore */ -async function loadAWSCredentials(kmsProviders) { +export async function loadAWSCredentials(kmsProviders) { if (awsCredentialProviders == null) { try { // Ensure you always wrap an optional require in the try block NODE-3199 @@ -22,5 +20,3 @@ async function loadAWSCredentials(kmsProviders) { return kmsProviders; } - -module.exports = { loadAWSCredentials }; diff --git a/encryption/lib/providers/azure.js b/src/client-side-encryption/providers/azure.js similarity index 89% rename from encryption/lib/providers/azure.js rename to src/client-side-encryption/providers/azure.js index bd5225643a8..d6525607dd9 100644 --- a/encryption/lib/providers/azure.js +++ b/src/client-side-encryption/providers/azure.js @@ -1,10 +1,8 @@ -'use strict'; - -const { +import { MongoCryptAzureKMSRequestError, MongoCryptKMSRequestNetworkTimeoutError -} = require('../errors'); -const utils = require('./utils'); +} from '../errors'; +import * as utils from './utils'; const MINIMUM_TOKEN_REFRESH_IN_MILLISECONDS = 6000; @@ -12,7 +10,7 @@ const MINIMUM_TOKEN_REFRESH_IN_MILLISECONDS = 6000; * @class * @ignore */ -class AzureCredentialCache { +export class AzureCredentialCache { constructor() { /** * @type { { accessToken: string, expiresOnTimestamp: number } | null} @@ -56,7 +54,7 @@ class AzureCredentialCache { * @type{ AzureCredentialCache } * @ignore */ -let tokenCache = new AzureCredentialCache(); +export let tokenCache = new AzureCredentialCache(); /** * @typedef {object} KmsRequestResponsePayload @@ -71,7 +69,7 @@ let tokenCache = new AzureCredentialCache(); * @returns { Promise<{ accessToken: string, expiresOnTimestamp: number } >} * @ignore */ -async function parseResponse(response) { +export async function parseResponse(response) { const { status, body: rawBody } = response; /** @@ -121,7 +119,7 @@ async function parseResponse(response) { * * @ignore */ -function prepareRequest(options) { +export function prepareRequest(options) { const url = options.url == null ? new URL('http://169.254.169.254/metadata/identity/oauth2/token') @@ -156,7 +154,7 @@ function prepareRequest(options) { * * @ignore */ -async function fetchAzureKMSToken(options = {}) { +export async function fetchAzureKMSToken(options = {}) { const { headers, url } = prepareRequest(options); const response = await utils.get(url, { headers }).catch(error => { if (error instanceof MongoCryptKMSRequestNetworkTimeoutError) { @@ -170,9 +168,7 @@ async function fetchAzureKMSToken(options = {}) { /** * @ignore */ -async function loadAzureCredentials(kmsProviders) { +export async function loadAzureCredentials(kmsProviders) { const azure = await tokenCache.getToken(); return { ...kmsProviders, azure }; } - -module.exports = { loadAzureCredentials, AzureCredentialCache, fetchAzureKMSToken, tokenCache }; diff --git a/encryption/lib/providers/gcp.js b/src/client-side-encryption/providers/gcp.js similarity index 82% rename from encryption/lib/providers/gcp.js rename to src/client-side-encryption/providers/gcp.js index 01edcfdd147..d529e62a33c 100644 --- a/encryption/lib/providers/gcp.js +++ b/src/client-side-encryption/providers/gcp.js @@ -1,8 +1,6 @@ -'use strict'; - let gcpMetadata = null; /** @ignore */ -async function loadGCPCredentials(kmsProviders) { +export async function loadGCPCredentials(kmsProviders) { if (gcpMetadata == null) { try { // Ensure you always wrap an optional require in the try block NODE-3199 @@ -20,5 +18,3 @@ async function loadGCPCredentials(kmsProviders) { return kmsProviders; } - -module.exports = { loadGCPCredentials }; diff --git a/encryption/lib/providers/index.js b/src/client-side-encryption/providers/index.js similarity index 88% rename from encryption/lib/providers/index.js rename to src/client-side-encryption/providers/index.js index 33847a57b5e..506f676ac6a 100644 --- a/encryption/lib/providers/index.js +++ b/src/client-side-encryption/providers/index.js @@ -1,8 +1,6 @@ -'use strict'; - -const { loadAWSCredentials } = require('./aws'); -const { loadAzureCredentials, fetchAzureKMSToken } = require('./azure'); -const { loadGCPCredentials } = require('./gcp'); +import { loadAWSCredentials } from './aws' +import { loadAzureCredentials, fetchAzureKMSToken }from './azure'; +import { loadGCPCredentials } from './gcp'; /** * Auto credential fetching should only occur when the provider is defined on the kmsProviders map diff --git a/encryption/lib/providers/utils.js b/src/client-side-encryption/providers/utils.js similarity index 88% rename from encryption/lib/providers/utils.js rename to src/client-side-encryption/providers/utils.js index 844c1369690..942851bf541 100644 --- a/encryption/lib/providers/utils.js +++ b/src/client-side-encryption/providers/utils.js @@ -1,7 +1,5 @@ -'use strict'; - -const { MongoCryptKMSRequestNetworkTimeoutError } = require('../errors'); -const http = require('http'); +import { MongoCryptKMSRequestNetworkTimeoutError } from '../errors'; +import * as http from 'http'; /** * @param {URL | string} url diff --git a/src/client-side-encryption/stateMachine.js b/src/client-side-encryption/stateMachine.js new file mode 100644 index 00000000000..c08fb259b2d --- /dev/null +++ b/src/client-side-encryption/stateMachine.js @@ -0,0 +1,477 @@ +import { promisify } from 'util'; + +import * as tls from 'tls'; +import * as net from 'net'; +import * as fs from 'fs'; +import { once } from 'events'; +import { SocksClient } from 'socks'; +import { MongoNetworkTimeoutError } from '../error'; +import { debug, databaseNamespace, collectionNamespace } from './common'; +import { MongoCryptError } from './errors'; +import { BufferPool } from './buffer_pool'; +import { serialize, deserialize } from '../bson'; + +// libmongocrypt states +const MONGOCRYPT_CTX_ERROR = 0; +const MONGOCRYPT_CTX_NEED_MONGO_COLLINFO = 1; +const MONGOCRYPT_CTX_NEED_MONGO_MARKINGS = 2; +const MONGOCRYPT_CTX_NEED_MONGO_KEYS = 3; +const MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS = 7; +const MONGOCRYPT_CTX_NEED_KMS = 4; +const MONGOCRYPT_CTX_READY = 5; +const MONGOCRYPT_CTX_DONE = 6; + +const HTTPS_PORT = 443; + +const stateToString = new Map([ + [MONGOCRYPT_CTX_ERROR, 'MONGOCRYPT_CTX_ERROR'], + [MONGOCRYPT_CTX_NEED_MONGO_COLLINFO, 'MONGOCRYPT_CTX_NEED_MONGO_COLLINFO'], + [MONGOCRYPT_CTX_NEED_MONGO_MARKINGS, 'MONGOCRYPT_CTX_NEED_MONGO_MARKINGS'], + [MONGOCRYPT_CTX_NEED_MONGO_KEYS, 'MONGOCRYPT_CTX_NEED_MONGO_KEYS'], + [MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS, 'MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS'], + [MONGOCRYPT_CTX_NEED_KMS, 'MONGOCRYPT_CTX_NEED_KMS'], + [MONGOCRYPT_CTX_READY, 'MONGOCRYPT_CTX_READY'], + [MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE'] +]); + +const INSECURE_TLS_OPTIONS = [ + 'tlsInsecure', + 'tlsAllowInvalidCertificates', + 'tlsAllowInvalidHostnames', + 'tlsDisableOCSPEndpointCheck', + 'tlsDisableCertificateRevocationCheck' +]; + +/** + * @ignore + * @callback StateMachine~executeCallback + * @param {Error} [err] If present, indicates that the execute call failed with the given error + * @param {object} [result] If present, is the result of executing the state machine. + * @returns {void} + */ + +/** + * @ignore + * @callback StateMachine~fetchCollectionInfoCallback + * @param {Error} [err] If present, indicates that fetching the collection info failed with the given error + * @param {object} [result] If present, is the fetched collection info for the first collection to match the given filter + * @returns {void} + */ + +/** + * @ignore + * @callback StateMachine~markCommandCallback + * @param {Error} [err] If present, indicates that marking the command failed with the given error + * @param {Buffer} [result] If present, is the marked command serialized into bson + * @returns {void} + */ + +/** + * @ignore + * @callback StateMachine~fetchKeysCallback + * @param {Error} [err] If present, indicates that fetching the keys failed with the given error + * @param {object[]} [result] If present, is all the keys from the keyVault collection that matched the given filter + */ + +/** + * @ignore + * An internal class that executes across a MongoCryptContext until either + * a finishing state or an error is reached. Do not instantiate directly. + * @class StateMachine + */ +class StateMachine { + constructor(options) { + this.options = options || {}; + + this.executeAsync = promisify((autoEncrypter, context, callback) => + this.execute(autoEncrypter, context, callback) + ); + } + + /** + * @ignore + * Executes the state machine according to the specification + * @param {AutoEncrypter|ClientEncryption} autoEncrypter The JS encryption object + * @param {object} context The C++ context object returned from the bindings + * @param {StateMachine~executeCallback} callback Invoked with the result/error of executing the state machine + * @returns {void} + */ + execute(autoEncrypter, context, callback) { + const keyVaultNamespace = autoEncrypter._keyVaultNamespace; + const keyVaultClient = autoEncrypter._keyVaultClient; + const metaDataClient = autoEncrypter._metaDataClient; + const mongocryptdClient = autoEncrypter._mongocryptdClient; + const mongocryptdManager = autoEncrypter._mongocryptdManager; + + debug(`[context#${context.id}] ${stateToString.get(context.state) || context.state}`); + switch (context.state) { + case MONGOCRYPT_CTX_NEED_MONGO_COLLINFO: { + const filter = deserialize(context.nextMongoOperation()); + this.fetchCollectionInfo(metaDataClient, context.ns, filter, (err, collInfo) => { + if (err) { + return callback(err, null); + } + + if (collInfo) { + context.addMongoOperationResponse(collInfo); + } + + context.finishMongoOperation(); + this.execute(autoEncrypter, context, callback); + }); + + return; + } + + case MONGOCRYPT_CTX_NEED_MONGO_MARKINGS: { + const command = context.nextMongoOperation(); + this.markCommand(mongocryptdClient, context.ns, command, (err, markedCommand) => { + if (err) { + // If we are not bypassing spawning, then we should retry once on a MongoTimeoutError (server selection error) + if ( + err instanceof MongoNetworkTimeoutError && + mongocryptdManager && + !mongocryptdManager.bypassSpawn + ) { + mongocryptdManager.spawn(() => { + // TODO: should we be shadowing the variables here? + this.markCommand(mongocryptdClient, context.ns, command, (err, markedCommand) => { + if (err) return callback(err, null); + + context.addMongoOperationResponse(markedCommand); + context.finishMongoOperation(); + + this.execute(autoEncrypter, context, callback); + }); + }); + return; + } + return callback(err, null); + } + context.addMongoOperationResponse(markedCommand); + context.finishMongoOperation(); + + this.execute(autoEncrypter, context, callback); + }); + + return; + } + + case MONGOCRYPT_CTX_NEED_MONGO_KEYS: { + const filter = context.nextMongoOperation(); + this.fetchKeys(keyVaultClient, keyVaultNamespace, filter, (err, keys) => { + if (err) return callback(err, null); + keys.forEach(key => { + context.addMongoOperationResponse(serialize(key)); + }); + + context.finishMongoOperation(); + this.execute(autoEncrypter, context, callback); + }); + + return; + } + + case MONGOCRYPT_CTX_NEED_KMS_CREDENTIALS: { + autoEncrypter + .askForKMSCredentials() + .then(kmsProviders => { + context.provideKMSProviders( + !Buffer.isBuffer(kmsProviders) ? serialize(kmsProviders) : kmsProviders + ); + this.execute(autoEncrypter, context, callback); + }) + .catch(err => { + callback(err, null); + }); + + return; + } + + case MONGOCRYPT_CTX_NEED_KMS: { + const promises = []; + + let request; + while ((request = context.nextKMSRequest())) { + promises.push(this.kmsRequest(request)); + } + + Promise.all(promises) + .then(() => { + context.finishKMSRequests(); + this.execute(autoEncrypter, context, callback); + }) + .catch(err => { + callback(err, null); + }); + + return; + } + + // terminal states + case MONGOCRYPT_CTX_READY: { + const finalizedContext = context.finalize(); + // TODO: Maybe rework the logic here so that instead of doing + // the callback here, finalize stores the result, and then + // we wait to MONGOCRYPT_CTX_DONE to do the callback + if (context.state === MONGOCRYPT_CTX_ERROR) { + const message = context.status.message || 'Finalization error'; + callback(new MongoCryptError(message)); + return; + } + callback(null, deserialize(finalizedContext, this.options)); + return; + } + case MONGOCRYPT_CTX_ERROR: { + const message = context.status.message; + callback(new MongoCryptError(message)); + return; + } + + case MONGOCRYPT_CTX_DONE: + callback(); + return; + + default: + callback(new MongoCryptError(`Unknown state: ${context.state}`)); + return; + } + } + + /** + * @ignore + * Handles the request to the KMS service. Exposed for testing purposes. Do not directly invoke. + * @param {*} kmsContext A C++ KMS context returned from the bindings + * @returns {Promise} A promise that resolves when the KMS reply has be fully parsed + */ + kmsRequest(request) { + const parsedUrl = request.endpoint.split(':'); + const port = parsedUrl[1] != null ? Number.parseInt(parsedUrl[1], 10) : HTTPS_PORT; + const options = { host: parsedUrl[0], servername: parsedUrl[0], port }; + const message = request.message; + + // TODO(NODE-3959): We can adopt `for-await on(socket, 'data')` with logic to control abort + // eslint-disable-next-line no-async-promise-executor + return new Promise(async (resolve, reject) => { + const buffer = new BufferPool(); + + let socket; + let rawSocket; + + function destroySockets() { + for (const sock of [socket, rawSocket]) { + if (sock) { + sock.removeAllListeners(); + sock.destroy(); + } + } + } + + function ontimeout() { + destroySockets(); + reject(new MongoCryptError('KMS request timed out')); + } + + function onerror(err) { + destroySockets(); + const mcError = new MongoCryptError('KMS request failed'); + mcError.originalError = err; + reject(mcError); + } + + if (this.options.proxyOptions && this.options.proxyOptions.proxyHost) { + rawSocket = net.connect({ + host: this.options.proxyOptions.proxyHost, + port: this.options.proxyOptions.proxyPort || 1080 + }); + + rawSocket.on('timeout', ontimeout); + rawSocket.on('error', onerror); + try { + await once(rawSocket, 'connect'); + options.socket = ( + await SocksClient.createConnection({ + existing_socket: rawSocket, + command: 'connect', + destination: { host: options.host, port: options.port }, + proxy: { + // host and port are ignored because we pass existing_socket + host: 'iLoveJavaScript', + port: 0, + type: 5, + userId: this.options.proxyOptions.proxyUsername, + password: this.options.proxyOptions.proxyPassword + } + }) + ).socket; + } catch (err) { + return onerror(err); + } + } + + const tlsOptions = this.options.tlsOptions; + if (tlsOptions) { + const kmsProvider = request.kmsProvider; + const providerTlsOptions = tlsOptions[kmsProvider]; + if (providerTlsOptions) { + const error = this.validateTlsOptions(kmsProvider, providerTlsOptions); + if (error) reject(error); + this.setTlsOptions(providerTlsOptions, options); + } + } + socket = tls.connect(options, () => { + socket.write(message); + }); + + socket.once('timeout', ontimeout); + socket.once('error', onerror); + + socket.on('data', data => { + buffer.append(data); + while (request.bytesNeeded > 0 && buffer.length) { + const bytesNeeded = Math.min(request.bytesNeeded, buffer.length); + request.addResponse(buffer.read(bytesNeeded)); + } + + if (request.bytesNeeded <= 0) { + // There's no need for any more activity on this socket at this point. + destroySockets(); + resolve(); + } + }); + }); + } + + /** + * @ignore + * Validates the provided TLS options are secure. + * + * @param {string} kmsProvider The KMS provider name. + * @param {ClientEncryptionTLSOptions} tlsOptions The client TLS options for the provider. + * + * @returns {Error} If any option is invalid. + */ + validateTlsOptions(kmsProvider, tlsOptions) { + const tlsOptionNames = Object.keys(tlsOptions); + for (const option of INSECURE_TLS_OPTIONS) { + if (tlsOptionNames.includes(option)) { + return new MongoCryptError( + `Insecure TLS options prohibited for ${kmsProvider}: ${option}` + ); + } + } + } + + /** + * @ignore + * Sets only the valid secure TLS options. + * + * @param {ClientEncryptionTLSOptions} tlsOptions The client TLS options for the provider. + * @param {Object} options The existing connection options. + */ + setTlsOptions(tlsOptions, options) { + if (tlsOptions.tlsCertificateKeyFile) { + const cert = fs.readFileSync(tlsOptions.tlsCertificateKeyFile); + options.cert = options.key = cert; + } + if (tlsOptions.tlsCAFile) { + options.ca = fs.readFileSync(tlsOptions.tlsCAFile); + } + if (tlsOptions.tlsCertificateKeyFilePassword) { + options.passphrase = tlsOptions.tlsCertificateKeyFilePassword; + } + } + + /** + * @ignore + * Fetches collection info for a provided namespace, when libmongocrypt + * enters the `MONGOCRYPT_CTX_NEED_MONGO_COLLINFO` state. The result is + * used to inform libmongocrypt of the schema associated with this + * namespace. Exposed for testing purposes. Do not directly invoke. + * + * @param {MongoClient} client A MongoClient connected to the topology + * @param {string} ns The namespace to list collections from + * @param {object} filter A filter for the listCollections command + * @param {StateMachine~fetchCollectionInfoCallback} callback Invoked with the info of the requested collection, or with an error + */ + fetchCollectionInfo(client, ns, filter, callback) { + const dbName = databaseNamespace(ns); + + client + .db(dbName) + .listCollections(filter, { + promoteLongs: false, + promoteValues: false + }) + .toArray() + .then( + collections => { + const info = collections.length > 0 ? serialize(collections[0]) : null; + return callback(null, info); + }, + err => { + callback(err, null); + } + ); + } + + /** + * @ignore + * Calls to the mongocryptd to provide markings for a command. + * Exposed for testing purposes. Do not directly invoke. + * @param {MongoClient} client A MongoClient connected to a mongocryptd + * @param {string} ns The namespace (database.collection) the command is being executed on + * @param {object} command The command to execute. + * @param {StateMachine~markCommandCallback} callback Invoked with the serialized and marked bson command, or with an error + * @returns {void} + */ + markCommand(client, ns, command, callback) { + const options = { promoteLongs: false, promoteValues: false }; + const dbName = databaseNamespace(ns); + const rawCommand = deserialize(command, options); + + client + .db(dbName) + .command(rawCommand, options) + .then( + response => { + return callback(null, serialize(response, this.options)); + }, + err => { + callback(err, null); + } + ); + } + + /** + * @ignore + * Requests keys from the keyVault collection on the topology. + * Exposed for testing purposes. Do not directly invoke. + * @param {MongoClient} client A MongoClient connected to the topology + * @param {string} keyVaultNamespace The namespace (database.collection) of the keyVault Collection + * @param {object} filter The filter for the find query against the keyVault Collection + * @param {StateMachine~fetchKeysCallback} callback Invoked with the found keys, or with an error + * @returns {void} + */ + fetchKeys(client, keyVaultNamespace, filter, callback) { + const dbName = databaseNamespace(keyVaultNamespace); + const collectionName = collectionNamespace(keyVaultNamespace); + filter = deserialize(filter); + + client + .db(dbName) + .collection(collectionName, { readConcern: { level: 'majority' } }) + .find(filter) + .toArray() + .then( + keys => { + return callback(null, keys); + }, + err => { + callback(err, null); + } + ); + } +} + +module.exports = { StateMachine }; diff --git a/src/cmap/connection.ts b/src/cmap/connection.ts index da3c6a4beb0..539309e3898 100644 --- a/src/cmap/connection.ts +++ b/src/cmap/connection.ts @@ -2,6 +2,7 @@ import { clearTimeout, setTimeout } from 'timers'; import { promisify } from 'util'; import type { BSONSerializeOptions, Document, ObjectId } from '../bson'; +import type { AutoEncrypter } from '../client-side-encryption/autoEncrypter'; import { CLOSE, CLUSTER_TIME_RECEIVED, @@ -12,7 +13,6 @@ import { PINNED, UNPINNED } from '../constants'; -import type { AutoEncrypter } from '../deps'; import { MongoCompatibilityError, MongoMissingDependencyError, @@ -113,7 +113,7 @@ export interface ConnectionOptions id: number | ''; generation: number; hostAddress: HostAddress; - // Settings + /** @internal */ autoEncrypter?: AutoEncrypter; serverApi?: ServerApi; monitorCommands: boolean; @@ -602,6 +602,8 @@ export class CryptoConnection extends Connection { ? cmd.indexes.map((index: { key: Map }) => index.key) : null; + // TODO(NODE-5422): add typescript support + // @ts-expect-error no typescript support yet autoEncrypter.encrypt(ns.toString(), cmd, options, (err, encrypted) => { if (err || encrypted == null) { callback(err, null); diff --git a/src/deps.ts b/src/deps.ts index 8aa3890c16c..e79e3f7fb6e 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -394,14 +394,21 @@ export interface AutoEncryptionOptions { }; } -/** @public */ -export interface AutoEncrypter { - // eslint-disable-next-line @typescript-eslint/no-misused-new - new (client: MongoClient, options: AutoEncryptionOptions): AutoEncrypter; - init(cb: Callback): void; - teardown(force: boolean, callback: Callback): void; - encrypt(ns: string, cmd: Document, options: any, callback: Callback): void; - decrypt(cmd: Document, options: any, callback: Callback): void; - /** @experimental */ - readonly cryptSharedLibVersionInfo: { version: bigint; versionStr: string } | null; +type MongoCrypt = { MongoCrypt: any }; +/** A utility function to get the instance of mongodb-client-encryption, if it exists. */ +export function getMongoDBClientEncryption(): MongoCrypt | null { + let mongodbClientEncryption = null; + + try { + // NOTE(NODE-3199): Ensure you always wrap an optional require literally in the try block + // Cannot be moved to helper utility function, bundlers search and replace the actual require call + // in a way that makes this line throw at bundle time, not runtime, catching here will make bundling succeed + mongodbClientEncryption = require('mongodb-client-encryption'); + } catch { + // ignore + } + + return mongodbClientEncryption; } + +export type MongodbClientEncryption = ReturnType; diff --git a/src/encrypter.ts b/src/encrypter.ts index 8ef92321c59..53cfebacda4 100644 --- a/src/encrypter.ts +++ b/src/encrypter.ts @@ -1,12 +1,9 @@ -/* eslint-disable @typescript-eslint/no-var-requires */ - +import { AutoEncrypter } from './client-side-encryption/autoEncrypter'; import { MONGO_CLIENT_EVENTS } from './constants'; -import type { AutoEncrypter, AutoEncryptionOptions } from './deps'; +import { type AutoEncryptionOptions, getMongoDBClientEncryption } from './deps'; import { MongoInvalidArgumentError, MongoMissingDependencyError } from './error'; import { MongoClient, type MongoClientOptions } from './mongo_client'; -import { type Callback, getMongoDBClientEncryption } from './utils'; - -let AutoEncrypterClass: { new (...args: ConstructorParameters): AutoEncrypter }; +import { type Callback } from './utils'; /** @internal */ const kInternalClient = Symbol('internalClient'); @@ -57,7 +54,7 @@ export class Encrypter { }; } - this.autoEncrypter = new AutoEncrypterClass(client, options.autoEncryption); + this.autoEncrypter = new AutoEncrypter(client, options.autoEncryption); } getInternalClient(client: MongoClient, uri: string, options: MongoClientOptions): MongoClient { @@ -105,7 +102,8 @@ export class Encrypter { } close(client: MongoClient, force: boolean, callback: Callback): void { - this.autoEncrypter.teardown(!!force, e => { + // TODO(NODE-5422): add typescript support + this.autoEncrypter.teardown(!!force, (e: any) => { const internalClient = this[kInternalClient]; if (internalClient != null && client !== internalClient) { internalClient.close(force).then( @@ -126,6 +124,5 @@ export class Encrypter { 'Please add `mongodb-client-encryption` as a dependency of your project' ); } - AutoEncrypterClass = mongodbClientEncryption.extension(require('../lib/index')).AutoEncrypter; } } diff --git a/src/index.ts b/src/index.ts index edcc43610d8..26282013f3f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -201,6 +201,8 @@ export type { ResumeToken, UpdateDescription } from './change_stream'; +export type { AutoEncrypter } from './client-side-encryption/autoEncrypter'; +export type { MongocryptdManager } from './client-side-encryption/mongocryptdManager'; export type { AuthContext } from './cmap/auth/auth_provider'; export type { AuthMechanismProperties, @@ -285,7 +287,7 @@ export type { } from './cursor/list_search_indexes_cursor'; export type { RunCursorCommandOptions } from './cursor/run_command_cursor'; export type { DbOptions, DbPrivate } from './db'; -export type { AutoEncrypter, AutoEncryptionOptions, AutoEncryptionTlsOptions } from './deps'; +export type { AutoEncryptionOptions, AutoEncryptionTlsOptions } from './deps'; export type { Encrypter, EncrypterOptions } from './encrypter'; export type { AnyError, ErrorDescription, MongoNetworkErrorOptions } from './error'; export type { Explain, ExplainOptions, ExplainVerbosityLike } from './explain'; diff --git a/src/mongo_client.ts b/src/mongo_client.ts index 3b95fd85239..dec5a9e7d2a 100644 --- a/src/mongo_client.ts +++ b/src/mongo_client.ts @@ -4,6 +4,7 @@ import { promisify } from 'util'; import { type BSONSerializeOptions, type Document, resolveBSONOptions } from './bson'; import { ChangeStream, type ChangeStreamDocument, type ChangeStreamOptions } from './change_stream'; +import { type AutoEncrypter } from './client-side-encryption/autoEncrypter'; import { type AuthMechanismProperties, DEFAULT_ALLOWED_HOSTS, @@ -17,7 +18,7 @@ import type { CompressorName } from './cmap/wire_protocol/compression'; import { parseOptions, resolveSRVRecord } from './connection_string'; import { MONGO_CLIENT_EVENTS } from './constants'; import { Db, type DbOptions } from './db'; -import type { AutoEncrypter, AutoEncryptionOptions } from './deps'; +import type { AutoEncryptionOptions } from './deps'; import type { Encrypter } from './encrypter'; import { MongoInvalidArgumentError } from './error'; import { MongoLogger, type MongoLoggerOptions } from './mongo_logger'; @@ -382,6 +383,7 @@ export class MongoClient extends TypedEventEmitter { this[kOptions].monitorCommands = value; } + /** @internal */ get autoEncrypter(): AutoEncrypter | undefined { return this[kOptions].autoEncrypter; } @@ -745,6 +747,7 @@ export interface MongoOptions writeConcern: WriteConcern; dbName: string; metadata: ClientMetadata; + /** @internal */ autoEncrypter?: AutoEncrypter; proxyHost?: string; proxyPort?: number; diff --git a/src/sdam/server.ts b/src/sdam/server.ts index f76f8c9a8c0..c67ec609274 100644 --- a/src/sdam/server.ts +++ b/src/sdam/server.ts @@ -1,6 +1,7 @@ import { promisify } from 'util'; import type { Document } from '../bson'; +import { type AutoEncrypter } from '../client-side-encryption/autoEncrypter'; import { type CommandOptions, Connection, type DestroyOptions } from '../cmap/connection'; import { ConnectionPool, @@ -20,7 +21,6 @@ import { SERVER_HEARTBEAT_STARTED, SERVER_HEARTBEAT_SUCCEEDED } from '../constants'; -import type { AutoEncrypter } from '../deps'; import { type AnyError, isNetworkErrorBeforeHandshake, diff --git a/src/utils.ts b/src/utils.ts index 505f3bfd1d5..6dd512d1cf2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1172,43 +1172,6 @@ export function commandSupportsReadConcern(command: Document, options?: Document return false; } -/** A utility function to get the instance of mongodb-client-encryption, if it exists. */ -export function getMongoDBClientEncryption(): { - extension: (mdb: unknown) => { - AutoEncrypter: any; - ClientEncryption: any; - }; -} | null { - let mongodbClientEncryption = null; - - // NOTE(NODE-4254): This is to get around the circular dependency between - // mongodb-client-encryption and the driver in the test scenarios. - if ( - typeof process.env.MONGODB_CLIENT_ENCRYPTION_OVERRIDE === 'string' && - process.env.MONGODB_CLIENT_ENCRYPTION_OVERRIDE.length > 0 - ) { - try { - // NOTE(NODE-3199): Ensure you always wrap an optional require literally in the try block - // Cannot be moved to helper utility function, bundlers search and replace the actual require call - // in a way that makes this line throw at bundle time, not runtime, catching here will make bundling succeed - mongodbClientEncryption = require(process.env.MONGODB_CLIENT_ENCRYPTION_OVERRIDE); - } catch { - // ignore - } - } else { - try { - // NOTE(NODE-3199): Ensure you always wrap an optional require literally in the try block - // Cannot be moved to helper utility function, bundlers search and replace the actual require call - // in a way that makes this line throw at bundle time, not runtime, catching here will make bundling succeed - mongodbClientEncryption = require('mongodb-client-encryption'); - } catch { - // ignore - } - } - - return mongodbClientEncryption; -} - /** * Compare objectIds. `null` is always less * - `+1 = oid1 is greater than oid2` diff --git a/test/action/dependency.test.ts b/test/action/dependency.test.ts index 7035124fb5a..c04a3f8cbc7 100644 --- a/test/action/dependency.test.ts +++ b/test/action/dependency.test.ts @@ -13,7 +13,8 @@ const EXPECTED_PEER_DEPENDENCIES = [ '@mongodb-js/zstd', 'kerberos', 'snappy', - 'mongodb-client-encryption' + 'mongodb-client-encryption', + 'gcp-metadata' ]; describe('package.json', function () { diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.06.corpus.test.js b/test/integration/client-side-encryption/client_side_encryption.prose.06.corpus.test.js index 2e854818cd4..f837cdd9f8e 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.06.corpus.test.js +++ b/test/integration/client-side-encryption/client_side_encryption.prose.06.corpus.test.js @@ -9,6 +9,8 @@ const { EJSON } = require('bson'); const { expect } = require('chai'); const { getEncryptExtraOptions } = require('../../tools/utils'); const { installNodeDNSWorkaroundHooks } = require('../../tools/runner/hooks/configuration'); +// eslint-disable-next-line no-restricted-modules +const { ClientEncryption } = require('../../../src/client-side-encryption/clientEncryption'); describe('Client Side Encryption Prose Corpus Test', function () { const metadata = { @@ -184,7 +186,6 @@ describe('Client Side Encryption Prose Corpus Test', function () { function defineCorpusTests(corpus, corpusEncryptedExpected, useClientSideSchema) { let clientEncrypted, clientEncryption; beforeEach(function () { - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; return Promise.resolve() .then(() => { // 2. Using ``client``, drop and create the collection ``db.coll`` configured with the included JSON schema `corpus/corpus-schema.json <../corpus/corpus-schema.json>`_. @@ -233,7 +234,7 @@ describe('Client Side Encryption Prose Corpus Test', function () { clientEncrypted = this.configuration.newClient({}, { autoEncryption }); return clientEncrypted.connect().then(() => { - clientEncryption = new mongodbClientEncryption.ClientEncryption(client, { + clientEncryption = new ClientEncryption(client, { bson: BSON, keyVaultNamespace, kmsProviders, diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.12.deadlock.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.12.deadlock.test.ts index 3cee0b1a7f9..77a7dafa3c0 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.12.deadlock.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.12.deadlock.test.ts @@ -4,6 +4,8 @@ import { readFileSync } from 'fs'; import * as path from 'path'; import * as util from 'util'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; import { type CommandStartedEvent, MongoClient, type MongoClientOptions } from '../../mongodb'; import { installNodeDNSWorkaroundHooks } from '../../tools/runner/hooks/configuration'; import { getEncryptExtraOptions } from '../../tools/utils'; @@ -105,7 +107,6 @@ const metadata = { describe('Connection Pool Deadlock Prevention', function () { installNodeDNSWorkaroundHooks(); beforeEach(async function () { - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; const url: string = this.configuration.url(); this.clientTest = new CapturingMongoClient(url); @@ -131,7 +132,7 @@ describe('Connection Pool Deadlock Prevention', function () { await this.clientTest.db('db').createCollection('coll', { validator: { $jsonSchema } }); - this.clientEncryption = new mongodbClientEncryption.ClientEncryption(this.clientTest, { + this.clientEncryption = new ClientEncryption(this.clientTest, { kmsProviders: { local: { key: LOCAL_KEY } }, keyVaultNamespace: 'keyvault.datakeys', keyVaultClient: this.keyVaultClient, diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.14.decryption_events.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.14.decryption_events.test.ts index c80f0ef61db..46a01c2a6bd 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.14.decryption_events.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.14.decryption_events.test.ts @@ -1,5 +1,7 @@ import { expect } from 'chai'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; import { Binary, BSON, @@ -37,7 +39,6 @@ describe('14. Decryption Events', metadata, function () { let aggregateFailed: CommandFailedEvent | undefined; beforeEach(async function () { - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; // Create a MongoClient named ``setupClient``. setupClient = this.configuration.newClient(); // Drop and create the collection ``db.decryption_events``. @@ -55,7 +56,7 @@ describe('14. Decryption Events', metadata, function () { // keyVaultNamespace: "keyvault.datakeys", // kmsProviders: { "local": { "key": } } // } - clientEncryption = new mongodbClientEncryption.ClientEncryption(setupClient, { + clientEncryption = new ClientEncryption(setupClient, { keyVaultNamespace: 'keyvault.datakeys', kmsProviders: { local: { key: LOCAL_KEY } }, bson: BSON, diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.17.on_demand_gcp.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.17.on_demand_gcp.test.ts index b3b3796e0ff..5d2df93a11f 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.17.on_demand_gcp.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.17.on_demand_gcp.test.ts @@ -1,6 +1,8 @@ import { expect } from 'chai'; import { env } from 'process'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; import { Binary } from '../../mongodb'; const metadata: MongoDBMetadataUI = { @@ -25,8 +27,6 @@ describe('17. On-demand GCP Credentials', () => { beforeEach(async function () { keyVaultClient = this.configuration.newClient(); - const { ClientEncryption } = this.configuration.mongodbClientEncryption; - if (typeof env.GCPKMS_GCLOUD === 'string') { // If Google cloud env is present then EXPECTED_GCPKMS_OUTCOME MUST be set expect(env.EXPECTED_GCPKMS_OUTCOME, `EXPECTED_GCPKMS_OUTCOME must be 'success' or 'failure'`) diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.18.azure_kms_mock_server.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.18.azure_kms_mock_server.test.ts index 2c3ed2fdd8b..86f2ac216a3 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.18.azure_kms_mock_server.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.18.azure_kms_mock_server.test.ts @@ -1,5 +1,9 @@ import { expect } from 'chai'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { MongoCryptAzureKMSRequestError } from '../../../src/client-side-encryption/errors'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { fetchAzureKMSToken } from '../../../src/client-side-encryption/providers/azure'; import { type Document } from '../../mongodb'; const BASE_URL = new URL(`http://127.0.0.1:8080/metadata/identity/oauth2/token`); @@ -24,18 +28,6 @@ const metadata: MongoDBMetadataUI = { }; context('Azure KMS Mock Server Tests', function () { - let fetchAzureKMSToken: (options: { - url: URL; - headers: Document; - }) => Promise<{ accessToken: string }>; - let MongoCryptAzureKMSRequestError; - - beforeEach(async function () { - fetchAzureKMSToken = this.configuration.mongodbClientEncryption['___azureKMSProseTestExports']; - MongoCryptAzureKMSRequestError = - this.configuration.mongodbClientEncryption.MongoCryptAzureKMSRequestError; - }); - context('Case 1: Success', metadata, function () { // Do not set an ``X-MongoDB-HTTP-TestParams`` header. diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.19.on_demand_azure.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.19.on_demand_azure.test.ts index 4c70172f2e2..2ba17f7f6cd 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.19.on_demand_azure.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.19.on_demand_azure.test.ts @@ -1,6 +1,10 @@ import { expect } from 'chai'; import { env } from 'process'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { MongoCryptAzureKMSRequestError } from '../../../src/client-side-encryption/errors'; import { Binary } from '../../mongodb'; const metadata: MongoDBMetadataUI = { @@ -17,17 +21,12 @@ const dataKeyOptions = { }; describe('19. On-demand Azure Credentials', () => { - let clientEncryption: import('mongodb-client-encryption').ClientEncryption; + let clientEncryption; let keyVaultClient; - let MongoCryptAzureKMSRequestError; beforeEach(async function () { keyVaultClient = this.configuration.newClient(); - const { ClientEncryption } = this.configuration.mongodbClientEncryption; - MongoCryptAzureKMSRequestError = - this.configuration.mongodbClientEncryption.MongoCryptAzureKMSRequestError; - if (typeof env.AZUREKMS_VMNAME === 'string') { // If azure cloud env is present then EXPECTED_AZUREKMS_OUTCOME MUST be set expect( diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.21.automatic_data_encryption_keys.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.21.automatic_data_encryption_keys.test.ts index 3f94687ac49..493406e15ed 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.21.automatic_data_encryption_keys.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.21.automatic_data_encryption_keys.test.ts @@ -1,5 +1,9 @@ import { expect } from 'chai'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { MongoCryptCreateEncryptedCollectionError } from '../../../src/client-side-encryption/errors'; import { BSON, Collection, type Db, MongoServerError } from '../../mongodb'; import { installNodeDNSWorkaroundHooks } from '../../tools/runner/hooks/configuration'; @@ -20,7 +24,6 @@ describe('21. Automatic Data Encryption Keys', () => { let db: Db; let clientEncryption; let client; - let MongoCryptCreateEncryptedCollectionError; const runProseTestsFor = provider => { const masterKey = { @@ -32,11 +35,6 @@ describe('21. Automatic Data Encryption Keys', () => { }[provider]; beforeEach(async function () { client = this.configuration.newClient(); - const { - ClientEncryption, - MongoCryptCreateEncryptedCollectionError: MongoCryptCreateEncryptedCollectionErrorCtor - } = this.configuration.mongodbClientEncryption; - MongoCryptCreateEncryptedCollectionError = MongoCryptCreateEncryptedCollectionErrorCtor; if (typeof process.env.CSFLE_KMS_PROVIDERS !== 'string') { if (this.currentTest) { diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.22.range_explicit_encryption.test.ts b/test/integration/client-side-encryption/client_side_encryption.prose.22.range_explicit_encryption.test.ts index 5efd8680b66..b111becaf86 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.22.range_explicit_encryption.test.ts +++ b/test/integration/client-side-encryption/client_side_encryption.prose.22.range_explicit_encryption.test.ts @@ -4,6 +4,10 @@ import { readFile } from 'fs/promises'; import { join } from 'path'; import { Decimal128, type Document, Double, Long, type MongoClient } from '../../../src'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { MongoCryptError } from '../../../src/client-side-encryption/errors'; import { installNodeDNSWorkaroundHooks } from '../../tools/runner/hooks/configuration'; const getKmsProviders = () => { @@ -143,7 +147,6 @@ describe('Range Explicit Encryption', function () { expect(value).to.deep.equal(factory(expected)); } }; - const ClientEncryption = this.configuration.mongodbClientEncryption.ClientEncryption; const keyDocument1 = EJSON.parse( await readFile(join(__dirname, '../../../', basePath, 'keys', `key1-document.json`), { encoding: 'utf8' @@ -459,9 +462,7 @@ describe('Range Explicit Encryption', function () { }) .catch(e => e); - expect(resultOrError).to.be.instanceOf( - this.configuration.mongodbClientEncryption.MongoCryptError - ); + expect(resultOrError).to.be.instanceOf(MongoCryptError); } ); @@ -488,9 +489,7 @@ describe('Range Explicit Encryption', function () { }) .catch(e => e); - expect(resultOrError).to.be.instanceOf( - this.configuration.mongodbClientEncryption.MongoCryptError - ); + expect(resultOrError).to.be.instanceOf(MongoCryptError); }); it( diff --git a/test/integration/client-side-encryption/client_side_encryption.prose.test.js b/test/integration/client-side-encryption/client_side_encryption.prose.test.js index a568918a176..667e1dabd50 100644 --- a/test/integration/client-side-encryption/client_side_encryption.prose.test.js +++ b/test/integration/client-side-encryption/client_side_encryption.prose.test.js @@ -16,6 +16,8 @@ const { coerce, gte } = require('semver'); const { externalSchema } = require('../../spec/client-side-encryption/external/external-schema.json'); +/* eslint-disable no-restricted-modules */ +const { ClientEncryption } = require('../../../src/client-side-encryption/clientEncryption'); const getKmsProviders = (localKey, kmipEndpoint, azureEndpoint, gcpEndpoint) => { const result = BSON.EJSON.parse(process.env.CSFLE_KMS_PROVIDERS || '{}'); @@ -81,8 +83,6 @@ describe('Client Side Encryption Prose Tests', metadata, function () { // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // First, perform the setup. beforeEach(function () { - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; - // 1. Create a MongoClient without encryption enabled (referred to as ``client``). Enable command monitoring to listen for command_started events. this.client = this.configuration.newClient({}, { monitorCommands: true }); @@ -139,7 +139,7 @@ describe('Client Side Encryption Prose Tests', metadata, function () { // } // Configure ``client_encryption`` with the ``keyVaultClient`` of the previously created ``client``. .then(() => { - this.clientEncryption = new mongodbClientEncryption.ClientEncryption(this.client, { + this.clientEncryption = new ClientEncryption(this.client, { kmsProviders: getKmsProviders(), keyVaultNamespace, extraOptions: getEncryptExtraOptions() @@ -398,7 +398,6 @@ describe('Client Side Encryption Prose Tests', metadata, function () { `should work ${withExternalKeyVault ? 'with' : 'without'} external key vault`, metadata, function () { - const ClientEncryption = this.configuration.mongodbClientEncryption.ClientEncryption; return ( Promise.resolve() .then(() => { @@ -858,8 +857,7 @@ describe('Client Side Encryption Prose Tests', metadata, function () { invalidKmsProviders.kmip.endpoint = 'doesnotexist.local:5698'; return this.client.connect().then(() => { - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; - this.clientEncryption = new mongodbClientEncryption.ClientEncryption(this.client, { + this.clientEncryption = new ClientEncryption(this.client, { bson: BSON, keyVaultNamespace, kmsProviders: customKmsProviders, @@ -872,7 +870,7 @@ describe('Client Side Encryption Prose Tests', metadata, function () { extraOptions: getEncryptExtraOptions() }); - this.clientEncryptionInvalid = new mongodbClientEncryption.ClientEncryption(this.client, { + this.clientEncryptionInvalid = new ClientEncryption(this.client, { keyVaultNamespace, kmsProviders: invalidKmsProviders, tlsOptions: { @@ -1184,16 +1182,7 @@ describe('Client Side Encryption Prose Tests', metadata, function () { !!error.cause?.cause?.errors?.every(e => e.code === 'ECONNREFUSED') ); - expect(insertError).not.to.be.instanceOf( - MongoServerSelectionError, - ` - -TODO(NODE-5283): The error thrown in this test fails an instanceof check with MongoServerSelectionError. - This should change after NODE-5283. If this assertion is failing, then the test - should be updated to reflect that the error thrown is now a server selection error. - -` - ); + expect(insertError).to.be.instanceOf(MongoServerSelectionError); }); }); @@ -1440,12 +1429,11 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo tlsOptions: tlsCaOptions, extraOptions: getEncryptExtraOptions() }; - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; switch (this.currentTest.title) { case 'should fail with no TLS': clientNoTls = this.configuration.newClient({}, { autoEncryption: clientNoTlsOptions }); - clientEncryptionNoTls = new mongodbClientEncryption.ClientEncryption(clientNoTls, { + clientEncryptionNoTls = new ClientEncryption(clientNoTls, { ...clientNoTlsOptions, bson: BSON }); @@ -1456,7 +1444,7 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo {}, { autoEncryption: clientWithTlsOptions } ); - clientEncryptionWithTls = new mongodbClientEncryption.ClientEncryption(clientWithTls, { + clientEncryptionWithTls = new ClientEncryption(clientWithTls, { ...clientWithTlsOptions, bson: BSON }); @@ -1467,10 +1455,10 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo {}, { autoEncryption: clientWithTlsExpiredOptions } ); - clientEncryptionWithTlsExpired = new mongodbClientEncryption.ClientEncryption( - clientWithTlsExpired, - { ...clientWithTlsExpiredOptions, bson: BSON } - ); + clientEncryptionWithTlsExpired = new ClientEncryption(clientWithTlsExpired, { + ...clientWithTlsExpiredOptions, + bson: BSON + }); await clientWithTlsExpired.connect(); break; case 'should fail with an invalid hostname': @@ -1478,10 +1466,10 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo {}, { autoEncryption: clientWithInvalidHostnameOptions } ); - clientEncryptionWithInvalidHostname = new mongodbClientEncryption.ClientEncryption( - clientWithInvalidHostname, - { ...clientWithInvalidHostnameOptions, bson: BSON } - ); + clientEncryptionWithInvalidHostname = new ClientEncryption(clientWithInvalidHostname, { + ...clientWithInvalidHostnameOptions, + bson: BSON + }); await clientWithInvalidHostname.connect(); break; default: @@ -1716,7 +1704,6 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo let encryptedClient; beforeEach(async function () { - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; // Load the file encryptedFields.json as encryptedFields. encryptedFields = EJSON.parse( await fs.promises.readFile(path.join(data, 'encryptedFields.json')), @@ -1748,7 +1735,7 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo // keyVaultNamespace: "keyvault.datakeys"; // kmsProviders: { "local": { "key": } } // } - clientEncryption = new mongodbClientEncryption.ClientEncryption(keyVaultClient, { + clientEncryption = new ClientEncryption(keyVaultClient, { keyVaultNamespace: 'keyvault.datakeys', kmsProviders: getKmsProviders(LOCAL_KEY), bson: BSON, @@ -2023,7 +2010,7 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo ); // Create a ClientEncryption object (referred to as client_encryption) with client set as the keyVaultClient. - clientEncryption = new this.configuration.mongodbClientEncryption.ClientEncryption(client, { + clientEncryption = new ClientEncryption(client, { keyVaultNamespace: 'keyvault.datakeys', kmsProviders: getKmsProviders(), extraOptions: getEncryptExtraOptions() @@ -2158,19 +2145,18 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo .catch(() => null); // Step 2. Create a ``ClientEncryption`` object named ``clientEncryption1`` - const clientEncryption1 = - new this.configuration.mongodbClientEncryption.ClientEncryption(client1, { - keyVaultNamespace: 'keyvault.datakeys', - kmsProviders: getKmsProviders(), - tlsOptions: { - kmip: { - tlsCAFile: process.env.KMIP_TLS_CA_FILE, - tlsCertificateKeyFile: process.env.KMIP_TLS_CERT_FILE - } - }, - extraOptions: getEncryptExtraOptions(), - bson: BSON - }); + const clientEncryption1 = new ClientEncryption(client1, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: getKmsProviders(), + tlsOptions: { + kmip: { + tlsCAFile: process.env.KMIP_TLS_CA_FILE, + tlsCertificateKeyFile: process.env.KMIP_TLS_CERT_FILE + } + }, + extraOptions: getEncryptExtraOptions(), + bson: BSON + }); // Step 3. Call ``clientEncryption1.createDataKey`` with ``srcProvider`` const keyId = await clientEncryption1.createDataKey(srcProvider, { @@ -2184,19 +2170,18 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo }); // Step 5. Create a ``ClientEncryption`` object named ``clientEncryption2`` - const clientEncryption2 = - new this.configuration.mongodbClientEncryption.ClientEncryption(client2, { - keyVaultNamespace: 'keyvault.datakeys', - kmsProviders: getKmsProviders(), - tlsOptions: { - kmip: { - tlsCAFile: process.env.KMIP_TLS_CA_FILE, - tlsCertificateKeyFile: process.env.KMIP_TLS_CERT_FILE - } - }, - extraOptions: getEncryptExtraOptions(), - bson: BSON - }); + const clientEncryption2 = new ClientEncryption(client2, { + keyVaultNamespace: 'keyvault.datakeys', + kmsProviders: getKmsProviders(), + tlsOptions: { + kmip: { + tlsCAFile: process.env.KMIP_TLS_CA_FILE, + tlsCertificateKeyFile: process.env.KMIP_TLS_CERT_FILE + } + }, + extraOptions: getEncryptExtraOptions(), + bson: BSON + }); // Step 6. Call ``clientEncryption2.rewrapManyDataKey`` with an empty ``filter`` const rewrapManyDataKeyResult = await clientEncryption2.rewrapManyDataKey( @@ -2233,7 +2218,7 @@ TODO(NODE-5283): The error thrown in this test fails an instanceof check with Mo // kmsProviders: , before(function () { client = this.configuration.newClient(); - clientEncryption = new this.configuration.mongodbClientEncryption.ClientEncryption(client, { + clientEncryption = new ClientEncryption(client, { keyVaultNamespace: 'keyvault.datakeys', kmsProviders: getKmsProviders(), extraOptions: getEncryptExtraOptions(), diff --git a/test/integration/client-side-encryption/driver.test.ts b/test/integration/client-side-encryption/driver.test.ts index 7a08a0967b6..c01ae18a97f 100644 --- a/test/integration/client-side-encryption/driver.test.ts +++ b/test/integration/client-side-encryption/driver.test.ts @@ -2,10 +2,11 @@ import { EJSON, UUID } from 'bson'; import { expect } from 'chai'; import * as crypto from 'crypto'; +// eslint-disable-next-line @typescript-eslint/no-restricted-imports +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; import { type Collection, type CommandStartedEvent, type MongoClient } from '../../mongodb'; import * as BSON from '../../mongodb'; import { installNodeDNSWorkaroundHooks } from '../../tools/runner/hooks/configuration'; -import { type ClientEncryption } from '../../tools/unified-spec-runner/schema'; import { getEncryptExtraOptions } from '../../tools/utils'; const metadata = { @@ -127,12 +128,11 @@ describe('Client Side Encryption Functional', function () { } }); - const mongodbClientEncryption = this.configuration.mongodbClientEncryption; const kmsProviders = this.configuration.kmsProviders(crypto.randomBytes(96)); await client.connect(); - const encryption: ClientEncryption = new mongodbClientEncryption.ClientEncryption(client, { + const encryption = new ClientEncryption(client, { bson: BSON, keyVaultNamespace, kmsProviders, diff --git a/test/integration/node-specific/auto_encrypter.test.ts b/test/integration/node-specific/auto_encrypter.test.ts new file mode 100644 index 00000000000..0ba023ed34a --- /dev/null +++ b/test/integration/node-specific/auto_encrypter.test.ts @@ -0,0 +1,429 @@ +import { expect } from 'chai'; +import { spawnSync } from 'child_process'; +import * as fs from 'fs'; +import { dirname, resolve } from 'path'; +import * as sinon from 'sinon'; +import { promisify } from 'util'; + +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { AutoEncrypter } from '../../../src/client-side-encryption/autoEncrypter'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { MongocryptdManager } from '../../../src/client-side-encryption/mongocryptdManager'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { StateMachine } from '../../../src/client-side-encryption/stateMachine'; +import { BSON, deserialize, serialize } from '../../mongodb'; +import { type MongoClient, MongoError, MongoNetworkTimeoutError } from '../../mongodb'; +import { getEncryptExtraOptions } from '../../tools/utils'; + +const { EJSON } = BSON; +const cryptShared = (status: 'enabled' | 'disabled') => () => { + const isPathPresent = (getEncryptExtraOptions().cryptSharedLibPath ?? '').length > 0; + + if (status === 'enabled') { + return isPathPresent ? true : 'Test requires the shared library.'; + } + + return isPathPresent ? 'Test requires that the crypt shared library NOT be present' : true; +}; + +const dataPath = (fileName: string) => + resolve(__dirname, '../../unit/client-side-encryption/data', fileName); + +function readExtendedJsonToBuffer(path) { + const ejson = EJSON.parse(fs.readFileSync(path, 'utf8')); + return serialize(ejson); +} + +function readHttpResponse(path) { + let data = fs.readFileSync(path, 'utf8'); + data = data.split('\n').join('\r\n'); + return Buffer.from(data, 'utf8'); +} + +const TEST_COMMAND = JSON.parse(fs.readFileSync(dataPath(`cmd.json`), { encoding: 'utf-8' })); +const MOCK_COLLINFO_RESPONSE = readExtendedJsonToBuffer(dataPath(`collection-info.json`)); +const MOCK_MONGOCRYPTD_RESPONSE = readExtendedJsonToBuffer(dataPath(`mongocryptd-reply.json`)); +const MOCK_KEYDOCUMENT_RESPONSE = readExtendedJsonToBuffer(dataPath(`key-document.json`)); +const MOCK_KMS_DECRYPT_REPLY = readHttpResponse(dataPath(`kms-decrypt-reply.txt`)); + +describe('crypt_shared library', function () { + let client: MongoClient; + let autoEncrypter: AutoEncrypter | undefined; + beforeEach(async function () { + client = this.configuration.newClient(); + await client.connect(); + }); + afterEach(async () => { + await promisify(cb => + autoEncrypter ? autoEncrypter.teardown(true, cb) : cb(undefined, undefined) + )(); + await client?.close(); + }); + const sandbox = sinon.createSandbox(); + beforeEach(() => { + sandbox.restore(); + sandbox.stub(StateMachine.prototype, 'kmsRequest').callsFake(request => { + request.addResponse(MOCK_KMS_DECRYPT_REPLY); + return Promise.resolve(); + }); + + sandbox + .stub(StateMachine.prototype, 'fetchCollectionInfo') + .callsFake((client, ns, filter, callback) => { + callback(null, MOCK_COLLINFO_RESPONSE); + }); + + sandbox + .stub(StateMachine.prototype, 'markCommand') + .callsFake((client, ns, command, callback) => { + if (ENABLE_LOG_TEST) { + const response = bson.deserialize(MOCK_MONGOCRYPTD_RESPONSE); + response.schemaRequiresEncryption = false; + + ENABLE_LOG_TEST = false; // disable test after run + callback(null, bson.serialize(response)); + return; + } + + callback(null, MOCK_MONGOCRYPTD_RESPONSE); + }); + + sandbox.stub(StateMachine.prototype, 'fetchKeys').callsFake((client, ns, filter, callback) => { + // mock data is already serialized, our action deals with the result of a cursor + const deserializedKey = deserialize(MOCK_KEYDOCUMENT_RESPONSE); + callback(null, [deserializedKey]); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + describe('autoSpawn', function () { + it( + 'should autoSpawn a mongocryptd on init by default', + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + + function (done) { + autoEncrypter = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + + expect(autoEncrypter).to.have.property('cryptSharedLibVersionInfo', null); + + const localMcdm = autoEncrypter._mongocryptdManager; + sandbox.spy(localMcdm, 'spawn'); + + autoEncrypter.init(err => { + if (err) return done(err); + expect(localMcdm.spawn).to.have.been.calledOnce; + done(); + }); + } + ); + + it( + 'should not attempt to kick off mongocryptd on a normal error', + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + function (done) { + let called = false; + StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { + if (!called) { + called = true; + callback(new Error('msg')); + return; + } + + callback(null, MOCK_MONGOCRYPTD_RESPONSE); + }); + + autoEncrypter = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + expect(autoEncrypter).to.have.property('cryptSharedLibVersionInfo', null); + + const localMcdm = autoEncrypter._mongocryptdManager; + autoEncrypter.init(err => { + if (err) return done(err); + + sandbox.spy(localMcdm, 'spawn'); + + autoEncrypter.encrypt('test.test', TEST_COMMAND, err => { + expect(localMcdm.spawn).to.not.have.been.called; + expect(err).to.be.an.instanceOf(Error); + done(); + }); + }); + } + ); + + it( + 'should restore the mongocryptd and retry once if a MongoNetworkTimeoutError is experienced', + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + function (done) { + let called = false; + StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { + if (!called) { + called = true; + callback(new MongoNetworkTimeoutError('msg')); + return; + } + + callback(null, MOCK_MONGOCRYPTD_RESPONSE); + }); + + autoEncrypter = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + expect(autoEncrypter).to.have.property('cryptSharedLibVersionInfo', null); + + const localMcdm = autoEncrypter._mongocryptdManager; + autoEncrypter.init(err => { + if (err) return done(err); + + sandbox.spy(localMcdm, 'spawn'); + + autoEncrypter.encrypt('test.test', TEST_COMMAND, err => { + expect(localMcdm.spawn).to.have.been.calledOnce; + expect(err).to.not.exist; + done(); + }); + }); + } + ); + + it( + 'should propagate error if MongoNetworkTimeoutError is experienced twice in a row', + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + function (done) { + let counter = 2; + StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { + if (counter) { + counter -= 1; + callback(new MongoNetworkTimeoutError('msg')); + return; + } + + callback(null, MOCK_MONGOCRYPTD_RESPONSE); + }); + + autoEncrypter = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + expect(autoEncrypter).to.have.property('cryptSharedLibVersionInfo', null); + + const localMcdm = autoEncrypter._mongocryptdManager; + autoEncrypter.init(err => { + if (err) return done(err); + + sandbox.spy(localMcdm, 'spawn'); + + autoEncrypter.encrypt('test.test', TEST_COMMAND, err => { + expect(localMcdm.spawn).to.have.been.calledOnce; + expect(err).to.be.an.instanceof(MongoNetworkTimeoutError); + done(); + }); + }); + } + ); + + it( + 'should return a useful message if mongocryptd fails to autospawn', + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + function (done) { + autoEncrypter = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + }, + extraOptions: { + mongocryptdURI: 'mongodb://something.invalid:27020/' + } + }); + expect(autoEncrypter).to.have.property('cryptSharedLibVersionInfo', null); + + sandbox.stub(MongocryptdManager.prototype, 'spawn').callsFake(callback => { + callback(); + }); + + autoEncrypter.init(err => { + expect(err).to.exist; + expect(err).to.be.instanceOf(MongoError); + done(); + }); + } + ); + }); + + describe('noAutoSpawn', function () { + ['mongocryptdBypassSpawn', 'bypassAutoEncryption', 'bypassQueryAnalysis'].forEach(opt => { + const encryptionOptions = { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + }, + extraOptions: { + mongocryptdBypassSpawn: opt === 'mongocryptdBypassSpawn' + }, + bypassAutoEncryption: opt === 'bypassAutoEncryption', + bypassQueryAnalysis: opt === 'bypassQueryAnalysis' + }; + + it( + `should not spawn mongocryptd on startup if ${opt} is true`, + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + function (done) { + autoEncrypter = new AutoEncrypter(client, encryptionOptions); + + const localMcdm = autoEncrypter._mongocryptdManager || { + spawn: () => { + // intentional empty function + } + }; + sandbox.spy(localMcdm, 'spawn'); + + autoEncrypter.init(err => { + expect(err).to.not.exist; + expect(localMcdm.spawn).to.have.a.callCount(0); + done(); + }); + } + ); + }); + + it( + 'should not spawn a mongocryptd or retry on a server selection error if mongocryptdBypassSpawn: true', + { requires: { clientSideEncryption: true, predicate: cryptShared('disabled') } }, + function (done) { + let called = false; + const timeoutError = new MongoNetworkTimeoutError('msg'); + StateMachine.prototype.markCommand.callsFake((client, ns, filter, callback) => { + if (!called) { + called = true; + callback(timeoutError); + return; + } + + callback(null, MOCK_MONGOCRYPTD_RESPONSE); + }); + + autoEncrypter = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + }, + extraOptions: { + mongocryptdBypassSpawn: true + } + }); + + const localMcdm = autoEncrypter._mongocryptdManager; + sandbox.spy(localMcdm, 'spawn'); + + autoEncrypter.init(err => { + expect(err).to.not.exist; + expect(localMcdm.spawn).to.not.have.been.called; + + autoEncrypter.encrypt('test.test', TEST_COMMAND, (err, response) => { + expect(localMcdm.spawn).to.not.have.been.called; + expect(response).to.not.exist; + expect(err).to.equal(timeoutError); + done(); + }); + }); + } + ); + }); + + describe('crypt shared library', () => { + it('should fail if no library can be found in the search path and cryptSharedLibRequired is set', async function () { + const env = { + MONGODB_URI: this.configuration.url(), + EXTRA_OPTIONS: JSON.stringify({ + cryptSharedLibSearchPaths: ['/nonexistent'], + cryptSharedLibRequired: true + }) + }; + const file = `${__dirname}/../../tools/fixtures/shared_library_test.js`; + const { stderr } = spawnSync(process.execPath, [file], { + env, + encoding: 'utf-8' + }); + + expect(stderr).to.include('`cryptSharedLibRequired` set but no crypt_shared library loaded'); + }); + + it( + 'should load a shared library by specifying its path', + { + requires: { + predicate: cryptShared('enabled') + } + }, + async function () { + const env = { + MONGODB_URI: this.configuration.url(), + EXTRA_OPTIONS: JSON.stringify({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + cryptSharedLibPath: getEncryptExtraOptions().cryptSharedLibPath! + }) + }; + const file = `${__dirname}/../../tools/fixtures/shared_library_test.js`; + const { stdout } = spawnSync(process.execPath, [file], { env, encoding: 'utf-8' }); + + const response = EJSON.parse(stdout, { useBigInt64: true }); + + expect(response).not.to.be.null; + + expect(response).to.have.property('version').that.is.a('bigint'); + expect(response).to.have.property('versionStr').that.is.a('string'); + } + ); + + it( + 'should load a shared library by specifying a search path', + { + requires: { + predicate: cryptShared('enabled') + } + }, + async function () { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const cryptDir = dirname(getEncryptExtraOptions().cryptSharedLibPath!); + const env = { + MONGODB_URI: this.configuration.url(), + EXTRA_OPTIONS: JSON.stringify({ + cryptSharedLibSearchPaths: [cryptDir] + }) + }; + const file = `${__dirname}/../../tools/fixtures/shared_library_test.js`; + const { stdout } = spawnSync(process.execPath, [file], { env, encoding: 'utf-8' }); + + const response = EJSON.parse(stdout, { useBigInt64: true }); + + expect(response).not.to.be.null; + + expect(response).to.have.property('version').that.is.a('bigint'); + expect(response).to.have.property('versionStr').that.is.a('string'); + } + ); + }); +}); diff --git a/test/integration/node-specific/client_encryption.test.ts b/test/integration/node-specific/client_encryption.test.ts new file mode 100644 index 00000000000..0b9a27f25a1 --- /dev/null +++ b/test/integration/node-specific/client_encryption.test.ts @@ -0,0 +1,604 @@ +import { expect } from 'chai'; +import { readFileSync } from 'fs'; +import * as sinon from 'sinon'; + +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { + ClientEncryption, + type DataKey +} from '../../../src/client-side-encryption/clientEncryption'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { StateMachine } from '../../../src/client-side-encryption/stateMachine'; +import { Binary, type Collection, Int32, Long, type MongoClient } from '../../mongodb'; + +function readHttpResponse(path) { + let data = readFileSync(path, 'utf8').toString(); + data = data.split('\n').join('\r\n'); + return Buffer.from(data, 'utf8'); +} + +const metadata: MongoDBMetadataUI = { + requires: { + clientSideEncryption: true + } +}; +describe('ClientEncryption integration tests', function () { + let client: MongoClient; + + beforeEach(async function () { + client = this.configuration.newClient(); + await client.connect(); + await client + .db('client') + .collection('encryption') + .drop() + .catch(() => null); + }); + + afterEach(async function () { + await client?.close(); + }); + + describe('stubbed stateMachine', function () { + const sandbox = sinon.createSandbox(); + + after(() => sandbox.restore()); + before(() => { + // stubbed out for AWS unit testing below + const MOCK_KMS_ENCRYPT_REPLY = readHttpResponse( + `${__dirname}/../../unit/client-side-encryption/data/kms-encrypt-reply.txt` + ); + sandbox.stub(StateMachine.prototype, 'kmsRequest').callsFake(request => { + request.addResponse(MOCK_KMS_ENCRYPT_REPLY); + return Promise.resolve(); + }); + }); + + [ + { + name: 'local', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }, + { + name: 'aws', + kmsProviders: { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }, + options: { masterKey: { region: 'region', key: 'cmk' } } + } + ].forEach(providerTest => { + it( + `should create a data key with the "${providerTest.name}" KMS provider`, + metadata, + async function () { + const providerName = providerTest.name; + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: providerTest.kmsProviders + }); + + const dataKeyOptions = providerTest.options || {}; + + const dataKey = await encryption.createDataKey(providerName, dataKeyOptions); + expect(dataKey).property('_bsontype', 'Binary'); + + const doc = await client.db('client').collection('encryption').findOne({ _id: dataKey }); + expect(doc).to.have.property('masterKey'); + expect(doc.masterKey).property('provider', providerName); + } + ); + + it( + `should create a data key with the "${providerTest.name}" KMS provider (fixed key material)`, + metadata, + async function () { + const providerName = providerTest.name; + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: providerTest.kmsProviders + }); + + const dataKeyOptions = { + ...providerTest.options, + keyMaterial: new Binary(Buffer.alloc(96)) + }; + + const dataKey = await encryption.createDataKey(providerName, dataKeyOptions); + expect(dataKey).property('_bsontype', 'Binary'); + + const doc = await client.db('client').collection('encryption').findOne({ _id: dataKey }); + expect(doc).to.have.property('masterKey'); + expect(doc.masterKey).property('provider', providerName); + } + ); + }); + + it( + `should create a data key with the local KMS provider (fixed key material, fixed key UUID)`, + metadata, + async function () { + // 'Custom Key Material Test' prose spec test: + const keyVaultColl = client.db('client').collection('encryption'); + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { + local: { + key: 'A'.repeat(128) // the value here is not actually relevant + } + } + }); + + const dataKeyOptions = { + keyMaterial: new Binary( + Buffer.from( + 'xPTAjBRG5JiPm+d3fj6XLi2q5DMXUS/f1f+SMAlhhwkhDRL0kr8r9GDLIGTAGlvC+HVjSIgdL+RKwZCvpXSyxTICWSXTUYsWYPyu3IoHbuBZdmw2faM3WhcRIgbMReU5', + 'base64' + ) + ) + }; + const dataKey = await encryption.createDataKey('local', dataKeyOptions); + expect(dataKey._bsontype).to.equal('Binary'); + + // Remove and re-insert with a fixed UUID to guarantee consistent output + const doc = ( + await keyVaultColl.findOneAndDelete({ _id: dataKey }, { writeConcern: { w: 'majority' } }) + ).value; + doc._id = new Binary(Buffer.alloc(16), 4); + await keyVaultColl.insertOne(doc, { writeConcern: { w: 'majority' } }); + + const encrypted = await encryption.encrypt('test', { + keyId: doc._id, + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + }); + expect(encrypted._bsontype).to.equal('Binary'); + expect(encrypted.toString('base64')).to.equal( + 'AQAAAAAAAAAAAAAAAAAAAAACz0ZOLuuhEYi807ZXTdhbqhLaS2/t9wLifJnnNYwiw79d75QYIZ6M/aYC1h9nCzCjZ7pGUpAuNnkUhnIXM3PjrA==' + ); + } + ); + + it('should fail to create a data key if keyMaterial is wrong', metadata, function (done) { + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: 'A'.repeat(128) } } + }); + + const dataKeyOptions = { + keyMaterial: new Binary(Buffer.alloc(97)) + }; + try { + encryption.createDataKey('local', dataKeyOptions); + expect.fail('missed exception'); + } catch (err) { + expect(err.message).to.equal('keyMaterial should have length 96, but has length 97'); + done(); + } + }); + + it( + 'should explicitly encrypt and decrypt with the "local" KMS provider', + metadata, + async function () { + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + const dataKey = await encryption.createDataKey('local'); + + const encryptOptions = { + keyId: dataKey, + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + }; + + const encrypted = await encryption.encrypt('hello', encryptOptions); + expect(encrypted._bsontype).to.equal('Binary'); + expect(encrypted.sub_type).to.equal(6); + + const decrypted = await encryption.decrypt(encrypted); + expect(decrypted).to.equal('hello'); + } + ); + + it( + 'should explicitly encrypt and decrypt with the "local" KMS provider (promise)', + metadata, + async function () { + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + const dataKey = await encryption.createDataKey('local'); + const encryptOptions = { + keyId: dataKey, + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + }; + + const encrypted = await encryption.encrypt('hello', encryptOptions); + expect(encrypted._bsontype).to.equal('Binary'); + expect(encrypted.sub_type).to.equal(6); + + const decrypted = await encryption.decrypt(encrypted); + + expect(decrypted).to.equal('hello'); + } + ); + + it( + 'should explicitly encrypt and decrypt with a re-wrapped local key', + metadata, + async function () { + // Create new ClientEncryption instances to make sure + // that we are actually using the rewrapped keys and not + // something that has been cached. + const newClientEncryption = () => + new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: 'A'.repeat(128) } } + }); + + const dataKey = await newClientEncryption().createDataKey('local'); + const encryptOptions = { + keyId: dataKey, + algorithm: 'Indexed', + contentionFactor: 0 + }; + + const encrypted = await newClientEncryption().encrypt('hello', encryptOptions); + expect(encrypted._bsontype).to.equal('Binary'); + expect(encrypted.sub_type).to.equal(6); + const rewrapManyDataKeyResult = await newClientEncryption().rewrapManyDataKey({}); + expect(rewrapManyDataKeyResult.bulkWriteResult.result.nModified).to.equal(1); + const decrypted = await newClientEncryption().decrypt(encrypted); + expect(decrypted).to.equal('hello'); + } + ); + + it('should not perform updates if no keys match', metadata, async function () { + const clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: 'A'.repeat(128) } } + }); + + const rewrapManyDataKeyResult = await clientEncryption.rewrapManyDataKey({ _id: 12345 }); + expect(rewrapManyDataKeyResult.bulkWriteResult).to.equal(undefined); + }); + + it.skip( + 'should explicitly encrypt and decrypt with a re-wrapped local key (explicit session/transaction)', + metadata, + function () { + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: 'A'.repeat(128) } } + }); + let encrypted; + let rewrapManyDataKeyResult; + + return encryption + .createDataKey('local') + .then(dataKey => { + const encryptOptions = { + keyId: dataKey, + algorithm: 'Indexed', + contentionFactor: 0 + }; + + return encryption.encrypt('hello', encryptOptions); + }) + .then(_encrypted => { + encrypted = _encrypted; + }) + .then(() => { + // withSession does not forward the callback's return value, hence + // the slightly awkward 'rewrapManyDataKeyResult' passing here + return client.withSession(session => { + return session.withTransaction(() => { + expect(session.transaction.isStarting).to.equal(true); + expect(session.transaction.isActive).to.equal(true); + rewrapManyDataKeyResult = encryption.rewrapManyDataKey( + {}, + { provider: 'local', session } + ); + return rewrapManyDataKeyResult.then(() => { + // Verify that the 'session' argument was actually used + expect(session.transaction.isStarting).to.equal(false); + expect(session.transaction.isActive).to.equal(true); + }); + }); + }); + }) + .then(() => { + return rewrapManyDataKeyResult; + }) + .then(rewrapManyDataKeyResult => { + expect(rewrapManyDataKeyResult.bulkWriteResult.result.nModified).to.equal(1); + return encryption.decrypt(encrypted); + }) + .then(decrypted => { + expect(decrypted).to.equal('hello'); + }); + } + ).skipReason = 'TODO(DRIVERS-2389): add explicit session support to key management API'; + + // TODO(NODE-3371): resolve KMS JSON response does not include string 'Plaintext'. HTTP status=200 error + it.skip( + 'should explicitly encrypt and decrypt with the "aws" KMS provider', + metadata, + function (done) { + const encryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { aws: { accessKeyId: 'example', secretAccessKey: 'example' } } + }); + + const dataKeyOptions = { + masterKey: { region: 'region', key: 'cmk' } + }; + + encryption.createDataKey('aws', dataKeyOptions, (err, dataKey) => { + expect(err).to.not.exist; + + const encryptOptions = { + keyId: dataKey, + algorithm: 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic' + }; + + encryption.encrypt('hello', encryptOptions, (err, encrypted) => { + expect(err).to.not.exist; + expect(encrypted).to.have.property('v'); + expect(encrypted.v._bsontype).to.equal('Binary'); + expect(encrypted.v.sub_type).to.equal(6); + + encryption.decrypt(encrypted, (err, decrypted) => { + expect(err).to.not.exist; + expect(decrypted).to.equal('hello'); + done(); + }); + }); + }); + } + ).skipReason = + "TODO(NODE-3371): resolve KMS JSON response does not include string 'Plaintext'. HTTP status=200 error"; + }); + + describe('encrypt()', function () { + let clientEncryption; + let completeOptions; + let dataKey; + + beforeEach(async function () { + clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + dataKey = await clientEncryption.createDataKey('local', { + name: 'local', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + completeOptions = { + algorithm: 'RangePreview', + contentionFactor: 0, + rangeOptions: { + min: new Long(0), + max: new Long(10), + sparsity: new Long(1) + }, + keyId: dataKey + }; + }); + + context('when expressionMode is incorrectly provided as an argument', function () { + it( + 'overrides the provided option with the correct value for expression mode', + metadata, + async function () { + const optionsWithExpressionMode = { ...completeOptions, expressionMode: true }; + const result = await clientEncryption.encrypt(new Long(0), optionsWithExpressionMode); + + expect(result).to.be.instanceof(Binary); + } + ); + }); + }); + + describe('encryptExpression()', function () { + let clientEncryption; + let completeOptions; + let dataKey; + const expression = { + $and: [{ someField: { $gt: 1 } }] + }; + + beforeEach(async function () { + clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + dataKey = await clientEncryption.createDataKey('local', { + name: 'local', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + completeOptions = { + algorithm: 'RangePreview', + queryType: 'rangePreview', + contentionFactor: 0, + rangeOptions: { + min: new Int32(0), + max: new Int32(10), + sparsity: new Long(1) + }, + keyId: dataKey + }; + }); + + it('throws if rangeOptions is not provided', metadata, async function () { + expect(delete completeOptions.rangeOptions).to.be.true; + const errorOrResult = await clientEncryption + .encryptExpression(expression, completeOptions) + .catch(e => e); + + expect(errorOrResult).to.be.instanceof(TypeError); + }); + + it('throws if algorithm is not provided', metadata, async function () { + expect(delete completeOptions.algorithm).to.be.true; + const errorOrResult = await clientEncryption + .encryptExpression(expression, completeOptions) + .catch(e => e); + + expect(errorOrResult).to.be.instanceof(TypeError); + }); + + it(`throws if algorithm does not equal 'rangePreview'`, metadata, async function () { + completeOptions['algorithm'] = 'equality'; + const errorOrResult = await clientEncryption + .encryptExpression(expression, completeOptions) + .catch(e => e); + + expect(errorOrResult).to.be.instanceof(TypeError); + }); + + it( + `does not throw if algorithm has different casing than 'rangePreview'`, + metadata, + async function () { + completeOptions['algorithm'] = 'rAnGePrEvIeW'; + const errorOrResult = await clientEncryption + .encryptExpression(expression, completeOptions) + .catch(e => e); + + expect(errorOrResult).not.to.be.instanceof(Error); + } + ); + + context('when expressionMode is incorrectly provided as an argument', function () { + it( + 'overrides the provided option with the correct value for expression mode', + metadata, + async function () { + const optionsWithExpressionMode = { ...completeOptions, expressionMode: false }; + const result = await clientEncryption.encryptExpression( + expression, + optionsWithExpressionMode + ); + + expect(result).not.to.be.instanceof(Binary); + } + ); + }); + }); + + describe('ClientEncryptionKeyAltNames', function () { + let client: MongoClient; + let clientEncryption: ClientEncryption; + let collection: Collection; + // Data Key Stuff + const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; + const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; + const AWS_REGION = process.env.AWS_REGION; + const AWS_CMK_ID = process.env.AWS_CMK_ID; + + const kmsProviders = { + aws: { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY } + }; + const dataKeyOptions = { masterKey: { key: AWS_CMK_ID, region: AWS_REGION } }; + + beforeEach(function () { + client = this.configuration.newClient(); + collection = client.db('client').collection('encryption'); + clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders + }); + }); + + afterEach(async function () { + await client?.close(); + }); + + function makeOptions(keyAltNames) { + expect(dataKeyOptions.masterKey).to.be.an('object'); + expect(dataKeyOptions.masterKey.key).to.be.a('string'); + expect(dataKeyOptions.masterKey.region).to.be.a('string'); + + return { + masterKey: { + key: dataKeyOptions.masterKey.key, + region: dataKeyOptions.masterKey.region + }, + keyAltNames + }; + } + + describe('errors', function () { + [42, 'hello', { keyAltNames: 'foobar' }, /foobar/].forEach(val => { + it(`should fail if typeof keyAltNames = ${typeof val}`, metadata, function () { + const options = makeOptions(val); + expect(() => clientEncryption.createDataKey('aws', options, () => undefined)).to.throw( + TypeError + ); + }); + }); + + [undefined, null, 42, { keyAltNames: 'foobar' }, ['foobar'], /foobar/].forEach(val => { + it(`should fail if typeof keyAltNames[x] = ${typeof val}`, metadata, function () { + const options = makeOptions([val]); + expect(() => clientEncryption.createDataKey('aws', options, () => undefined)).to.throw( + TypeError + ); + }); + }); + }); + + it('should create a key with keyAltNames', metadata, async function () { + const options = makeOptions(['foobar']); + const dataKey = await clientEncryption.createDataKey('aws', options); + const document = await collection.findOne({ keyAltNames: 'foobar' }); + expect(document).to.be.an('object'); + expect(document).to.have.property('keyAltNames').that.includes.members(['foobar']); + expect(document).to.have.property('_id').that.deep.equals(dataKey); + }); + + it('should create a key with multiple keyAltNames', metadata, async function () { + const dataKey = await clientEncryption.createDataKey( + 'aws', + makeOptions(['foobar', 'fizzbuzz']) + ); + const docs = await Promise.all([ + collection.findOne({ keyAltNames: 'foobar' }), + collection.findOne({ keyAltNames: 'fizzbuzz' }) + ]); + expect(docs).to.have.lengthOf(2); + const doc1 = docs[0]; + const doc2 = docs[1]; + expect(doc1).to.be.an('object'); + expect(doc2).to.be.an('object'); + expect(doc1).to.have.property('keyAltNames').that.includes.members(['foobar', 'fizzbuzz']); + expect(doc1).to.have.property('_id').that.deep.equals(dataKey); + expect(doc2).to.have.property('keyAltNames').that.includes.members(['foobar', 'fizzbuzz']); + expect(doc2).to.have.property('_id').that.deep.equals(dataKey); + }); + + it( + 'should be able to reference a key with `keyAltName` during encryption', + metadata, + async function () { + const keyAltName = 'mySpecialKey'; + const algorithm = 'AEAD_AES_256_CBC_HMAC_SHA_512-Deterministic'; + + const valueToEncrypt = 'foobar'; + + const keyId = await clientEncryption.createDataKey('aws', makeOptions([keyAltName])); + const encryptedValue = await clientEncryption.encrypt(valueToEncrypt, { keyId, algorithm }); + const encryptedValue2 = await clientEncryption.encrypt(valueToEncrypt, { + keyAltName, + algorithm + }); + expect(encryptedValue).to.deep.equal(encryptedValue2); + } + ); + }); +}); diff --git a/encryption/test/cryptoCallbacks.test.js b/test/integration/node-specific/crypto_callbacks.test.ts similarity index 78% rename from encryption/test/cryptoCallbacks.test.js rename to test/integration/node-specific/crypto_callbacks.test.ts index 941dfbaf967..3390ae01f5a 100644 --- a/encryption/test/cryptoCallbacks.test.js +++ b/test/integration/node-specific/crypto_callbacks.test.ts @@ -1,61 +1,49 @@ -'use strict'; +import { expect } from 'chai'; +import * as sinon from 'sinon'; -const sinon = require('sinon'); -const { expect } = require('chai'); -const mongodb = require('mongodb'); -const MongoClient = mongodb.MongoClient; -const stateMachine = require('../lib/stateMachine')({ mongodb }); -const cryptoCallbacks = require('../lib/cryptoCallbacks'); -const ClientEncryption = require('../lib/clientEncryption')({ - mongodb, - stateMachine -}).ClientEncryption; - -const requirements = require('./requirements.helper'); +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import * as cryptoCallbacks from '../../../src/client-side-encryption/cryptoCallbacks'; +import { type MongoClient } from '../../mongodb'; // Data Key Stuff -const kmsProviders = Object.assign({}, requirements.awsKmsProviders); -const dataKeyOptions = Object.assign({}, requirements.awsDataKeyOptions); +const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID; +const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY; +const AWS_REGION = process.env.AWS_REGION; +const AWS_CMK_ID = process.env.AWS_CMK_ID; + +const kmsProviders = { + aws: { accessKeyId: AWS_ACCESS_KEY_ID, secretAccessKey: AWS_SECRET_ACCESS_KEY } +}; +const dataKeyOptions = { masterKey: { key: AWS_CMK_ID, region: AWS_REGION } }; + +const SKIP_AWS_TESTS = [AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_CMK_ID].some( + secret => !secret +); describe('cryptoCallbacks', function () { + let client: MongoClient; + let sandbox; before(function () { - if (requirements.SKIP_AWS_TESTS) { - console.error('Skipping crypto callback tests'); - return; - } - this.sinon = sinon.createSandbox(); + sandbox = sinon.createSandbox(); }); beforeEach(function () { - if (requirements.SKIP_AWS_TESTS) { - this.currentTest.skipReason = `requirements.SKIP_AWS_TESTS=${requirements.SKIP_AWS_TESTS}`; - this.test.skip(); + if (SKIP_AWS_TESTS) { + this.currentTest?.skip(); return; } - this.sinon.restore(); - this.client = new MongoClient('mongodb://localhost:27017/', { - useUnifiedTopology: true, - useNewUrlParser: true - }); - return this.client.connect(); - }); - - afterEach(function () { - if (requirements.SKIP_AWS_TESTS) { - return; - } - this.sinon.restore(); - let p = Promise.resolve(); - if (this.client) { - p = p.then(() => this.client.close()).then(() => (this.client = undefined)); - } + sandbox.restore(); + client = this.configuration.newClient(); - return p; + return client.connect(); }); - after(function () { - this.sinon = undefined; + afterEach(async function () { + sandbox.restore(); + await client?.close(); }); // TODO(NODE-3370): fix key formatting error "asn1_check_tlen:wrong tag" @@ -92,10 +80,10 @@ describe('cryptoCallbacks', function () { it('should invoke crypto callbacks when doing encryption', function (done) { for (const name of hookNames) { - this.sinon.spy(cryptoCallbacks, name); + sandbox.spy(cryptoCallbacks, name); } - function assertCertainHooksCalled(expectedSet) { + function assertCertainHooksCalled(expectedSet?) { expectedSet = expectedSet || new Set([]); for (const name of hookNames) { const hook = cryptoCallbacks[name]; @@ -109,7 +97,7 @@ describe('cryptoCallbacks', function () { } } - const encryption = new ClientEncryption(this.client, { + const encryption = new ClientEncryption(client, { keyVaultNamespace: 'test.encryption', kmsProviders }); @@ -159,9 +147,9 @@ describe('cryptoCallbacks', function () { ['aes256CbcEncryptHook', 'aes256CbcDecryptHook', 'hmacSha512Hook'].forEach(hookName => { it(`should properly propagate an error when ${hookName} fails`, function (done) { const error = new Error('some random error text'); - this.sinon.stub(cryptoCallbacks, hookName).returns(error); + sandbox.stub(cryptoCallbacks, hookName).returns(error); - const encryption = new ClientEncryption(this.client, { + const encryption = new ClientEncryption(client, { keyVaultNamespace: 'test.encryption', kmsProviders }); @@ -201,9 +189,9 @@ describe('cryptoCallbacks', function () { ['hmacSha256Hook', 'sha256Hook'].forEach(hookName => { it(`should error with a specific kms error when ${hookName} fails`, function () { const error = new Error('some random error text'); - this.sinon.stub(cryptoCallbacks, hookName).returns(error); + sandbox.stub(cryptoCallbacks, hookName).returns(error); - const encryption = new ClientEncryption(this.client, { + const encryption = new ClientEncryption(client, { keyVaultNamespace: 'test.encryption', kmsProviders }); @@ -216,9 +204,9 @@ describe('cryptoCallbacks', function () { it('should error synchronously with error when randomHook fails', function (done) { const error = new Error('some random error text'); - this.sinon.stub(cryptoCallbacks, 'randomHook').returns(error); + sandbox.stub(cryptoCallbacks, 'randomHook').returns(error); - const encryption = new ClientEncryption(this.client, { + const encryption = new ClientEncryption(client, { keyVaultNamespace: 'test.encryption', kmsProviders }); diff --git a/test/tools/fixtures/shared_library_test.js b/test/tools/fixtures/shared_library_test.js new file mode 100644 index 00000000000..0b1eab9d743 --- /dev/null +++ b/test/tools/fixtures/shared_library_test.js @@ -0,0 +1,23 @@ +/* eslint-disable no-restricted-modules */ +const { EJSON } = require('bson'); +const { AutoEncrypter } = require('../../../lib/client-side-encryption/autoEncrypter'); +const { MongoClient } = require('../../../lib/mongo_client'); + +try { + const extraOptions = JSON.parse(process.env.EXTRA_OPTIONS); + const autoEncrypter = new AutoEncrypter(new MongoClient(process.env.MONGODB_URI), { + keyVaultNamespace: 'admin.datakeys', + logger: () => {}, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + }, + extraOptions + }); + + process.stdout.write( + EJSON.stringify(autoEncrypter.cryptSharedLibVersionInfo, { useBigInt64: true, relaxed: false }) + ); +} catch (error) { + process.stderr.write(EJSON.stringify({ error: error.message })); +} diff --git a/test/tools/runner/config.ts b/test/tools/runner/config.ts index dae6f7a4d87..2f190fe3b1d 100644 --- a/test/tools/runner/config.ts +++ b/test/tools/runner/config.ts @@ -350,11 +350,6 @@ export class TestConfiguration { return { writeConcern: { w: 1 } }; } - // Accessors and methods Client-Side Encryption - get mongodbClientEncryption(): typeof import('mongodb-client-encryption') { - return this.clientSideEncryption && this.clientSideEncryption.mongodbClientEncryption; - } - kmsProviders(localKey): Record { return { local: { key: localKey } }; } diff --git a/test/tools/runner/filters/client_encryption_filter.js b/test/tools/runner/filters/client_encryption_filter.js index 563620d5d44..d811c574b19 100644 --- a/test/tools/runner/filters/client_encryption_filter.js +++ b/test/tools/runner/filters/client_encryption_filter.js @@ -1,6 +1,5 @@ 'use strict'; -const mongodb = require('../../../mongodb'); const process = require('process'); /** @@ -19,7 +18,7 @@ class ClientSideEncryptionFilter { const CSFLE_KMS_PROVIDERS = process.env.CSFLE_KMS_PROVIDERS; let mongodbClientEncryption; try { - mongodbClientEncryption = require('mongodb-client-encryption').extension(mongodb); + mongodbClientEncryption = require('mongodb-client-encryption'); } catch (failedToGetFLELib) { if (process.env.TEST_CSFLE) { console.error({ failedToGetFLELib }); diff --git a/test/tools/runner/filters/generic_predicate_filter.js b/test/tools/runner/filters/generic_predicate_filter.js new file mode 100644 index 00000000000..2682acc0eba --- /dev/null +++ b/test/tools/runner/filters/generic_predicate_filter.js @@ -0,0 +1,26 @@ +'use strict'; + +/** + * Generic filter than can run predicates. + * + * Predicates cannot be async. The test is skipped if the predicate returns + * a string. The string returned should be a skip reason. + * + * example: + * metadata: { + * requires: { + * predicate: (test: Mocha.Test) => true | string + * } + * } + */ + +class GenericPredicateFilter { + filter(test) { + /** @type{ ((test?: Mocha.Test) => string | true) | undefined } */ + const predicate = test?.metadata?.requires?.predicate; + + return predicate?.(test) ?? true; + } +} + +module.exports = GenericPredicateFilter; diff --git a/test/tools/runner/hooks/configuration.js b/test/tools/runner/hooks/configuration.js index 45351dbef11..f07da9e2943 100644 --- a/test/tools/runner/hooks/configuration.js +++ b/test/tools/runner/hooks/configuration.js @@ -71,18 +71,23 @@ const testSkipBeforeEachHook = async function () { const metadata = this.currentTest.metadata; if (metadata && metadata.requires && Object.keys(metadata.requires).length > 0) { - const failedFilter = filters.find(filter => !filter.filter(this.currentTest)); - + const failedFilter = filters.find(filter => filter.filter(this.currentTest) !== true); if (failedFilter) { const filterName = failedFilter.constructor.name; - const metadataString = inspect(metadata.requires, { - colors: true, - compact: true, - depth: 10, - breakLength: Infinity - }); - - this.currentTest.skipReason = `filtered by ${filterName} requires ${metadataString}`; + if (filterName === 'GenericPredicateFilter') { + this.currentTest.skipReason = `filtered by ${filterName}: ${failedFilter.filter( + this.currentTest + )}`; + } else { + const metadataString = inspect(metadata.requires, { + colors: true, + compact: true, + depth: 10, + breakLength: Infinity + }); + + this.currentTest.skipReason = `filtered by ${filterName} requires ${metadataString}`; + } this.skip(); } diff --git a/test/tools/runner/hooks/leak_checker.ts b/test/tools/runner/hooks/leak_checker.ts index ae8597edfe0..c32933501be 100644 --- a/test/tools/runner/hooks/leak_checker.ts +++ b/test/tools/runner/hooks/leak_checker.ts @@ -127,7 +127,7 @@ const leakCheckerBeforeEach = async function () { const leakCheckerAfterEach = async function () { let thrownError: Error | undefined; try { - currentLeakChecker.assert(); + currentLeakChecker?.assert(); } catch (error) { thrownError = error; } diff --git a/test/tools/unified-spec-runner/schema.ts b/test/tools/unified-spec-runner/schema.ts index e3fc1aa8dfb..04629560c0b 100644 --- a/test/tools/unified-spec-runner/schema.ts +++ b/test/tools/unified-spec-runner/schema.ts @@ -1,12 +1,10 @@ import type { Document, - type FindCursor, - type MongoClient, MongoLoggableComponent, ObjectId, ReadConcernLevel, ReadPreferenceMode, - type ServerApiVersion, + ServerApiVersion, SeverityLevel, TagSet, W @@ -332,20 +330,3 @@ export interface ExpectedLogMessage { * A type that represents the test filter provided to the unifed runner. */ export type TestFilter = (test: Test, ctx: TestConfiguration) => string | false; - -/** - * This interface represents the bare minimum of type information needed to get *some* type - * safety on the client encryption object in unified tests. - */ -export interface ClientEncryption { - // eslint-disable-next-line @typescript-eslint/no-misused-new - new (client: MongoClient, options: any): ClientEncryption; - createDataKey(provider, options?: Document): Promise; - rewrapManyDataKey(filter, options): Promise; - deleteKey(id): Promise; - getKey(id): Promise; - getKeys(): FindCursor; - addKeyAltName(id, keyAltName): Promise; - removeKeyAltName(id, keyAltName): Promise; - getKeyByAltName(keyAltName): Promise; -} diff --git a/test/tools/unified-spec-runner/unified-utils.ts b/test/tools/unified-spec-runner/unified-utils.ts index 7b863fe7cde..106d284890e 100644 --- a/test/tools/unified-spec-runner/unified-utils.ts +++ b/test/tools/unified-spec-runner/unified-utils.ts @@ -4,20 +4,20 @@ import ConnectionString from 'mongodb-connection-string-url'; import { gte as semverGte, lte as semverLte } from 'semver'; import { isDeepStrictEqual } from 'util'; +/* eslint-disable @typescript-eslint/no-restricted-imports */ +import { ClientEncryption } from '../../../src/client-side-encryption/clientEncryption'; import { type AutoEncryptionOptions, type CollectionOptions, type DbOptions, type Document, getMongoDBClientEncryption, - type MongoClient, - MongoMissingDependencyError + type MongoClient } from '../../mongodb'; import { shouldRunServerlessTest } from '../../tools/utils'; import { type CmapEvent, type CommandEvent, type EntitiesMap } from './entities'; import { matchesEvents } from './match'; import type { - ClientEncryption, ClientEncryptionEntity, CollectionOrDatabaseOptions, ExpectedEventsForClient, @@ -219,31 +219,6 @@ export function getMatchingEventCount(event, client, entities): number { }).length; } -/** - * attempts to import mongodb-client-encryption and extract the client encryption class for - * use in the csfle unified tests - * - * @throws MongoMissingDependencyError when mongodb-client-encryption is not installed - */ -export function getClientEncryptionClass(): ClientEncryption { - try { - const mongodbClientEncryption = getMongoDBClientEncryption(); - if (mongodbClientEncryption == null) { - throw new MongoMissingDependencyError( - 'Attempting to import mongodb-client-encryption but it is not installed.' - ); - } - - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { ClientEncryption } = mongodbClientEncryption.extension(require('../../mongodb')); - return ClientEncryption; - } catch { - throw new MongoMissingDependencyError( - 'Attempting to import mongodb-client-encryption but it is not installed.' - ); - } -} - /** * parses the process.env for three required environment variables * @@ -362,14 +337,7 @@ export function createClientEncryption( map: EntitiesMap, entity: ClientEncryptionEntity ): ClientEncryption { - let ClientEncryptionClass; - try { - ClientEncryptionClass = getClientEncryptionClass(); - } catch { - throw new Error( - 'unable to import client encryption. has mongodb-client-encryption been installed?' - ); - } + getMongoDBClientEncryption(); const { clientEncryptionOpts } = entity; const { @@ -404,6 +372,6 @@ export function createClientEncryption( }; } - const clientEncryption = new ClientEncryptionClass(clientEntity, autoEncryptionOptions); + const clientEncryption = new ClientEncryption(clientEntity, autoEncryptionOptions); return clientEncryption; } diff --git a/test/types/client-side-encryption.test-d.ts b/test/types/client-side-encryption.test-d.ts new file mode 100644 index 00000000000..dbdf5ca23c3 --- /dev/null +++ b/test/types/client-side-encryption.test-d.ts @@ -0,0 +1,65 @@ +// TODO(NODE-5422): add TS support for FLE in the driver and unskip type tests + +// import { expectAssignable, expectError, expectType, expectNotType, expectNotAssignable } from 'tsd'; +// import { RangeOptions, AWSEncryptionKeyOptions, AzureEncryptionKeyOptions, ClientEncryption, GCPEncryptionKeyOptions, ClientEncryptionEncryptOptions, KMSProviders } from '../..'; + +// type RequiredCreateEncryptedCollectionSettings = Parameters< +// ClientEncryption['createEncryptedCollection'] +// >[2]; + +// expectError({}); +// expectError({ +// provider: 'blah!', +// createCollectionOptions: { encryptedFields: {} } +// }); +// expectError({ +// provider: 'aws', +// createCollectionOptions: {} +// }); +// expectError({ +// provider: 'aws', +// createCollectionOptions: { encryptedFields: null } +// }); + +// expectAssignable({ +// provider: 'aws', +// createCollectionOptions: { encryptedFields: {} } +// }); +// expectAssignable({ +// provider: 'aws', +// createCollectionOptions: { encryptedFields: {} }, +// masterKey: { } as AWSEncryptionKeyOptions | AzureEncryptionKeyOptions | GCPEncryptionKeyOptions +// }); + +// { +// // NODE-5041 - incorrect spelling of rangeOpts in typescript definitions +// const options = {} as ClientEncryptionEncryptOptions; +// expectType(options.rangeOptions) +// } + +// { +// // KMSProviders +// // aws +// expectAssignable({ accessKeyId: '', secretAccessKey: '' }); +// expectAssignable({ accessKeyId: '', secretAccessKey: '', sessionToken: undefined }); +// expectAssignable({ accessKeyId: '', secretAccessKey: '', sessionToken: '' }); +// // automatic +// expectAssignable({}); + +// // azure +// expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a' }); +// expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a' }); +// expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a', identityPlatformEndpoint: undefined }); +// expectAssignable({ tenantId: 'a', clientId: 'a', clientSecret: 'a', identityPlatformEndpoint: '' }); +// expectAssignable({ accessToken: 'a' }); +// expectAssignable({}); + +// // gcp +// expectAssignable({ email: 'a', privateKey: 'a' }); +// expectAssignable({ email: 'a', privateKey: 'a', endpoint: undefined }); +// expectAssignable({ email: 'a', privateKey: 'a', endpoint: 'a' }); +// expectAssignable({ accessToken: 'a' }); +// // automatic +// expectAssignable({}); + +// } diff --git a/test/unit/client-side-encryption/autoEncrypter.test.js b/test/unit/client-side-encryption/autoEncrypter.test.js new file mode 100644 index 00000000000..96c76f9b74a --- /dev/null +++ b/test/unit/client-side-encryption/autoEncrypter.test.js @@ -0,0 +1,433 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const sinon = require('sinon'); +const { MongoClient } = require('../../../src/mongo_client'); +const { BSON } = require('../../mongodb'); +const bson = BSON; +const { EJSON } = BSON; +const requirements = require('./requirements.helper'); +const { MongoError, MongoNetworkTimeoutError } = require('../../../src/error'); +const { StateMachine } = require('../../../src/client-side-encryption/stateMachine'); + +const { AutoEncrypter } = require('../../../src/client-side-encryption/autoEncrypter'); +const { MongocryptdManager } = require('../../../src/client-side-encryption/mongocryptdManager'); + +const { expect } = require('chai'); + +function readExtendedJsonToBuffer(path) { + const ejson = EJSON.parse(fs.readFileSync(path, 'utf8')); + return bson.serialize(ejson); +} + +function readHttpResponse(path) { + let data = fs.readFileSync(path, 'utf8'); + data = data.split('\n').join('\r\n'); + return Buffer.from(data, 'utf8'); +} + +const TEST_COMMAND = JSON.parse(fs.readFileSync(`${__dirname}/data/cmd.json`)); +const MOCK_COLLINFO_RESPONSE = readExtendedJsonToBuffer(`${__dirname}/data/collection-info.json`); +const MOCK_MONGOCRYPTD_RESPONSE = readExtendedJsonToBuffer( + `${__dirname}/data/mongocryptd-reply.json` +); +const MOCK_KEYDOCUMENT_RESPONSE = readExtendedJsonToBuffer(`${__dirname}/data/key-document.json`); +const MOCK_KMS_DECRYPT_REPLY = readHttpResponse(`${__dirname}/data/kms-decrypt-reply.txt`); + +class MockClient { + constructor() { + this.topology = { + bson + }; + } +} + +const originalAccessKeyId = process.env.AWS_ACCESS_KEY_ID; +const originalSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + +describe('AutoEncrypter', function () { + this.timeout(12000); + let ENABLE_LOG_TEST = false; + let sandbox = sinon.createSandbox(); + beforeEach(() => { + sandbox.restore(); + sandbox.stub(StateMachine.prototype, 'kmsRequest').callsFake(request => { + request.addResponse(MOCK_KMS_DECRYPT_REPLY); + return Promise.resolve(); + }); + + sandbox + .stub(StateMachine.prototype, 'fetchCollectionInfo') + .callsFake((client, ns, filter, callback) => { + callback(null, MOCK_COLLINFO_RESPONSE); + }); + + sandbox + .stub(StateMachine.prototype, 'markCommand') + .callsFake((client, ns, command, callback) => { + if (ENABLE_LOG_TEST) { + const response = bson.deserialize(MOCK_MONGOCRYPTD_RESPONSE); + response.schemaRequiresEncryption = false; + + ENABLE_LOG_TEST = false; // disable test after run + callback(null, bson.serialize(response)); + return; + } + + callback(null, MOCK_MONGOCRYPTD_RESPONSE); + }); + + sandbox.stub(StateMachine.prototype, 'fetchKeys').callsFake((client, ns, filter, callback) => { + // mock data is already serialized, our action deals with the result of a cursor + const deserializedKey = bson.deserialize(MOCK_KEYDOCUMENT_RESPONSE); + callback(null, [deserializedKey]); + }); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('#constructor', function () { + context('when using mongocryptd', function () { + const client = new MockClient(); + const autoEncrypterOptions = { + mongocryptdBypassSpawn: true, + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }; + const autoEncrypter = new AutoEncrypter(client, autoEncrypterOptions); + + it('instantiates a mongo client on the auto encrypter', function () { + expect(autoEncrypter) + .to.have.property('_mongocryptdClient') + .to.be.instanceOf(MongoClient); + }); + + it('sets serverSelectionTimeoutMS to 10000ms', function () { + expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); + const options = autoEncrypter._mongocryptdClient.s.options; + expect(options).to.have.property('serverSelectionTimeoutMS', 10000); + }); + + context('when mongocryptdURI is not specified', () => { + it('sets the ip address family to ipv4', function () { + expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); + const options = autoEncrypter._mongocryptdClient.s.options; + expect(options).to.have.property('family', 4); + }); + }); + + context('when mongocryptdURI is specified', () => { + it('does not set the ip address family to ipv4', function () { + const autoEncrypter = new AutoEncrypter(client, { + ...autoEncrypterOptions, + extraOptions: { mongocryptdURI: MongocryptdManager.DEFAULT_MONGOCRYPTD_URI } + }); + + expect(autoEncrypter).to.have.nested.property('_mongocryptdClient.s.options'); + const options = autoEncrypter._mongocryptdClient.s.options; + expect(options).not.to.have.property('family', 4); + }); + }); + }); + }); + + it('should support `bypassAutoEncryption`', function (done) { + const client = new MockClient(); + const autoEncrypter = new AutoEncrypter(client, { + bypassAutoEncryption: true, + mongocryptdBypassSpawn: true, + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + + autoEncrypter.encrypt('test.test', { test: 'command' }, (err, encrypted) => { + expect(err).to.not.exist; + expect(encrypted).to.eql({ test: 'command' }); + done(); + }); + }); + + describe('state machine', function () { + it('should decrypt mock data', function (done) { + const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + mc.decrypt(input, (err, decrypted) => { + if (err) return done(err); + expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); + expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + expect(decrypted.filter).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + done(); + }); + }); + + it('should decrypt mock data and mark decrypted items if enabled for testing', function (done) { + const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); + const nestedInput = readExtendedJsonToBuffer( + `${__dirname}/data/encrypted-document-nested.json` + ); + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + mc[Symbol.for('@@mdb.decorateDecryptionResult')] = true; + mc.decrypt(input, (err, decrypted) => { + if (err) return done(err); + expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); + expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + expect(decrypted.filter[Symbol.for('@@mdb.decryptedKeys')]).to.eql(['ssn']); + + // The same, but with an object containing different data types as the input + mc.decrypt({ a: [null, 1, { c: new bson.Binary('foo', 1) }] }, (err, decrypted) => { + if (err) return done(err); + expect(decrypted).to.eql({ a: [null, 1, { c: new bson.Binary('foo', 1) }] }); + expect(decrypted).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + + // The same, but with nested data inside the decrypted input + mc.decrypt(nestedInput, (err, decrypted) => { + if (err) return done(err); + expect(decrypted).to.eql({ nested: { x: { y: 1234 } } }); + expect(decrypted[Symbol.for('@@mdb.decryptedKeys')]).to.eql(['nested']); + expect(decrypted.nested).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + expect(decrypted.nested.x).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + expect(decrypted.nested.x.y).to.not.have.property(Symbol.for('@@mdb.decryptedKeys')); + done(); + }); + }); + }); + }); + + it('should decrypt mock data with per-context KMS credentials', function (done) { + const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: {} + }, + async onKmsProviderRefresh() { + return { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }; + } + }); + mc.decrypt(input, (err, decrypted) => { + if (err) return done(err); + expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); + done(); + }); + }); + + context('when no refresh function is provided', function () { + const accessKey = 'example'; + const secretKey = 'example'; + + before(function () { + if (!requirements.credentialProvidersInstalled.aws) { + this.currentTest.skipReason = 'Cannot refresh credentials without sdk provider'; + this.currentTest.skip(); + return; + } + // After the entire suite runs, set the env back for the rest of the test run. + process.env.AWS_ACCESS_KEY_ID = accessKey; + process.env.AWS_SECRET_ACCESS_KEY = secretKey; + }); + + after(function () { + // After the entire suite runs, set the env back for the rest of the test run. + process.env.AWS_ACCESS_KEY_ID = originalAccessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = originalSecretAccessKey; + }); + + it('should decrypt mock data with KMS credentials from the environment', function (done) { + const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: {} + } + }); + mc.decrypt(input, (err, decrypted) => { + if (err) return done(err); + expect(decrypted).to.eql({ filter: { find: 'test', ssn: '457-55-5462' } }); + done(); + }); + }); + }); + + context('when no refresh function is provided and no optional sdk', function () { + const accessKey = 'example'; + const secretKey = 'example'; + + before(function () { + if (requirements.credentialProvidersInstalled.aws) { + this.currentTest.skipReason = 'With optional sdk installed credentials would be loaded.'; + this.currentTest.skip(); + return; + } + // After the entire suite runs, set the env back for the rest of the test run. + process.env.AWS_ACCESS_KEY_ID = accessKey; + process.env.AWS_SECRET_ACCESS_KEY = secretKey; + }); + + after(function () { + // After the entire suite runs, set the env back for the rest of the test run. + process.env.AWS_ACCESS_KEY_ID = originalAccessKeyId; + process.env.AWS_SECRET_ACCESS_KEY = originalSecretAccessKey; + }); + + it('errors without the optional sdk credential provider', function (done) { + const input = readExtendedJsonToBuffer(`${__dirname}/data/encrypted-document.json`); + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: {} + } + }); + mc.decrypt(input, err => { + expect(err.message).to.equal( + 'client not configured with KMS provider necessary to decrypt' + ); + done(); + }); + }); + }); + + it('should encrypt mock data', function (done) { + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + + mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { + if (err) return done(err); + const expected = EJSON.parse( + JSON.stringify({ + find: 'test', + filter: { + ssn: { + $binary: { + base64: + 'AWFhYWFhYWFhYWFhYWFhYWECRTOW9yZzNDn5dGwuqsrJQNLtgMEKaujhs9aRWRp+7Yo3JK8N8jC8P0Xjll6C1CwLsE/iP5wjOMhVv1KMMyOCSCrHorXRsb2IKPtzl2lKTqQ=', + subType: '6' + } + } + } + }) + ); + + expect(encrypted).to.containSubset(expected); + done(); + }); + }); + + it('should encrypt mock data with per-context KMS credentials', function (done) { + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + keyVaultNamespace: 'admin.datakeys', + logger: () => { }, + kmsProviders: { + aws: {} + }, + async onKmsProviderRefresh() { + return { aws: { accessKeyId: 'example', secretAccessKey: 'example' } }; + } + }); + + mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { + if (err) return done(err); + const expected = EJSON.parse( + JSON.stringify({ + find: 'test', + filter: { + ssn: { + $binary: { + base64: + 'AWFhYWFhYWFhYWFhYWFhYWECRTOW9yZzNDn5dGwuqsrJQNLtgMEKaujhs9aRWRp+7Yo3JK8N8jC8P0Xjll6C1CwLsE/iP5wjOMhVv1KMMyOCSCrHorXRsb2IKPtzl2lKTqQ=', + subType: '6' + } + } + } + }) + ); + + expect(encrypted).to.containSubset(expected); + done(); + }); + }); + }); + + describe('logging', function () { + it('should allow registration of a log handler', function (done) { + ENABLE_LOG_TEST = true; + + let loggerCalled = false; + const logger = (level, message) => { + if (loggerCalled) return; + + loggerCalled = true; + expect(level).to.be.oneOf([2, 3]); + expect(message).to.not.be.empty; + }; + + const client = new MockClient(); + const mc = new AutoEncrypter(client, { + logger, + kmsProviders: { + aws: { accessKeyId: 'example', secretAccessKey: 'example' }, + local: { key: Buffer.alloc(96) } + } + }); + + mc.encrypt('test.test', TEST_COMMAND, (err, encrypted) => { + if (err) return done(err); + const expected = EJSON.parse( + JSON.stringify({ + find: 'test', + filter: { + ssn: '457-55-5462' + } + }) + ); + + expect(encrypted).to.containSubset(expected); + done(); + }); + }); + }); + + it('should provide the libmongocrypt version', function () { + expect(AutoEncrypter.libmongocryptVersion).to.be.a('string'); + }); +}); diff --git a/test/unit/client-side-encryption/clientEncryption.test.js b/test/unit/client-side-encryption/clientEncryption.test.js new file mode 100644 index 00000000000..5927b6e69d7 --- /dev/null +++ b/test/unit/client-side-encryption/clientEncryption.test.js @@ -0,0 +1,358 @@ +'use strict'; +const fs = require('fs'); +const { expect } = require('chai'); +const sinon = require('sinon'); +const { MongoClient } = require('../../../lib/mongo_client'); +const cryptoCallbacks = require('../../../src/client-side-encryption/cryptoCallbacks'); +const { StateMachine } = require('../../../src/client-side-encryption/stateMachine'); +const { ClientEncryption } = require('../../../src/client-side-encryption/clientEncryption') +const { Binary, BSON, deserialize, Long, Int32 } = require('../../mongodb'); +const { EJSON } = BSON; + +const { + MongoCryptCreateEncryptedCollectionError, + MongoCryptCreateDataKeyError +} = require('../../../src/client-side-encryption/errors'); +const { resolve } = require('path'); + +class MockClient { + db(dbName) { + return { + async createCollection(name, options) { + return { namespace: `${dbName}.${name}`, options }; + } + }; + } +} + + +describe('ClientEncryption', function () { + this.timeout(12000); + + + + context('with stubbed key material and fixed random source', function () { + let sandbox = sinon.createSandbox(); + + afterEach(() => { + sandbox.restore(); + }); + beforeEach(() => { + const rndData = Buffer.from( + '\x4d\x06\x95\x64\xf5\xa0\x5e\x9e\x35\x23\xb9\x8f\x57\x5a\xcb\x15', + 'latin1' + ); + let rndPos = 0; + sandbox.stub(cryptoCallbacks, 'randomHook').callsFake((buffer, count) => { + if (rndPos + count > rndData) { + return new Error('Out of fake random data'); + } + buffer.set(rndData.subarray(rndPos, rndPos + count)); + rndPos += count; + return count; + }); + + // stubbed out for AWS unit testing below + sandbox.stub(StateMachine.prototype, 'fetchKeys').callsFake((client, ns, filter, cb) => { + filter = deserialize(filter); + const keyIds = filter.$or[0]._id.$in.map(key => key.toString('hex')); + const fileNames = keyIds.map( + keyId => resolve(`${__dirname}/data/keys/${keyId.toUpperCase()}-local-document.json`) + ); + const contents = fileNames.map(filename => EJSON.parse(fs.readFileSync(filename))); + cb(null, contents); + }); + }); + + // This exactly matches _test_encrypt_fle2_explicit from the C tests + it('should explicitly encrypt and decrypt with the "local" KMS provider (FLE2, exact result)', function () { + const encryption = new ClientEncryption(new MockClient(), { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: Buffer.alloc(96) } } + }); + + const encryptOptions = { + keyId: new Binary(Buffer.from('ABCDEFAB123498761234123456789012', 'hex'), 4), + algorithm: 'Unindexed' + }; + + return encryption + .encrypt('value123', encryptOptions) + .then(encrypted => { + expect(encrypted._bsontype).to.equal('Binary'); + expect(encrypted.sub_type).to.equal(6); + return encryption.decrypt(encrypted); + }) + .then(decrypted => { + expect(decrypted).to.equal('value123'); + }); + }); + }); + + it('should provide the libmongocrypt version', function () { + expect(ClientEncryption.libmongocryptVersion).to.be.a('string'); + }); + + describe('createEncryptedCollection()', () => { + /** @type {InstanceType} */ + let clientEncryption; + const client = new MockClient(); + let db; + const collectionName = 'secure'; + + beforeEach(async function () { + clientEncryption = new ClientEncryption(client, { + keyVaultNamespace: 'client.encryption', + kmsProviders: { local: { key: Buffer.alloc(96, 0) } } + }); + + db = client.db('createEncryptedCollectionDb'); + }); + + afterEach(async () => { + sinon.restore(); + }); + + context('validates input', () => { + it('throws TypeError if options are omitted', async () => { + const error = await clientEncryption + .createEncryptedCollection(db, collectionName) + .catch(error => error); + expect(error).to.be.instanceOf(TypeError, /provider/); + }); + + it('throws TypeError if options.createCollectionOptions are omitted', async () => { + const error = await clientEncryption + .createEncryptedCollection(db, collectionName, {}) + .catch(error => error); + expect(error).to.be.instanceOf(TypeError, /encryptedFields/); + }); + + it('throws TypeError if options.createCollectionOptions.encryptedFields are omitted', async () => { + const error = await clientEncryption + .createEncryptedCollection(db, collectionName, { createCollectionOptions: {} }) + .catch(error => error); + expect(error).to.be.instanceOf(TypeError, /Cannot read properties/); + }); + }); + + context('when options.encryptedFields.fields is not an array', () => { + it('does not generate any encryption keys', async () => { + const createCollectionSpy = sinon.spy(db, 'createCollection'); + const createDataKeySpy = sinon.spy(clientEncryption, 'createDataKey'); + await clientEncryption.createEncryptedCollection(db, collectionName, { + createCollectionOptions: { encryptedFields: { fields: 'not an array' } } + }); + + expect(createDataKeySpy.callCount).to.equal(0); + const options = createCollectionSpy.getCall(0).args[1]; + expect(options).to.deep.equal({ encryptedFields: { fields: 'not an array' } }); + }); + }); + + context('when options.encryptedFields.fields elements are not objects', () => { + it('they are passed along to createCollection', async () => { + const createCollectionSpy = sinon.spy(db, 'createCollection'); + const keyId = new Binary(Buffer.alloc(16, 0)); + const createDataKeyStub = sinon.stub(clientEncryption, 'createDataKey').resolves(keyId); + await clientEncryption.createEncryptedCollection(db, collectionName, { + createCollectionOptions: { + encryptedFields: { fields: ['not an array', { keyId: null }, { keyId: {} }] } + } + }); + + expect(createDataKeyStub.callCount).to.equal(1); + const options = createCollectionSpy.getCall(0).args[1]; + expect(options).to.deep.equal({ + encryptedFields: { fields: ['not an array', { keyId: keyId }, { keyId: {} }] } + }); + }); + }); + + it('only passes options.masterKey to createDataKey', async () => { + const masterKey = Symbol('key'); + const createDataKey = sinon + .stub(clientEncryption, 'createDataKey') + .resolves(new Binary(Buffer.alloc(16, 0))); + const result = await clientEncryption.createEncryptedCollection(db, collectionName, { + provider: 'aws', + createCollectionOptions: { encryptedFields: { fields: [{}] } }, + masterKey + }); + expect(result).to.have.property('collection'); + expect(createDataKey).to.have.been.calledOnceWithExactly('aws', { masterKey }); + }); + + context('when createDataKey rejects', () => { + const customErrorEvil = new Error('evil!'); + const customErrorGood = new Error('good!'); + const keyId = new Binary(Buffer.alloc(16, 0), 4); + const createCollectionOptions = { + encryptedFields: { fields: [{}, {}, { keyId: 'cool id!' }, {}] } + }; + const createDataKeyRejection = async () => { + const stub = sinon.stub(clientEncryption, 'createDataKey'); + stub.onCall(0).resolves(keyId); + stub.onCall(1).rejects(customErrorEvil); + stub.onCall(2).rejects(customErrorGood); + stub.onCall(4).resolves(keyId); + + const error = await clientEncryption + .createEncryptedCollection(db, collectionName, { + provider: 'local', + createCollectionOptions + }) + .catch(error => error); + + // At least make sure the function did not succeed + expect(error).to.be.instanceOf(Error); + + return error; + }; + + it('throws MongoCryptCreateDataKeyError', async () => { + const error = await createDataKeyRejection(); + expect(error).to.be.instanceOf(MongoCryptCreateDataKeyError); + }); + + it('thrown error has a cause set to the first error that was thrown from createDataKey', async () => { + const error = await createDataKeyRejection(); + expect(error.cause).to.equal(customErrorEvil); + expect(error.message).to.include(customErrorEvil.message); + }); + + it('thrown error contains partially filled encryptedFields.fields', async () => { + const error = await createDataKeyRejection(); + expect(error.encryptedFields).property('fields').that.is.an('array'); + expect(error.encryptedFields.fields).to.have.lengthOf( + createCollectionOptions.encryptedFields.fields.length + ); + expect(error.encryptedFields.fields).to.have.nested.property('[0].keyId', keyId); + expect(error.encryptedFields.fields).to.not.have.nested.property('[1].keyId'); + expect(error.encryptedFields.fields).to.have.nested.property('[2].keyId', 'cool id!'); + }); + }); + + context('when createCollection rejects', () => { + const customError = new Error('evil!'); + const keyId = new Binary(Buffer.alloc(16, 0), 4); + const createCollectionRejection = async () => { + const stubCreateDataKey = sinon.stub(clientEncryption, 'createDataKey'); + stubCreateDataKey.onCall(0).resolves(keyId); + stubCreateDataKey.onCall(1).resolves(keyId); + stubCreateDataKey.onCall(2).resolves(keyId); + + sinon.stub(db, 'createCollection').rejects(customError); + + const createCollectionOptions = { + encryptedFields: { fields: [{}, {}, { keyId: 'cool id!' }] } + }; + const error = await clientEncryption + .createEncryptedCollection(db, collectionName, { + provider: 'local', + createCollectionOptions + }) + .catch(error => error); + + // At least make sure the function did not succeed + expect(error).to.be.instanceOf(Error); + + return error; + }; + + it('throws MongoCryptCreateEncryptedCollectionError', async () => { + const error = await createCollectionRejection(); + expect(error).to.be.instanceOf(MongoCryptCreateEncryptedCollectionError); + }); + + it('thrown error has a cause set to the error that was thrown from createCollection', async () => { + const error = await createCollectionRejection(); + expect(error.cause).to.equal(customError); + expect(error.message).to.include(customError.message); + }); + + it('thrown error contains filled encryptedFields.fields', async () => { + const error = await createCollectionRejection(); + expect(error.encryptedFields).property('fields').that.is.an('array'); + expect(error.encryptedFields.fields).to.have.nested.property('[0].keyId', keyId); + expect(error.encryptedFields.fields).to.have.nested.property('[1].keyId', keyId); + expect(error.encryptedFields.fields).to.have.nested.property('[2].keyId', 'cool id!'); + }); + }); + + context('when there are nullish keyIds in the encryptedFields.fields array', function () { + it('does not mutate the input fields array when generating data keys', async () => { + const encryptedFields = Object.freeze({ + escCollection: 'esc', + eccCollection: 'ecc', + ecocCollection: 'ecoc', + fields: Object.freeze([ + Object.freeze({ keyId: false }), + Object.freeze({ + keyId: null, + path: 'name', + bsonType: 'int', + queries: Object.freeze({ contentionFactor: 0 }) + }), + null + ]) + }); + + const keyId = new Binary(Buffer.alloc(16, 0), 4); + sinon.stub(clientEncryption, 'createDataKey').resolves(keyId); + + const { collection, encryptedFields: resultEncryptedFields } = + await clientEncryption.createEncryptedCollection(db, collectionName, { + provider: 'local', + createCollectionOptions: { + encryptedFields + } + }); + + expect(collection).to.have.property('namespace', 'createEncryptedCollectionDb.secure'); + expect(encryptedFields, 'original encryptedFields should be unmodified').nested.property( + 'fields[0].keyId', + false + ); + expect( + resultEncryptedFields, + 'encryptedFields created by helper should have replaced nullish keyId' + ).nested.property('fields[1].keyId', keyId); + expect(encryptedFields, 'original encryptedFields should be unmodified').nested.property( + 'fields[2]', + null + ); + }); + + it('generates dataKeys for all null keyIds in the fields array', async () => { + const encryptedFields = Object.freeze({ + escCollection: 'esc', + eccCollection: 'ecc', + ecocCollection: 'ecoc', + fields: Object.freeze([ + Object.freeze({ keyId: null }), + Object.freeze({ keyId: null }), + Object.freeze({ keyId: null }) + ]) + }); + + const keyId = new Binary(Buffer.alloc(16, 0), 4); + sinon.stub(clientEncryption, 'createDataKey').resolves(keyId); + + const { collection, encryptedFields: resultEncryptedFields } = + await clientEncryption.createEncryptedCollection(db, collectionName, { + provider: 'local', + createCollectionOptions: { + encryptedFields + } + }); + + expect(collection).to.have.property('namespace', 'createEncryptedCollectionDb.secure'); + expect(resultEncryptedFields.fields).to.have.lengthOf(3); + expect(resultEncryptedFields.fields.filter(({ keyId }) => keyId === null)).to.have.lengthOf( + 0 + ); + }); + }); + }); +}); diff --git a/encryption/test/common.test.js b/test/unit/client-side-encryption/common.test.js similarity index 97% rename from encryption/test/common.test.js rename to test/unit/client-side-encryption/common.test.js index 7b1b7dadd67..247c593a703 100644 --- a/encryption/test/common.test.js +++ b/test/unit/client-side-encryption/common.test.js @@ -1,7 +1,7 @@ 'use strict'; const { expect } = require('chai'); -const maybeCallback = require('../lib/common').maybeCallback; +const maybeCallback = require('../../../src/client-side-encryption/common').maybeCallback; describe('maybeCallback()', () => { it('should accept two arguments', () => { diff --git a/test/unit/client-side-encryption/data/README.md b/test/unit/client-side-encryption/data/README.md new file mode 100644 index 00000000000..6f6a572b0c6 --- /dev/null +++ b/test/unit/client-side-encryption/data/README.md @@ -0,0 +1,21 @@ +This directory contains Data Encryption Key (DEKs) encrypted by various Key Encryption Keys (KEKs) for testing. + +Files are named as follows: + +- `-key-material.txt` is the decrypted key material. +- `-local-document.json` is a key document with "_id" of encrypted with a local KEK. +- `-aws-document.json` is a key document with "_id" of encrypted with an AWS KEK. +- `-aws-decrypt-reply.txt` is an HTTP reply from AWS KMS decrypting the DEK. + +The key material of the local KEK 96 bytes of 0. + +The `csfle` CLI tool was used to generate output for these files. Here is an example command used for creating a "-aws-document.json" file: + +```bash +./cmake-build/csfle create_datakey \ + --kms_providers_file ~/.csfle/kms_providers.json \ + --kms_provider aws \ + --aws_kek_region us-east-1 \ + --aws_kek_key 'arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0' \ + --key_material "p928TIvgDVH2jZ2OSF81HI7cjSIGsk2ODhgW0AX75SDkiRJQR9ZHsNhoS/vb8JwwQIXtCGq6bCsrFnfMyRztiEenM79eVoLISz7nlp5KX+Dgwh5ePuGQWVpV+DFH2N4q" +``` diff --git a/encryption/test/data/cmd.json b/test/unit/client-side-encryption/data/cmd.json similarity index 100% rename from encryption/test/data/cmd.json rename to test/unit/client-side-encryption/data/cmd.json diff --git a/encryption/test/data/collection-info.json b/test/unit/client-side-encryption/data/collection-info.json similarity index 100% rename from encryption/test/data/collection-info.json rename to test/unit/client-side-encryption/data/collection-info.json diff --git a/encryption/test/data/encrypted-document-nested.json b/test/unit/client-side-encryption/data/encrypted-document-nested.json similarity index 100% rename from encryption/test/data/encrypted-document-nested.json rename to test/unit/client-side-encryption/data/encrypted-document-nested.json diff --git a/encryption/test/data/encrypted-document.json b/test/unit/client-side-encryption/data/encrypted-document.json similarity index 100% rename from encryption/test/data/encrypted-document.json rename to test/unit/client-side-encryption/data/encrypted-document.json diff --git a/encryption/test/data/encryptedFields.json b/test/unit/client-side-encryption/data/encryptedFields.json similarity index 100% rename from encryption/test/data/encryptedFields.json rename to test/unit/client-side-encryption/data/encryptedFields.json diff --git a/encryption/test/data/key-document.json b/test/unit/client-side-encryption/data/key-document.json similarity index 100% rename from encryption/test/data/key-document.json rename to test/unit/client-side-encryption/data/key-document.json diff --git a/encryption/test/data/key1-document.json b/test/unit/client-side-encryption/data/key1-document.json similarity index 100% rename from encryption/test/data/key1-document.json rename to test/unit/client-side-encryption/data/key1-document.json diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-aws-decrypt-reply.txt b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-aws-decrypt-reply.txt new file mode 100644 index 00000000000..a11b07d1e68 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-aws-decrypt-reply.txt @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +x-amzn-RequestId: 4d58d836-9a21-4ce8-a222-d90f89bac7dd +Cache-Control: no-cache, no-store, must-revalidate, private +Expires: 0 +Pragma: no-cache +Date: Sat, 02 Apr 2022 15:45:27 GMT +Content-Type: application/x-amz-json-1.1 +Content-Length: 272 +Connection: close + +{"EncryptionAlgorithm":"SYMMETRIC_DEFAULT","KeyId":"arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0","Plaintext":"fb/rxhmqaKZZ9kuOI8zSFkSsMmy3SiaEDD0kIBdsQK4IgpTQCtbK6WhCN7IbdUz1A/CFwlzTIL8DXDQXQW4eb+PZIZ95WGWCESdAsq3YjhAw2Rkmror8E+5XXPuLuWW3"} \ No newline at end of file diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-aws-document.json b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-aws-document.json new file mode 100644 index 00000000000..aa01dadf789 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-aws-document.json @@ -0,0 +1,32 @@ +{ + "_id": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "AQICAHhQNmWG2CzOm1dq3kWLM+iDUZhEqnhJwH9wZVpuZ94A8gG69plztrxKXh9M8C54okakAAAAwjCBvwYJKoZIhvcNAQcGoIGxMIGuAgEAMIGoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDN5KpQl1MfBbXDngKQIBEIB7zzdKA1fG4Jaqte1TRUzAB7zbS6sr1og+T5lo/WDXjELnvvsfWflvrEK/Dc1dnpSldVgkidjdT//H6HX5cJ+aw2CgNZz2F15PqEvU1h2QKGBbs4Omhb0L8jBv5fMa/9nenxl5j5y4wO0XPGtiV2G/dUztYNB6qz0YhdTl", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1648914327152" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1648914327152" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "aws", + "region": "us-east-1", + "key": "arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0" + } +} diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-key-material.txt b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-key-material.txt new file mode 100644 index 00000000000..ad56fed21fb --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-key-material.txt @@ -0,0 +1 @@ +7dbfebc619aa68a659f64b8e23ccd21644ac326cb74a26840c3d2420176c40ae088294d00ad6cae9684237b21b754cf503f085c25cd320bf035c3417416e1e6fe3d9219f79586582112740b2add88e1030d91926ae8afc13ee575cfb8bb965b7 diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-local-document.json b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-local-document.json new file mode 100644 index 00000000000..7a3dfda474b --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789012-local-document.json @@ -0,0 +1,30 @@ +{ + "_id": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "1ZbBTB1i/z4LcmBKi9+nnWqkVB4Wl6P4G7/TFQvXATRF2fX0lhBLIM6rT1U547FX2YgMtaP7sid+jpd4Vhz5kS+UlgtmCFfjeO4qOnJ78KEXRzeIebzKWKQz1pMhYZ3OURDL4wCtNqt3tbSr11kfTADmCMuzgp8U8P8T21RWWBU0f2XDcxiIShYncOS3poKu7GJaPCTav4r3h5h2xRklDA==", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1648914851981" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1648914851981" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "local" + } +} diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-aws-decrypt-reply.txt b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-aws-decrypt-reply.txt new file mode 100644 index 00000000000..c89ae5f5af4 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-aws-decrypt-reply.txt @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +x-amzn-RequestId: 61524068-10fc-458d-8c85-aa23fd58ff56 +Cache-Control: no-cache, no-store, must-revalidate, private +Expires: 0 +Pragma: no-cache +Date: Fri, 29 Apr 2022 13:21:22 GMT +Content-Type: application/x-amz-json-1.1 +Content-Length: 294 +Connection: close + +{"EncryptionAlgorithm":"SYMMETRIC_DEFAULT","KeyId":"arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0","KeyOrigin":"AWS_KMS","Plaintext":"H2XDIj1WU829c8Eaj4VYeq/L1b5+TDCNNXsvAbvPdqmAKTDl8jOSO7w/Xr0L4duYB/BKqHDIlgkhgN2LBYFrj3Vo/3YqGk79NbvAKCY5TrMPNs2ODGRq4vQ99CDlChnr"} \ No newline at end of file diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-aws-document.json b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-aws-document.json new file mode 100644 index 00000000000..4dc7ce0b01d --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-aws-document.json @@ -0,0 +1,32 @@ +{ + "_id": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEw==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "AQICAHhQNmWG2CzOm1dq3kWLM+iDUZhEqnhJwH9wZVpuZ94A8gG7JTEihw4JH7KZ7aFD677EAAAAwjCBvwYJKoZIhvcNAQcGoIGxMIGuAgEAMIGoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDANc1smIZE/xHp5+kwIBEIB78OFCdWkE1363uAER/mTCM6ccm0UZbjHXJfLCIvy52tiYLkUsykDXCcFoLaZ4LgK/6bm9816iEthHMKHMdHrn7kVEk+2H0A9TWzW6d5lvN3IT4qNKNy5dzH1ayufINYt/FnH+316Z/HQxvlG4Uf+ajPM21G5/PIUUe0hZ", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1651238481951" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1651238481951" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "aws", + "region": "us-east-1", + "key": "arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0" + } +} \ No newline at end of file diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-key-material.txt b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-key-material.txt new file mode 100644 index 00000000000..00e43398134 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-key-material.txt @@ -0,0 +1 @@ +1f65c3223d5653cdbd73c11a8f85587aafcbd5be7e4c308d357b2f01bbcf76a9802930e5f233923bbc3f5ebd0be1db9807f04aa870c896092180dd8b05816b8f7568ff762a1a4efd35bbc02826394eb30f36cd8e0c646ae2f43df420e50a19eb diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-local-document.json b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-local-document.json new file mode 100644 index 00000000000..0b18522accc --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789013-local-document.json @@ -0,0 +1,30 @@ +{ + "_id": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQEw==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "YQXu48YyDbXvVQ1OhPsodQQNA1gLVWZSV0udYVmCTpVrgyAZePHQmsWWnQzNZj+ZsTxRm02soje/FJCqWGLeth3gKdvIsRg15CDEUOqLdDEpHl46hadosXyJIfo0umZ/LVTkvxRhmDCDxAkd0+Dg4/vWSiG0FgNzGrlvOUsTLGbqWtNMuOdZ8pKAdnFRrqce5cwBGQmd2VVBA2OQ0/IMxQ==", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1650631142512" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1650631142512" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "local" + } +} diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789014-key-material.txt b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789014-key-material.txt new file mode 100644 index 00000000000..00e43398134 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789014-key-material.txt @@ -0,0 +1 @@ +1f65c3223d5653cdbd73c11a8f85587aafcbd5be7e4c308d357b2f01bbcf76a9802930e5f233923bbc3f5ebd0be1db9807f04aa870c896092180dd8b05816b8f7568ff762a1a4efd35bbc02826394eb30f36cd8e0c646ae2f43df420e50a19eb diff --git a/test/unit/client-side-encryption/data/keys/12345678123498761234123456789014-local-document.json b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789014-local-document.json new file mode 100644 index 00000000000..7cccd5b7b7a --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/12345678123498761234123456789014-local-document.json @@ -0,0 +1,30 @@ +{ + "_id": { + "$binary": { + "base64": "EjRWeBI0mHYSNBI0VniQFA==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "huOrOjM8DWLRP43+oeYYFePEbHVYFgLSBAI6OPTDxW9AhegvodHyTLW9GD3QjhF0QnKqRiCEsBSbVEuGHxGD3Mf946/JYl9Ky8OsMzGeIoKXoxZsdztUDoW7TSWM4joPeVM+0GpmkEWgIPwWX9V9lnRv60CL+USOdOoK3FCOH+8u5NdjtdeDTAUFtbasZRW+r/5P/4LvnGuIfN/CIv7Ovg==", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1650631261637" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1650631261637" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "local" + } +} diff --git a/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-aws-decrypt-reply.txt b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-aws-decrypt-reply.txt new file mode 100644 index 00000000000..9bf9f607585 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-aws-decrypt-reply.txt @@ -0,0 +1,11 @@ +HTTP/1.1 200 OK +x-amzn-RequestId: adcfdbde-7213-4fcf-a312-370e1d0ccaf0 +Cache-Control: no-cache, no-store, must-revalidate, private +Expires: 0 +Pragma: no-cache +Date: Sat, 02 Apr 2022 16:00:00 GMT +Content-Type: application/x-amz-json-1.1 +Content-Length: 272 +Connection: close + +{"EncryptionAlgorithm":"SYMMETRIC_DEFAULT","KeyId":"arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0","Plaintext":"p928TIvgDVH2jZ2OSF81HI7cjSIGsk2ODhgW0AX75SDkiRJQR9ZHsNhoS/vb8JwwQIXtCGq6bCsrFnfMyRztiEenM79eVoLISz7nlp5KX+Dgwh5ePuGQWVpV+DFH2N4q"} \ No newline at end of file diff --git a/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-aws-document.json b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-aws-document.json new file mode 100644 index 00000000000..103909dcb9a --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-aws-document.json @@ -0,0 +1,32 @@ +{ + "_id": { + "$binary": { + "base64": "q83vqxI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "AQICAHhQNmWG2CzOm1dq3kWLM+iDUZhEqnhJwH9wZVpuZ94A8gECARBA9u8bNDBpw/x3UP0GAAAAwjCBvwYJKoZIhvcNAQcGoIGxMIGuAgEAMIGoBgkqhkiG9w0BBwEwHgYJYIZIAWUDBAEuMBEEDGo4wmA5gMfyL7UCoQIBEIB7k7qshUFeiG8B1dlxR0zjcbM8z7tKHlC+mH6IJ9tdSncldQ1Mb+Ur28J2OF9IVxA5poU/HV+JbI0unldTWJ57k70wu11k5v7PU0gikw0OCQiBdfwaoC9MvvpJ6r4t+15Rf0G35jSk7ZCPZa8dwYuzEoAoKJhxNuUjDhVY", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1648915200516" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1648915200516" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "aws", + "region": "us-east-1", + "key": "arn:aws:kms:us-east-1:579766882180:key/89fcc2c4-08b0-4bd9-9f25-e30687b580d0" + } +} diff --git a/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-key-material.txt b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-key-material.txt new file mode 100644 index 00000000000..8ad33e57e09 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-key-material.txt @@ -0,0 +1 @@ +a7ddbc4c8be00d51f68d9d8e485f351c8edc8d2206b24d8e0e1816d005fbe520e489125047d647b0d8684bfbdbf09c304085ed086aba6c2b2b1677ccc91ced8847a733bf5e5682c84b3ee7969e4a5fe0e0c21e5e3ee190595a55f83147d8de2a diff --git a/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-local-document.json b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-local-document.json new file mode 100644 index 00000000000..e5d1a3f7661 --- /dev/null +++ b/test/unit/client-side-encryption/data/keys/ABCDEFAB123498761234123456789012-local-document.json @@ -0,0 +1,30 @@ +{ + "_id": { + "$binary": { + "base64": "q83vqxI0mHYSNBI0VniQEg==", + "subType": "04" + } + }, + "keyMaterial": { + "$binary": { + "base64": "27OBvUqHAuYFy60nwCdvq2xmZ4kFzVySphXzBGq+HEot13comCoydEfnltBzLTuXLbV9cnREFJIO5f0jMqrlkxIuvAV8yO84p5VJTEa8j/xSNe7iA594rx7UeKT0fOt4VqM47fht8h+8PZYc5JVezvEMvwk115IBCwENxDjLtT0g+y8Hf+aTUEGtxrYToH8zf1/Y7S16mHiIc4jK3/vxHw==", + "subType": "00" + } + }, + "creationDate": { + "$date": { + "$numberLong": "1648915408923" + } + }, + "updateDate": { + "$date": { + "$numberLong": "1648915408923" + } + }, + "status": { + "$numberInt": "0" + }, + "masterKey": { + "provider": "local" + } +} diff --git a/encryption/test/data/kms-decrypt-reply.txt b/test/unit/client-side-encryption/data/kms-decrypt-reply.txt similarity index 100% rename from encryption/test/data/kms-decrypt-reply.txt rename to test/unit/client-side-encryption/data/kms-decrypt-reply.txt diff --git a/encryption/test/data/kms-encrypt-reply.txt b/test/unit/client-side-encryption/data/kms-encrypt-reply.txt similarity index 100% rename from encryption/test/data/kms-encrypt-reply.txt rename to test/unit/client-side-encryption/data/kms-encrypt-reply.txt diff --git a/encryption/test/data/mongocryptd-reply.json b/test/unit/client-side-encryption/data/mongocryptd-reply.json similarity index 100% rename from encryption/test/data/mongocryptd-reply.json rename to test/unit/client-side-encryption/data/mongocryptd-reply.json diff --git a/encryption/test/mongocryptdManager.test.js b/test/unit/client-side-encryption/mongocryptdManager.test.js similarity index 93% rename from encryption/test/mongocryptdManager.test.js rename to test/unit/client-side-encryption/mongocryptdManager.test.js index 17f86879286..19529a19d39 100644 --- a/encryption/test/mongocryptdManager.test.js +++ b/test/unit/client-side-encryption/mongocryptdManager.test.js @@ -1,6 +1,6 @@ 'use strict'; -const MongocryptdManager = require('../lib/mongocryptdManager').MongocryptdManager; +const MongocryptdManager = require('../../../src/client-side-encryption/mongocryptdManager').MongocryptdManager; const { expect } = require('chai'); describe('MongocryptdManager', function () { diff --git a/encryption/test/providers/credentialsProvider.test.js b/test/unit/client-side-encryption/providers/credentialsProvider.test.js similarity index 98% rename from encryption/test/providers/credentialsProvider.test.js rename to test/unit/client-side-encryption/providers/credentialsProvider.test.js index abd15d15445..e23a7b06ca8 100644 --- a/encryption/test/providers/credentialsProvider.test.js +++ b/test/unit/client-side-encryption/providers/credentialsProvider.test.js @@ -3,14 +3,14 @@ const { expect } = require('chai'); const http = require('http'); const requirements = require('../requirements.helper'); -const { loadCredentials, isEmptyCredentials } = require('../../lib/providers'); -const { tokenCache, fetchAzureKMSToken } = require('../../lib/providers/azure'); +const { loadCredentials, isEmptyCredentials } = require('../../../../src/client-side-encryption/providers'); +const { tokenCache, fetchAzureKMSToken } = require('../../../../src/client-side-encryption/providers/azure'); const sinon = require('sinon'); -const utils = require('../../lib/providers/utils'); +const utils = require('../../../../src/client-side-encryption/providers/utils'); const { MongoCryptKMSRequestNetworkTimeoutError, MongoCryptAzureKMSRequestError -} = require('../../lib/errors'); +} = require('../../../../src/client-side-encryption/errors'); const originalAccessKeyId = process.env.AWS_ACCESS_KEY_ID; const originalSecretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; diff --git a/encryption/test/requirements.helper.js b/test/unit/client-side-encryption/requirements.helper.js similarity index 83% rename from encryption/test/requirements.helper.js rename to test/unit/client-side-encryption/requirements.helper.js index 9dc5711c16f..a456c49197c 100644 --- a/encryption/test/requirements.helper.js +++ b/test/unit/client-side-encryption/requirements.helper.js @@ -11,9 +11,7 @@ const awsKmsProviders = { }; const awsDataKeyOptions = { masterKey: { key: AWS_CMK_ID, region: AWS_REGION } }; -const SKIP_LIVE_TESTS = !!process.env.MONGODB_NODE_SKIP_LIVE_TESTS; -const SKIP_AWS_TESTS = - SKIP_LIVE_TESTS || !AWS_ACCESS_KEY_ID || !AWS_SECRET_ACCESS_KEY || !AWS_REGION || !AWS_CMK_ID; +const SKIP_AWS_TESTS = [AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, AWS_CMK_ID].some(secret => !secret); function isAWSCredentialProviderInstalled() { try { @@ -34,7 +32,6 @@ function isGCPCredentialProviderInstalled() { } module.exports = { - SKIP_LIVE_TESTS, SKIP_AWS_TESTS, KEYS: { AWS_ACCESS_KEY_ID, diff --git a/encryption/test/stateMachine.test.js b/test/unit/client-side-encryption/stateMachine.test.js similarity index 94% rename from encryption/test/stateMachine.test.js rename to test/unit/client-side-encryption/stateMachine.test.js index 31d96ba68ae..63ab990861c 100644 --- a/encryption/test/stateMachine.test.js +++ b/test/unit/client-side-encryption/stateMachine.test.js @@ -6,9 +6,10 @@ const tls = require('tls'); const fs = require('fs'); const { expect } = require('chai'); const sinon = require('sinon'); -const mongodb = require('mongodb'); -const BSON = mongodb.BSON; -const StateMachine = require('../lib/stateMachine')({ mongodb }).StateMachine; +const { serialize, Long, Int32 } = require('../../mongodb'); +const { StateMachine } = require('../../../src/client-side-encryption/stateMachine'); +const { Db } = require('../../../src/db'); +const { MongoClient } = require('../../../src/mongo_client'); describe('StateMachine', function () { class MockRequest { @@ -44,22 +45,22 @@ describe('StateMachine', function () { beforeEach(function () { this.sinon = sinon.createSandbox(); runCommandStub = this.sinon.stub().resolves({}); - dbStub = this.sinon.createStubInstance(mongodb.Db, { + dbStub = this.sinon.createStubInstance(Db, { command: runCommandStub }); - clientStub = this.sinon.createStubInstance(mongodb.MongoClient, { + clientStub = this.sinon.createStubInstance(MongoClient, { db: dbStub }); }); const command = { encryptedFields: {}, - a: new BSON.Long('0'), - b: new BSON.Int32(0) + a: new Long('0'), + b: new Int32(0) }; const options = { promoteLongs: false, promoteValues: false }; - const serializedCommand = BSON.serialize(command); - const stateMachine = new StateMachine({ bson: BSON }); + const serializedCommand = serialize(command); + const stateMachine = new StateMachine(); const callback = () => {}; context('when executing the command', function () { @@ -97,7 +98,7 @@ describe('StateMachine', function () { }); it('should only resolve once bytesNeeded drops to zero', function (done) { - const stateMachine = new StateMachine({ bson: BSON }); + const stateMachine = new StateMachine(); const request = new MockRequest(Buffer.from('foobar'), 500); let status = 'pending'; stateMachine @@ -139,7 +140,6 @@ describe('StateMachine', function () { ].forEach(function (option) { context(`when the option is ${option}`, function () { const stateMachine = new StateMachine({ - bson: BSON, tlsOptions: { aws: { [option]: true } } }); const request = new MockRequest(Buffer.from('foobar'), 500); @@ -157,7 +157,6 @@ describe('StateMachine', function () { context('when the options are secure', function () { context('when providing tlsCertificateKeyFile', function () { const stateMachine = new StateMachine({ - bson: BSON, tlsOptions: { aws: { tlsCertificateKeyFile: 'test.pem' } } }); const request = new MockRequest(Buffer.from('foobar'), -1); @@ -185,7 +184,6 @@ describe('StateMachine', function () { context('when providing tlsCAFile', function () { const stateMachine = new StateMachine({ - bson: BSON, tlsOptions: { aws: { tlsCAFile: 'test.pem' } } }); const request = new MockRequest(Buffer.from('foobar'), -1); @@ -212,7 +210,6 @@ describe('StateMachine', function () { context('when providing tlsCertificateKeyFilePassword', function () { const stateMachine = new StateMachine({ - bson: BSON, tlsOptions: { aws: { tlsCertificateKeyFilePassword: 'test' } } }); const request = new MockRequest(Buffer.from('foobar'), -1); @@ -285,7 +282,6 @@ describe('StateMachine', function () { it('should create HTTPS connections through a Socks5 proxy (no proxy auth)', async function () { const stateMachine = new StateMachine({ - bson: BSON, proxyOptions: { proxyHost: 'localhost', proxyPort: socks5srv.address().port @@ -307,7 +303,6 @@ describe('StateMachine', function () { it('should create HTTPS connections through a Socks5 proxy (username/password auth)', async function () { withUsernamePassword = true; const stateMachine = new StateMachine({ - bson: BSON, proxyOptions: { proxyHost: 'localhost', proxyPort: socks5srv.address().port, diff --git a/tsconfig.json b/tsconfig.json index b0fd401f79e..c8b6b54e07a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "allowJs": false, + "allowJs": true, "checkJs": false, "strict": true, "alwaysStrict": true,