Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(NODE-3777): set tls options per kms provider #235

Merged
merged 12 commits into from
Jan 20, 2022
8 changes: 2 additions & 6 deletions bindings/node/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -203,7 +199,7 @@ export interface ClientEncryptionOptions {
/**
* TLS options for kms providers to use.
*/
tlsOptions?: ClientEncryptionTLSOptions;
tlsOptions?: { [kms in keyof KMSProviders]?: ClientEncryptionTLSOptions };
}

/**
Expand Down
15 changes: 13 additions & 2 deletions bindings/node/lib/autoEncrypter.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
}
}
Expand Down
19 changes: 16 additions & 3 deletions bindings/node/lib/clientEncryption.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ module.exports = function(modules) {
this._client = client;
durran marked this conversation as resolved.
Show resolved Hide resolved
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`');
Expand Down Expand Up @@ -199,7 +200,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) => {
Expand Down Expand Up @@ -291,7 +296,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 => {
Expand Down Expand Up @@ -336,7 +345,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) => {
Expand Down
58 changes: 58 additions & 0 deletions bindings/node/lib/stateMachine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -38,6 +40,14 @@ module.exports = function(modules) {
[MONGOCRYPT_CTX_DONE, 'MONGOCRYPT_CTX_DONE']
]);

const INSECURE_TLS_OPTIONS = [
durran marked this conversation as resolved.
Show resolved Hide resolved
'tlsInsecure',
'tlsAllowInvalidCertificates',
'tlsAllowInvalidHostnames',
'tlsDisableOCSPEndpointCheck',
'tlsDisableCertificateRevocationCheck'
];

/**
* @ignore
* @callback StateMachine~executeCallback
Expand Down Expand Up @@ -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);
});
Expand All @@ -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}`);
durran marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

/**
* @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) {
durran marked this conversation as resolved.
Show resolved Hide resolved
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
Expand Down
2 changes: 1 addition & 1 deletion bindings/node/package-lock.json

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

169 changes: 140 additions & 29 deletions bindings/node/test/stateMachine.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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));
});
});
});
Expand Down