diff --git a/packages/grpc-native-core/ext/channel_credentials.cc b/packages/grpc-native-core/ext/channel_credentials.cc index c334d5d0..6a2a1dde 100644 --- a/packages/grpc-native-core/ext/channel_credentials.cc +++ b/packages/grpc-native-core/ext/channel_credentials.cc @@ -38,6 +38,8 @@ using Nan::ObjectWrap; using Nan::Persistent; using Nan::Utf8String; +using v8::Array; +using v8::Context; using v8::Exception; using v8::External; using v8::Function; @@ -58,6 +60,44 @@ ChannelCredentials::~ChannelCredentials() { grpc_channel_credentials_release(wrapped_credentials); } +static int verify_peer_callback_wrapper(const char* servername, const char* cert, void* userdata) { + Nan::HandleScope scope; + Nan::TryCatch try_catch; + Nan::Callback *callback = (Nan::Callback*)userdata; + + const unsigned argc = 2; + Local argv[argc]; + if (servername == NULL) { + argv[0] = Nan::Null(); + } else { + argv[0] = Nan::New(servername).ToLocalChecked(); + } + if (cert == NULL) { + argv[1] = Nan::Null(); + } else { + argv[1] = Nan::New(cert).ToLocalChecked(); + } + + Local result = callback->Call(argc, argv); + + // Catch any exception and return with a distinct status code which indicates this + if (try_catch.HasCaught()) { + return 2; + } + + // If the result is an error, return a failure + if (result->IsNativeError()) { + return 1; + } + + return 0; +} + +static void verify_peer_callback_destruct(void *userdata) { + Nan::Callback *callback = (Nan::Callback*)userdata; + delete callback; +} + void ChannelCredentials::Init(Local exports) { HandleScope scope; Local tpl = Nan::New(New); @@ -148,9 +188,31 @@ NAN_METHOD(ChannelCredentials::CreateSsl) { "createSsl's second and third arguments must be" " provided or omitted together"); } + + verify_peer_options verify_options = {NULL, NULL, NULL}; + if (!info[3]->IsUndefined()) { + if (!info[3]->IsObject()) { + return Nan::ThrowTypeError("createSsl's fourth argument must be an object"); + } + Local object = info[3]->ToObject(); + + Local checkServerIdentityValue = Nan::Get(object, + Nan::New("checkServerIdentity").ToLocalChecked()).ToLocalChecked(); + if (!checkServerIdentityValue->IsUndefined()) { + if (!checkServerIdentityValue->IsFunction()) { + return Nan::ThrowTypeError("Value of checkServerIdentity must be a function."); + } + Nan::Callback *callback = new Callback(Local::Cast( + checkServerIdentityValue)); + verify_options.verify_peer_callback = verify_peer_callback_wrapper; + verify_options.verify_peer_callback_userdata = (void*)callback; + verify_options.verify_peer_destruct = verify_peer_callback_destruct; + } + } + grpc_channel_credentials *creds = grpc_ssl_credentials_create( root_certs.get(), private_key.isAssigned() ? &key_cert_pair : NULL, - NULL, NULL); + &verify_options, NULL); if (creds == NULL) { info.GetReturnValue().SetNull(); } else { diff --git a/packages/grpc-native-core/index.d.ts b/packages/grpc-native-core/index.d.ts index a704694e..ac11056e 100644 --- a/packages/grpc-native-core/index.d.ts +++ b/packages/grpc-native-core/index.d.ts @@ -794,6 +794,36 @@ declare module "grpc" { ERROR, } + /** + * A certificate as received by the checkServerIdentity callback. + */ + export interface Certificate { + /** + * The raw certificate in DER form. + */ + raw: Buffer; + } + + /** + * A callback that will receive the expected hostname and presented peer + * certificate as parameters. The callback should return an error to + * indicate that the presented certificate is considered invalid and + * otherwise returned undefined. + */ + export type CheckServerIdentityCallback = (hostname: string, cert: Certificate) => Error | undefined; + + /** + * Additional peer verification options that can be set when creating + * SSL credentials. + */ + export interface VerifyOptions: { + /** + * If set, this callback will be invoked after the usual hostname verification + * has been performed on the peer certificate. + */ + checkServerIdentity?: CheckServerIdentityCallback; + } + /** * Credentials module * @@ -828,9 +858,10 @@ declare module "grpc" { * @param rootCerts The root certificate data * @param privateKey The client certificate private key, if applicable * @param certChain The client certificate cert chain, if applicable + * @param verifyOptions Additional peer verification options, if desired * @return The SSL Credentials object */ - createSsl(rootCerts?: Buffer, privateKey?: Buffer, certChain?: Buffer): ChannelCredentials; + createSsl(rootCerts?: Buffer, privateKey?: Buffer, certChain?: Buffer, verifyOptions?: VerifyOptions): ChannelCredentials; /** * Create a gRPC credentials object from a metadata generation function. This diff --git a/packages/grpc-native-core/package.json b/packages/grpc-native-core/package.json index 5d2a82d5..3e1e647c 100644 --- a/packages/grpc-native-core/package.json +++ b/packages/grpc-native-core/package.json @@ -44,6 +44,7 @@ "istanbul": "^0.4.4", "lodash": "^4.17.4", "minimist": "^1.1.0", + "node-forge": "^0.7.5", "poisson-process": "^0.2.1" }, "engines": { diff --git a/packages/grpc-native-core/src/credentials.js b/packages/grpc-native-core/src/credentials.js index a2312966..28b668b1 100644 --- a/packages/grpc-native-core/src/credentials.js +++ b/packages/grpc-native-core/src/credentials.js @@ -76,9 +76,35 @@ var _ = require('lodash'); * @see https://github.com/google/google-auth-library-nodejs */ +const PEM_CERT_HEADER = "-----BEGIN CERTIFICATE-----"; +const PEM_CERT_FOOTER = "-----END CERTIFICATE-----"; + +function wrapCheckServerIdentityCallback(callback) { + return function(hostname, cert) { + // Parse cert from pem to a version that matches the tls.checkServerIdentity + // format. + // https://nodejs.org/api/tls.html#tls_tls_checkserveridentity_hostname_cert + + var pemHeaderIndex = cert.indexOf(PEM_CERT_HEADER); + if (pemHeaderIndex === -1) { + return new Error("Unable to parse certificate PEM."); + } + cert = cert.substring(pemHeaderIndex); + var pemFooterIndex = cert.indexOf(PEM_CERT_FOOTER); + if (pemFooterIndex === -1) { + return new Error("Unable to parse certificate PEM."); + } + cert = cert.substring(PEM_CERT_HEADER.length, pemFooterIndex); + var rawBuffer = new Buffer(cert.replace("\n", "").replace(" ", ""), "base64"); + + return callback(hostname, { raw: rawBuffer }); + } +} + /** * Create an SSL Credentials object. If using a client-side certificate, both - * the second and third arguments must be passed. + * the second and third arguments must be passed. Additional peer verification + * options can be passed in the fourth argument as described below. * @memberof grpc.credentials * @alias grpc.credentials.createSsl * @kind function @@ -86,9 +112,30 @@ var _ = require('lodash'); * @param {Buffer=} private_key The client certificate private key, if * applicable * @param {Buffer=} cert_chain The client certificate cert chain, if applicable + * @param {Function} verify_options.checkServerIdentity Optional callback + * receiving the expected hostname and peer certificate for additional + * verification. The callback should return an Error if verification + * fails and otherwise return undefined. * @return {grpc.credentials~ChannelCredentials} The SSL Credentials object */ -exports.createSsl = ChannelCredentials.createSsl; +exports.createSsl = function(root_certs, private_key, cert_chain, verify_options) { + // The checkServerIdentity callback from gRPC core will receive the cert as a PEM. + // To better match the checkServerIdentity callback of Node, we wrap the callback + // to decode the PEM and populate a cert object. + if (verify_options && verify_options.checkServerIdentity) { + if (typeof verify_options.checkServerIdentity !== 'function') { + throw new TypeError("Value of checkServerIdentity must be a function."); + } + // Make a shallow clone of verify_options so our modification of the callback + // isn't reflected to the caller + var updated_verify_options = Object.assign({}, verify_options); + updated_verify_options.checkServerIdentity = wrapCheckServerIdentityCallback( + verify_options.checkServerIdentity); + arguments[3] = updated_verify_options; + } + return ChannelCredentials.createSsl.apply(this, arguments); +} + /** * @callback grpc.credentials~metadataCallback diff --git a/packages/grpc-native-core/templates/package.json.template b/packages/grpc-native-core/templates/package.json.template index a6d580a8..77bcfd13 100644 --- a/packages/grpc-native-core/templates/package.json.template +++ b/packages/grpc-native-core/templates/package.json.template @@ -46,6 +46,7 @@ "istanbul": "^0.4.4", "lodash": "^4.17.4", "minimist": "^1.1.0", + "node-forge": "^0.7.5", "poisson-process": "^0.2.1" }, "engines": { diff --git a/packages/grpc-native-core/test/credentials_test.js b/packages/grpc-native-core/test/credentials_test.js index 58dddb50..74d3edc1 100644 --- a/packages/grpc-native-core/test/credentials_test.js +++ b/packages/grpc-native-core/test/credentials_test.js @@ -21,6 +21,7 @@ var assert = require('assert'); var fs = require('fs'); var path = require('path'); +var forge = require('node-forge'); var grpc = require('..'); @@ -128,6 +129,25 @@ describe('channel credentials', function() { grpc.credentials.createSsl(null, null, pem_data); }); }); + it('works if the fourth argument is an empty object', function() { + var creds; + assert.doesNotThrow(function() { + creds = grpc.credentials.createSsl(ca_data, null, null, {}); + }); + assert.notEqual(creds, null); + }); + it('fails if the fourth argument is a non-object value', function() { + assert.throws(function() { + grpc.credentials.createSsl(ca_data, null, null, 'test'); + }, TypeError); + }); + it('fails if the checkServerIdentity is a non-function', function() { + assert.throws(function() { + grpc.credentials.createSsl(ca_data, null, null, { + "checkServerIdentity": 'test' + }); + }, TypeError); + }); }); }); @@ -260,6 +280,58 @@ describe('client credentials', function() { done(); }); }); + it('Verify callback receives correct arguments', function(done) { + var callback_host, callback_cert; + var client_ssl_creds = grpc.credentials.createSsl(ca_data, null, null, { + "checkServerIdentity": function(host, cert) { + callback_host = host; + callback_cert = cert; + } + }); + var client = new Client('localhost:' + port, client_ssl_creds, + client_options); + client.unary({}, function(err, data) { + assert.ifError(err); + assert.equal(callback_host, 'foo.test.google.fr'); + + // The roundabout forge APIs for converting PEM to a node DER Buffer + var expected_der = new Buffer(forge.asn1.toDer( + forge.pki.certificateToAsn1(forge.pki.certificateFromPem(pem_data))) + .getBytes(), 'binary'); + + // Assert the buffers are equal by converting them to hex strings + assert.equal(callback_cert.raw.toString('hex'), expected_der.toString('hex')); + // Documented behavior of callback cert is that raw should be its only property + assert.equal(Object.keys(callback_cert).length, 1); + done(); + }); + }); + it('Verify callback exception causes connection failure', function(done) { + var client_ssl_creds = grpc.credentials.createSsl(ca_data, null, null, { + "checkServerIdentity": function(host, cert) { + throw "Verification callback exception"; + } + }); + var client = new Client('localhost:' + port, client_ssl_creds, + client_options); + client.unary({}, function(err, data) { + assert.ok(err, "Should have raised an error"); + done(); + }); + }); + it('Verify callback returning an Error causes connection failure', function(done) { + var client_ssl_creds = grpc.credentials.createSsl(ca_data, null, null, { + "checkServerIdentity": function(host, cert) { + return new Error("Verification error"); + } + }); + var client = new Client('localhost:' + port, client_ssl_creds, + client_options); + client.unary({}, function(err, data) { + assert.ok(err, "Should have raised an error"); + done(); + }); + }); it('Should update metadata with SSL creds', function(done) { var metadataUpdater = function(service_url, callback) { var metadata = new grpc.Metadata();