Skip to content

Commit

Permalink
tls: allow tls.rootCertificates to be set
Browse files Browse the repository at this point in the history
Modifies the tls.rootCertificates property so that it is settable. PEM-formatted certificates set to the property are loaded into a new Node.js root certificate store that is used for all subsequent TLS requests and peer certificate validation.

Allows full programmatic control of a Node.js process' root certificate store.

Fixes: nodejs#20432
Refs: nodejs#27079
  • Loading branch information
ebickle committed Mar 5, 2020
1 parent d973ce4 commit 301275f
Show file tree
Hide file tree
Showing 5 changed files with 191 additions and 22 deletions.
14 changes: 14 additions & 0 deletions doc/api/tls.md
Original file line number Diff line number Diff line change
Expand Up @@ -1775,6 +1775,20 @@ An immutable array of strings representing the root certificates (in PEM format)
used for verifying peer certificates. This is the default value of the `ca`
option to [`tls.createSecureContext()`][].

`tls.rootCertificates` can be assigned a new value to change the root
certificates used used for all subsequent peer certificate verifications.
Existing secure connections are not affected.

The following illustrates adding an extra certificate to the default set of
well known "root" CAs:

```sh
tls.rootCertificates = [
...tls.rootCertificates,
fs.readFileSync('custom-ca.pem')
];
```

## `tls.DEFAULT_ECDH_CURVE`
<!-- YAML
added: v0.11.13
Expand Down
20 changes: 18 additions & 2 deletions lib/tls.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const {
} = primordials;

const {
ERR_INVALID_ARG_TYPE,
ERR_TLS_CERT_ALTNAME_INVALID,
ERR_OUT_OF_RANGE
} = require('internal/errors').codes;
Expand All @@ -40,7 +41,11 @@ const { isArrayBufferView } = require('internal/util/types');
const net = require('net');
const { getOptionValue } = require('internal/options');
const url = require('url');
const { getRootCertificates, getSSLCiphers } = internalBinding('crypto');
const {
getRootCertificates,
setRootCertificates,
getSSLCiphers
} = internalBinding('crypto');
const { Buffer } = require('buffer');
const EventEmitter = require('events');
const { URL } = require('internal/url');
Expand Down Expand Up @@ -84,7 +89,7 @@ exports.getCiphers = internalUtil.cachedResult(
() => internalUtil.filterDuplicateStrings(getSSLCiphers(), true)
);

let rootCertificates;
let rootCertificates = null;

function cacheRootCertificates() {
rootCertificates = ObjectFreeze(getRootCertificates());
Expand All @@ -98,6 +103,17 @@ ObjectDefineProperty(exports, 'rootCertificates', {
if (!rootCertificates) cacheRootCertificates();
return rootCertificates;
},
set: (value) => {
if (!ArrayIsArray(value))
throw new ERR_INVALID_ARG_TYPE('value', 'Array', value);
for (let i = 0; i < value.length; i++) {
if (typeof value[i] !== 'string')
throw new ERR_INVALID_ARG_TYPE(`value[${i}]`, 'String', value[i]);
}

setRootCertificates(value);
rootCertificates = null;
}
});

// Convert protocols array into valid OpenSSL protocols list
Expand Down
48 changes: 48 additions & 0 deletions src/node_crypto.cc
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,53 @@ void GetRootCertificates(const FunctionCallbackInfo<Value>& args) {
}


void SetRootCertificates(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);

// Single argument must be an array of PEM-formatted strings.
CHECK_EQ(args.Length(), 1);
CHECK(args[0]->IsArray());
Local<Array> arr = args[0].As<Array>();
size_t len = arr->Length();

ERR_clear_error();
ClearErrorOnReturn clear_error_on_return;

X509StorePointer store(X509_STORE_new());

