diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index d941ffe7b..1224f3e29 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -151,11 +151,7 @@ export interface KMSProviders { * - tlsDisableOCSPEndpointCheck * - tlsDisableCertificateRevocationCheck */ -export interface ClientEncryptionTLSOptions { - /** - * Enables or disables TLS/SSL for the connection. - */ - tls?: boolean; +export interface ClientEncryptionTlsOptions { /** * Specifies the location of a local .pem file that contains * either the client's TLS/SSL certificate and key or only the @@ -203,7 +199,7 @@ export interface ClientEncryptionOptions { /** * TLS options for kms providers to use. */ - tlsOptions?: ClientEncryptionTLSOptions; + tlsOptions?: { [kms in keyof KMSProviders]?: ClientEncryptionTLSOptions }; } /** diff --git a/bindings/node/lib/autoEncrypter.js b/bindings/node/lib/autoEncrypter.js index 2c78c07a8..23cc1a010 100644 --- a/bindings/node/lib/autoEncrypter.js +++ b/bindings/node/lib/autoEncrypter.js @@ -106,6 +106,7 @@ module.exports = function(modules) { this._keyVaultClient = options.keyVaultClient || client; this._metaDataClient = options.metadataClient || client; this._proxyOptions = options.proxyOptions || {}; + this._tlsOptions = options.tlsOptions || {}; const mongoCryptOptions = {}; if (options.schemaMap) { @@ -213,7 +214,12 @@ module.exports = function(modules) { context.ns = ns; context.document = cmd; - const stateMachine = new StateMachine({ bson, ...options, proxyOptions: this._proxyOptions }); + const stateMachine = new StateMachine({ + bson, + ...options, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); stateMachine.execute(this, context, callback); } @@ -244,7 +250,12 @@ module.exports = function(modules) { // TODO: should this be an accessor from the addon? context.id = this._contextCounter++; - const stateMachine = new StateMachine({ bson, ...options, proxyOptions: this._proxyOptions }); + const stateMachine = new StateMachine({ + bson, + ...options, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); stateMachine.execute(this, context, callback); } } diff --git a/bindings/node/lib/clientEncryption.js b/bindings/node/lib/clientEncryption.js index 521de535e..920253b0c 100644 --- a/bindings/node/lib/clientEncryption.js +++ b/bindings/node/lib/clientEncryption.js @@ -38,6 +38,7 @@ module.exports = function(modules) { * @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 * @@ -66,6 +67,7 @@ module.exports = function(modules) { this._client = client; this._bson = options.bson || client.topology.bson; this._proxyOptions = options.proxyOptions; + this._tlsOptions = options.tlsOptions; if (options.keyVaultNamespace == null) { throw new TypeError('Missing required option `keyVaultNamespace`'); @@ -199,7 +201,11 @@ module.exports = function(modules) { const dataKeyBson = bson.serialize(dataKey); const context = this._mongoCrypt.makeDataKeyContext(dataKeyBson, { keyAltNames }); - const stateMachine = new StateMachine({ bson, proxyOptions: this._proxyOptions }); + const stateMachine = new StateMachine({ + bson, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); return promiseOrCallback(callback, cb => { stateMachine.execute(this, context, (err, dataKey) => { @@ -291,7 +297,11 @@ module.exports = function(modules) { contextOptions.keyAltName = bson.serialize({ keyAltName }); } - const stateMachine = new StateMachine({ bson, proxyOptions: this._proxyOptions }); + const stateMachine = new StateMachine({ + bson, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); const context = this._mongoCrypt.makeExplicitEncryptionContext(valueBuffer, contextOptions); return promiseOrCallback(callback, cb => { @@ -336,7 +346,11 @@ module.exports = function(modules) { const valueBuffer = bson.serialize({ v: value }); const context = this._mongoCrypt.makeExplicitDecryptionContext(valueBuffer); - const stateMachine = new StateMachine({ bson, proxyOptions: this._proxyOptions }); + const stateMachine = new StateMachine({ + bson, + proxyOptions: this._proxyOptions, + tlsOptions: this._tlsOptions + }); return promiseOrCallback(callback, cb => { stateMachine.execute(this, context, (err, result) => { diff --git a/bindings/node/lib/stateMachine.js b/bindings/node/lib/stateMachine.js index 63171a755..f44901b54 100644 --- a/bindings/node/lib/stateMachine.js +++ b/bindings/node/lib/stateMachine.js @@ -3,6 +3,8 @@ module.exports = function(modules) { const tls = require('tls'); const net = require('net'); + const path = require('path'); + const fs = require ('fs'); const { once } = require('events'); const { SocksClient } = require('socks'); @@ -38,6 +40,14 @@ module.exports = function(modules) { [MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE'] ]); + const INSECURE_TLS_OPTIONS = [ + 'tlsInsecure', + 'tlsAllowInvalidCertificates', + 'tlsAllowInvalidHostnames', + 'tlsDisableOCSPEndpointCheck', + 'tlsDisableCertificateRevocationCheck' + ]; + /** * @ignore * @callback StateMachine~executeCallback @@ -283,6 +293,16 @@ module.exports = function(modules) { } } + 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); }); @@ -305,6 +325,44 @@ module.exports = function(modules) { }); } + /** + * @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 diff --git a/bindings/node/package-lock.json b/bindings/node/package-lock.json index c12f88449..4d447f26a 100644 --- a/bindings/node/package-lock.json +++ b/bindings/node/package-lock.json @@ -6,7 +6,7 @@ "packages": { "": { "name": "mongodb-client-encryption", - "version": "2.0.0-beta.1", + "version": "2.0.0-beta.2", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/bindings/node/test/stateMachine.test.js b/bindings/node/test/stateMachine.test.js index c2c9c9e1e..0c975313a 100644 --- a/bindings/node/test/stateMachine.test.js +++ b/bindings/node/test/stateMachine.test.js @@ -4,6 +4,7 @@ const BSON = require('bson'); const { EventEmitter, once } = require('events'); const net = require('net'); const tls = require('tls'); +const fs = require('fs'); const expect = require('chai').expect; const sinon = require('sinon'); const mongodb = require('mongodb'); @@ -52,40 +53,150 @@ describe('StateMachine', function() { this.sinon = sinon.createSandbox(); }); - beforeEach(function() { - this.fakeSocket = undefined; - this.sinon.stub(tls, 'connect').callsFake((options, callback) => { - this.fakeSocket = new MockSocket(callback); - return this.fakeSocket; + context('when handling standard kms requests', function() { + beforeEach(function() { + this.fakeSocket = undefined; + this.sinon.stub(tls, 'connect').callsFake((options, callback) => { + this.fakeSocket = new MockSocket(callback); + return this.fakeSocket; + }); }); - }); - it('should only resolve once bytesNeeded drops to zero', function(done) { - const stateMachine = new StateMachine({ bson: BSON }); - const request = new MockRequest(Buffer.from('foobar'), 500); - let status = 'pending'; - stateMachine - .kmsRequest(request) - .then( - () => (status = 'resolved'), - () => (status = 'rejected') - ) - .catch(() => {}); - - this.fakeSocket.emit('connect'); - setTimeout(() => { - expect(status).to.equal('pending'); - expect(request.bytesNeeded).to.equal(500); - expect(request.kmsProvider).to.equal('aws'); - this.fakeSocket.emit('data', Buffer.alloc(300)); + it('should only resolve once bytesNeeded drops to zero', function(done) { + const stateMachine = new StateMachine({ bson: BSON }); + const request = new MockRequest(Buffer.from('foobar'), 500); + let status = 'pending'; + stateMachine + .kmsRequest(request) + .then( + () => (status = 'resolved'), + () => (status = 'rejected') + ) + .catch(() => {}); + + this.fakeSocket.emit('connect'); setTimeout(() => { expect(status).to.equal('pending'); - expect(request.bytesNeeded).to.equal(200); - this.fakeSocket.emit('data', Buffer.alloc(200)); + expect(request.bytesNeeded).to.equal(500); + expect(request.kmsProvider).to.equal('aws'); + this.fakeSocket.emit('data', Buffer.alloc(300)); setTimeout(() => { - expect(status).to.equal('resolved'); - expect(request.bytesNeeded).to.equal(0); - done(); + expect(status).to.equal('pending'); + expect(request.bytesNeeded).to.equal(200); + this.fakeSocket.emit('data', Buffer.alloc(200)); + setTimeout(() => { + expect(status).to.equal('resolved'); + expect(request.bytesNeeded).to.equal(0); + done(); + }); + }); + }); + }); + }); + + context('when tls options are provided', function() { + context('when the options are insecure', function() { + [ + 'tlsInsecure', + 'tlsAllowInvalidCertificates', + 'tlsAllowInvalidHostnames', + 'tlsDisableOCSPEndpointCheck', + 'tlsDisableCertificateRevocationCheck' + ].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); + + it('rejects with the validation error', function(done) { + stateMachine + .kmsRequest(request) + .catch((err) => { + expect(err.message).to.equal(`Insecure TLS options prohibited for aws: ${option}`); + done(); + }); + }); + }); + }); + }); + + 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); + const buffer = Buffer.from('foobar'); + let connectOptions; + + it('sets the cert and key options in the tls connect options', function(done) { + this.sinon.stub(fs, 'readFileSync').callsFake((fileName) => { + expect(fileName).to.equal('test.pem'); + return buffer; + }); + this.sinon.stub(tls, 'connect').callsFake((options, callback) => { + connectOptions = options; + this.fakeSocket = new MockSocket(callback); + return this.fakeSocket; + }); + stateMachine.kmsRequest(request).then(function() { + expect(connectOptions.cert).to.equal(buffer); + expect(connectOptions.key).to.equal(buffer); + done(); + }); + this.fakeSocket.emit('data', Buffer.alloc(0)); + }); + }); + + context('when providing tlsCAFile', function() { + const stateMachine = new StateMachine({ + bson: BSON, + tlsOptions: { aws: { tlsCAFile: 'test.pem' }} + }); + const request = new MockRequest(Buffer.from('foobar'), -1); + const buffer = Buffer.from('foobar'); + let connectOptions; + + it('sets the ca options in the tls connect options', function(done) { + this.sinon.stub(fs, 'readFileSync').callsFake((fileName) => { + expect(fileName).to.equal('test.pem'); + return buffer; + }); + this.sinon.stub(tls, 'connect').callsFake((options, callback) => { + connectOptions = options; + this.fakeSocket = new MockSocket(callback); + return this.fakeSocket; + }); + stateMachine.kmsRequest(request).then(function() { + expect(connectOptions.ca).to.equal(buffer); + done(); + }); + this.fakeSocket.emit('data', Buffer.alloc(0)); + }); + }); + + context('when providing tlsCertificateKeyFilePassword', function() { + const stateMachine = new StateMachine({ + bson: BSON, + tlsOptions: { aws: { tlsCertificateKeyFilePassword: 'test' }} + }); + const request = new MockRequest(Buffer.from('foobar'), -1); + let connectOptions; + + it('sets the passphrase option in the tls connect options', function(done) { + this.sinon.stub(tls, 'connect').callsFake((options, callback) => { + connectOptions = options; + this.fakeSocket = new MockSocket(callback); + return this.fakeSocket; + }); + stateMachine.kmsRequest(request).then(function() { + expect(connectOptions.passphrase).to.equal('test'); + done(); + }); + this.fakeSocket.emit('data', Buffer.alloc(0)); }); }); });