for (size_t i = 0; i < len; i++) {
// All array values must be strings.
Local<Value> value;
if (!arr->Get(env->context(), i).ToLocal(&value)) return;
CHECK(value->IsString());

BIOPointer bio(LoadBIO(env, value));
if (!bio) return ThrowCryptoError(env, ERR_get_error(), "LoadBIO");

// Each string can contain multiple PEM-formatted certificates.
while (X509Pointer x509 = X509Pointer(
PEM_read_bio_X509(bio.get(), nullptr, NoPasswordCallback, nullptr))) {
if (X509_STORE_add_cert(store.get(), x509.get()) == 0) {
return ThrowCryptoError(env, ERR_get_error(), "X509_STORE_add_cert");
}
}

// Ignore error if its EOF/no start line found.
unsigned long err = ERR_peek_last_error(); // NOLINT(runtime/int)
if (ERR_GET_LIB(err) != ERR_LIB_PEM ||
ERR_GET_REASON(err) != PEM_R_NO_START_LINE) {
return ThrowCryptoError(env, err, "PEM_read_bio_X509");
}
}

if (root_cert_store != nullptr) {
X509_STORE_free(root_cert_store);
}

root_cert_store = store.release();
}


void SecureContext::AddCACert(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);

Expand Down Expand Up @@ -6890,6 +6937,7 @@ void Initialize(Local<Object> target,
env->SetMethodNoSideEffect(target, "certExportChallenge", ExportChallenge);
env->SetMethodNoSideEffect(target, "getRootCertificates",
GetRootCertificates);
env->SetMethod(target, "setRootCertificates", SetRootCertificates);
// Exposed for testing purposes only.
env->SetMethodNoSideEffect(target, "isExtraRootCertsFileLoaded",
IsExtraRootCertsFileLoaded);
Expand Down
1 change: 1 addition & 0 deletions src/node_crypto.h
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ struct MarkPopErrorOnReturn {

// Define smart pointers for the most commonly used OpenSSL types:
using X509Pointer = DeleteFnPtr<X509, X509_free>;
using X509StorePointer = DeleteFnPtr<X509_STORE, X509_STORE_free>;
using BIOPointer = DeleteFnPtr<BIO, BIO_free_all>;
using SSLCtxPointer = DeleteFnPtr<SSL_CTX, SSL_CTX_free>;
using SSLSessionPointer = DeleteFnPtr<SSL_SESSION, SSL_SESSION_free>;
Expand Down
130 changes: 110 additions & 20 deletions test/parallel/test-tls-root-certificates.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use strict';

const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');

Expand All @@ -7,44 +8,133 @@ const assert = require('assert');
const tls = require('tls');
const { fork } = require('child_process');

const extraCertFiles = [
'ca1-cert.pem',
'ca2-cert.pem',
'ca3-cert.pem',
'ca4-cert.pem',
'ca5-cert.pem',
'ca6-cert.pem'
];

// Spawn tests in a child process to check NODE_EXTRA_CA_CERTS behavior.
if (process.argv[2] !== 'child') {
// Parent
const NODE_EXTRA_CA_CERTS = fixtures.path('keys', 'ca1-cert.pem');

fork(
__filename,
['child'],
{ env: { ...process.env, NODE_EXTRA_CA_CERTS } }
).on('exit', common.mustCall(function(status) {
const env = {
...process.env,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', extraCertFiles[0])
};

fork(__filename, ['child'], { env }).on('exit', common.mustCall((status) => {
assert.strictEqual(status, 0);
}));
} else {
// Child

return;
}

function testGetRootCertificates() {
assert(Array.isArray(tls.rootCertificates));
assert(tls.rootCertificates.length > 0);

// Getter should return the same object.
assert.strictEqual(tls.rootCertificates, tls.rootCertificates);

// Array is immutable...
// Array is immutable.
assert.throws(() => tls.rootCertificates[0] = 0, /TypeError/);
assert.throws(() => tls.rootCertificates.sort(), /TypeError/);

// ...and so is the property.
assert.throws(() => tls.rootCertificates = 0, /TypeError/);
assert.throws(() => tls.rootCertificates.push(''), /TypeError/);

// Does not contain duplicates.
assert.strictEqual(tls.rootCertificates.length,
new Set(tls.rootCertificates).size);

// Array values are PEM-encoded strings.
// Only one certificate per array entry.
assert(tls.rootCertificates.every((s) => {
return s.startsWith('-----BEGIN CERTIFICATE-----\n');
const beginCert = '-----BEGIN CERTIFICATE-----\n';
return (s.lastIndexOf(beginCert) === 0);
}));

assert(tls.rootCertificates.every((s) => {
return s.endsWith('\n-----END CERTIFICATE-----\n');
const endCert = '\n-----END CERTIFICATE-----\n';
return (s.indexOf(endCert) === s.length - endCert.length);
}));
}

const extraCerts = extraCertFiles.map((f) => fixtures.readKey(f, 'utf8'));

// Basic checks.
testGetRootCertificates();
assert(tls.rootCertificates.length > 0);

// Contains certificate loaded via NODE_EXTRA_CERTS environment variable.
assert(tls.rootCertificates.includes(extraCerts[0]));

const extraCert = fixtures.readKey('ca1-cert.pem', 'utf8');
assert(tls.rootCertificates.includes(extraCert));
// Can't set to non-array types.
assert.throws(() => tls.rootCertificates = undefined, /TypeError/);
assert.throws(() => tls.rootCertificates = null, /TypeError/);
assert.throws(() => tls.rootCertificates = 0, /TypeError/);
assert.throws(() => tls.rootCertificates = extraCerts[0], /TypeError/);
assert.throws(() => delete tls.rootCertificates, /TypeError/);
assert.throws(() => tls.rootCertificates = [1], /TypeError/);
assert.throws(() => tls.rootCertificates = [null], /TypeError/);

// Can set to empty array.
tls.rootCertificates = [];
testGetRootCertificates();
assert.strictEqual(tls.rootCertificates.length, 0);

// Can set to array of PEM-encoded strings.
tls.rootCertificates = extraCerts;
testGetRootCertificates();
assert.strictEqual(tls.rootCertificates.length, extraCerts.length);
assert.notStrictEqual(tls.rootCertificates, extraCerts);
for (let i = 0; i < extraCerts.length; i++) {
assert(tls.rootCertificates.includes(
extraCerts[i].trim() + '\n'));
}

// A single PEM-encoded string can contain multiple certificates.
// Excess whitespace shouldn't adversely affect it.
tls.rootCertificates = [
'\n \n\n' + extraCerts[0] + '\n \n\n',
extraCerts[1] + '\n\n' + extraCerts[2],
extraCerts[3]
];
testGetRootCertificates();
assert.strictEqual(tls.rootCertificates.length, 4);
for (let i = 0; i < 4; i++) {
assert(tls.rootCertificates.includes(
extraCerts[i].trim() + '\n'));
}

// Strings that aren't PEM-encoded are ignored.
// This is OpenSSL's own behavior when reading PEMs.
tls.rootCertificates = ['INVALID', extraCerts[0], '', ' '];
assert.strictEqual(tls.rootCertificates.length, 1);

// Invaid certificate bodies throw an error.
assert.throws(() => tls.rootCertificates = [
'-----BEGIN CERTIFICATE-----\nINVALID\n-----END CERTIFICATE-----\n'
], /Error/);

// TLS connection tests
const server = tls.createServer({
key: fixtures.readKey('agent3-key.pem'),
cert: fixtures.readKey('agent3-cert.pem')
}, common.mustCall((socket) => {
socket.end('bye');
server.close();
})).listen(0, common.mustCall(() => {
const copts = {
port: server.address().port,
checkServerIdentity: common.mustCall()
};

// Successful connection
tls.rootCertificates = [extraCerts[1]];
const client = tls.connect(copts, common.mustCall(() => {
client.end('hi');
}));

// Unsuccessful connection
tls.rootCertificates = [extraCerts[0]];
tls.connect(copts, common.mustNotCall()).on('error', common.mustCall());
}));

0 comments on commit 301275f

Please sign in to comment.