Skip to content

Commit 51ddd68

Browse files
committed
crypto: add tls.useSystemCA()
This API allows enabling system CA certificates to be used by the Node.js TLS clients by default. This is equivalent to enabling the `--use-system-ca` flag, but can be done programmatically at runtime. Once called, the system CA certificates will be included in the default CA certificate list returned by `tls.getCACertificates()` and used by TLS connections that don't specify their own CA certificates. Subsequent calls to this function are no-ops. The system CA certificates are loaded and cached on the first call. This function only affects the current Node.js thread.
1 parent 5584cc5 commit 51ddd68

File tree

7 files changed

+291
-6
lines changed

7 files changed

+291
-6
lines changed

doc/api/tls.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2260,6 +2260,25 @@ openssl pkcs12 -certpbe AES-256-CBC -export -out client-cert.pem \
22602260
The server can be tested by connecting to it using the example client from
22612261
[`tls.connect()`][].
22622262

2263+
## `tls.useSystemCA()`
2264+
2265+
<!-- YAML
2266+
added: REPLACEME
2267+
-->
2268+
2269+
Enables system CA certificates to be used by the Node.js TLS clients by default.
2270+
This is equivalent to enabling the [`--use-system-ca`][] flag, but can be done
2271+
programmatically at runtime.
2272+
2273+
Once called, the system CA certificates will be included in the default CA
2274+
certificate list returned by [`tls.getCACertificates()`][] and used by TLS
2275+
connections that don't specify their own CA certificates.
2276+
2277+
Subsequent calls to this function are no-ops. The system CA certificates are
2278+
loaded and cached on the first call.
2279+
2280+
This function only affects the current Node.js thread.
2281+
22632282
## `tls.getCACertificates([type])`
22642283

22652284
<!-- YAML

lib/tls.js

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const {
5151
getBundledRootCertificates,
5252
getExtraCACertificates,
5353
getSystemCACertificates,
54+
useSystemCA: useSystemCABinding,
5455
getSSLCiphers,
5556
} = internalBinding('crypto');
5657
const { Buffer } = require('buffer');
@@ -122,6 +123,21 @@ function cacheSystemCACertificates() {
122123
}
123124

124125
let defaultCACertificates;
126+
let enabledSystemCACertificates = false;
127+
function useSystemCA() {
128+
if (enabledSystemCACertificates) {
129+
// Already enabled. It's a no-op.
130+
return;
131+
}
132+
133+
useSystemCABinding();
134+
enabledSystemCACertificates = true;
135+
// Invalidate the cached values so that they will be
136+
// recalculated the next time tls.getCACertificates() is called.
137+
systemCACertificates = undefined;
138+
defaultCACertificates = undefined;
139+
}
140+
125141
function cacheDefaultCACertificates() {
126142
if (defaultCACertificates) { return defaultCACertificates; }
127143
defaultCACertificates = [];
@@ -131,7 +147,7 @@ function cacheDefaultCACertificates() {
131147
for (let i = 0; i < bundled.length; ++i) {
132148
ArrayPrototypePush(defaultCACertificates, bundled[i]);
133149
}
134-
if (getOptionValue('--use-system-ca')) {
150+
if (getOptionValue('--use-system-ca') || enabledSystemCACertificates) {
135151
const system = cacheSystemCACertificates();
136152
for (let i = 0; i < system.length; ++i) {
137153

@@ -170,6 +186,7 @@ function getCACertificates(type = 'default') {
170186
}
171187
}
172188
exports.getCACertificates = getCACertificates;
189+
exports.useSystemCA = useSystemCA;
173190

174191
// Convert protocols array into valid OpenSSL protocols list
175192
// ("\x06spdy/2\x08http/1.1\x08http/1.0")

src/crypto/crypto_context.cc

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,11 +84,15 @@ static std::string extra_root_certs_file; // NOLINT(runtime/string)
8484
static std::atomic<bool> has_cached_bundled_root_certs{false};
8585
static std::atomic<bool> has_cached_system_root_certs{false};
8686
static std::atomic<bool> has_cached_extra_root_certs{false};
87-
87+
// Per-thread root cert store.
88+
static thread_local X509_STORE* root_cert_store = nullptr;
89+
static bool use_system_ca_in_root_cert_store = false;
8890
X509_STORE* GetOrCreateRootCertStore() {
89-
// Guaranteed thread-safe by standard, just don't use -fno-threadsafe-statics.
90-
static X509_STORE* store = NewRootCertStore();
91-
return store;
91+
if (root_cert_store != nullptr) {
92+
return root_cert_store;
93+
}
94+
root_cert_store = NewRootCertStore();
95+
return root_cert_store;
9296
}
9397

9498
// Takes a string or buffer and loads it into a BIO.
@@ -851,7 +855,8 @@ X509_STORE* NewRootCertStore() {
851855
for (X509* cert : GetBundledRootCertificates()) {
852856
CHECK_EQ(1, X509_STORE_add_cert(store, cert));
853857
}
854-
if (per_process::cli_options->use_system_ca) {
858+
if (per_process::cli_options->use_system_ca ||
859+
use_system_ca_in_root_cert_store) {
855860
for (X509* cert : GetSystemStoreCACertificates()) {
856861
CHECK_EQ(1, X509_STORE_add_cert(store, cert));
857862
}
@@ -935,6 +940,17 @@ MaybeLocal<Array> X509sToArrayOfStrings(Environment* env,
935940
return scope.Escape(Array::New(env->isolate(), result.data(), result.size()));
936941
}
937942

943+
void UseSystemCA(const FunctionCallbackInfo<Value>& args) {
944+
Environment* env = Environment::GetCurrent(args);
945+
use_system_ca_in_root_cert_store = true;
946+
X509_STORE* store = GetOrCreateRootCertStore();
947+
for (X509* cert : GetSystemStoreCACertificates()) {
948+
if (!X509_STORE_add_cert(store, cert)) {
949+
return ThrowCryptoError(env, ERR_get_error(), "X509_STORE_add_cert");
950+
}
951+
}
952+
}
953+
938954
void GetSystemCACertificates(const FunctionCallbackInfo<Value>& args) {
939955
Environment* env = Environment::GetCurrent(args);
940956
Local<Array> results;
@@ -1046,6 +1062,7 @@ void SecureContext::Initialize(Environment* env, Local<Object> target) {
10461062
context, target, "getSystemCACertificates", GetSystemCACertificates);
10471063
SetMethodNoSideEffect(
10481064
context, target, "getExtraCACertificates", GetExtraCACertificates);
1065+
SetMethodNoSideEffect(context, target, "useSystemCA", UseSystemCA);
10491066
}
10501067

10511068
void SecureContext::RegisterExternalReferences(
@@ -1088,6 +1105,7 @@ void SecureContext::RegisterExternalReferences(
10881105
registry->Register(GetBundledRootCertificates);
10891106
registry->Register(GetSystemCACertificates);
10901107
registry->Register(GetExtraCACertificates);
1108+
registry->Register(UseSystemCA);
10911109
}
10921110

10931111
SecureContext* SecureContext::Create(Environment* env) {
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use strict';
2+
// Flags: --use-system-ca
3+
4+
// This tests that tls.useSystemCA() is a no-op when
5+
// --use-system-ca flag is already set.
6+
7+
const common = require('../common');
8+
if (!common.hasCrypto) common.skip('missing crypto');
9+
10+
const assert = require('assert');
11+
const tls = require('tls');
12+
13+
// Get initial state when --use-system-ca is already set
14+
const initialDefaultCerts = tls.getCACertificates('default');
15+
const systemCerts = tls.getCACertificates('system');
16+
17+
assert(Array.isArray(systemCerts));
18+
19+
// With --use-system-ca, default should already include system certs
20+
const initialSystemSet = new Set(systemCerts);
21+
const initialDefaultSet = new Set(initialDefaultCerts);
22+
assert.deepStrictEqual(initialDefaultSet.intersection(initialSystemSet), initialSystemSet);
23+
24+
// Enable system CA certificates (should be a no-op)
25+
tls.useSystemCA();
26+
27+
// Get certificates after calling useSystemCA
28+
const newDefaultCerts = tls.getCACertificates('default');
29+
const newSystemCerts = tls.getCACertificates('system');
30+
31+
// Everything should have the same content.
32+
assert.deepStrictEqual(initialDefaultCerts, newDefaultCerts);
33+
assert.deepStrictEqual(systemCerts, newSystemCerts);
34+
35+
// Multiple calls should still be no-ops
36+
tls.useSystemCA();
37+
tls.useSystemCA();
38+
const stillSameDefaultCerts = tls.getCACertificates('default');
39+
assert.deepStrictEqual(newDefaultCerts, stillSameDefaultCerts);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
'use strict';
2+
3+
// Flags: --no-use-system-ca
4+
// This tests that tls.useSystemCA() works correctly.
5+
6+
const common = require('../common');
7+
if (!common.hasCrypto) common.skip('missing crypto');
8+
9+
const assert = require('assert');
10+
const tls = require('tls');
11+
12+
// Get initial state
13+
const initialDefaultCerts = tls.getCACertificates('default');
14+
const systemCerts = tls.getCACertificates('system');
15+
16+
assert(Array.isArray(systemCerts));
17+
18+
// Check that system certs are not included by default (without --use-system-ca)
19+
const initialSystemSet = new Set(systemCerts);
20+
const initialDefaultSet = new Set(initialDefaultCerts);
21+
const initialIntersection = initialDefaultSet.intersection(initialSystemSet);
22+
23+
// The initial default should not contain all system certs
24+
// if there are system certs installed.
25+
if (systemCerts.length > 0) {
26+
assert(initialIntersection.size < systemCerts.length);
27+
}
28+
29+
// Enable system CA certificates.
30+
tls.useSystemCA();
31+
32+
// Get certificates after enabling system CAs
33+
const newDefaultCerts = tls.getCACertificates('default');
34+
const newSystemCerts = tls.getCACertificates('system');
35+
36+
// System certificates should have the same content
37+
assert.deepStrictEqual(systemCerts, newSystemCerts);
38+
39+
// Default certificates behavior depends on whether system certs exist
40+
if (systemCerts.length > 0) {
41+
// The new default should be old default plus system certs
42+
const newDefaultSet = new Set(newDefaultCerts);
43+
const newSystemSet = new Set(systemCerts);
44+
assert.deepStrictEqual(newDefaultSet.intersection(initialDefaultSet), initialDefaultSet);
45+
assert.deepStrictEqual(newDefaultSet.intersection(newSystemSet), newSystemSet);
46+
} else {
47+
// If no system certs, default certs should remain the same
48+
assert.deepStrictEqual(initialDefaultCerts, newDefaultCerts);
49+
}
50+
51+
// Calling useSystemCA again should be a no-op
52+
tls.useSystemCA();
53+
const sameDefaultCerts = tls.getCACertificates('default');
54+
assert.deepStrictEqual(newDefaultCerts, sameDefaultCerts);
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
'use strict';
2+
3+
// Flags: --no-use-system-ca
4+
5+
// This tests that tls.useSystemCA() works correctly
6+
// when the certificates from the README are installed on the system.
7+
// To run this test, install the certificates as described in README.md
8+
9+
const common = require('../common');
10+
if (!common.hasCrypto) common.skip('missing crypto');
11+
12+
const assert = require('assert');
13+
const tls = require('tls');
14+
const { assertIsCAArray } = require('../common/tls');
15+
const fixtures = require('../common/fixtures');
16+
17+
// Read the expected certificates that should be installed
18+
const startcomRootCert = fixtures.readKey('fake-startcom-root-cert.pem', 'utf8');
19+
20+
// Get initial state.
21+
const initialDefaultCerts = tls.getCACertificates('default');
22+
const systemCerts = tls.getCACertificates('system');
23+
24+
// System certs should be a valid CA array and should contain our test certificates
25+
assertIsCAArray(systemCerts);
26+
assert(systemCerts.length > 0, 'System certificates should be available when test certs are installed');
27+
28+
assert(systemCerts.includes(startcomRootCert));
29+
assert(!initialDefaultCerts.includes(startcomRootCert));
30+
31+
// Check that system certs are not included by default (without --use-system-ca)
32+
const initialSystemSet = new Set(systemCerts);
33+
const initialDefaultSet = new Set(initialDefaultCerts);
34+
const initialIntersection = initialDefaultSet.intersection(initialSystemSet);
35+
36+
// The initial default should not contain all system certs
37+
assert(initialIntersection.size < systemCerts.length, 'Default certs should not include all system certs initially');
38+
39+
// Enable system CA certificates
40+
tls.useSystemCA();
41+
42+
// Get certificates after enabling system CAs
43+
const newDefaultCerts = tls.getCACertificates('default');
44+
const newSystemCerts = tls.getCACertificates('system');
45+
46+
// System certificates should have the same content
47+
assert.deepStrictEqual(systemCerts, newSystemCerts);
48+
49+
// Default certificates should now include the system certificates
50+
assert.notStrictEqual(initialDefaultCerts, newDefaultCerts, 'Default certs should change after enabling system CAs');
51+
52+
// The new default should be a superset of system certificates
53+
assert(newDefaultCerts.length >= systemCerts.length, 'New default should include all system certs');
54+
const newDefaultSet = new Set(newDefaultCerts);
55+
const newSystemSet = new Set(systemCerts);
56+
assert.deepStrictEqual(newDefaultSet.intersection(newSystemSet), newSystemSet);
57+
58+
// Verify that our test certificates are now in the default certs
59+
assert(newDefaultCerts.includes(startcomRootCert));
60+
61+
// Calling useSystemCA again should be a no-op
62+
tls.useSystemCA();
63+
const sameDefaultCerts = tls.getCACertificates('default');
64+
assert.deepStrictEqual(newDefaultCerts, sameDefaultCerts);
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Flags: --no-use-system-ca
2+
3+
// This tests that tls.useSystemCA() dynamically enables
4+
// system certificates, allowing connections that would otherwise fail.
5+
// To run this test, install the certificates as described in README.md
6+
7+
import * as common from '../common/index.mjs';
8+
import assert from 'node:assert/strict';
9+
import https from 'node:https';
10+
import tls from 'node:tls';
11+
import fixtures from '../common/fixtures.js';
12+
import { it, beforeEach, afterEach, describe } from 'node:test';
13+
import { once } from 'events';
14+
15+
if (!common.hasCrypto) {
16+
common.skip('requires crypto');
17+
}
18+
19+
const handleRequest = (req, res) => {
20+
const path = req.url;
21+
switch (path) {
22+
case '/hello-world':
23+
res.writeHead(200);
24+
res.end('hello world\n');
25+
break;
26+
default:
27+
assert(false, `Unexpected path: ${path}`);
28+
}
29+
};
30+
31+
describe('enable-system-ca-dynamic', function() {
32+
async function setupServer(key, cert) {
33+
const theServer = https.createServer({
34+
key: fixtures.readKey(key),
35+
cert: fixtures.readKey(cert),
36+
}, handleRequest);
37+
theServer.listen(0);
38+
await once(theServer, 'listening');
39+
40+
return theServer;
41+
}
42+
43+
let server;
44+
45+
beforeEach(async function() {
46+
server = await setupServer('agent8-key.pem', 'agent8-cert.pem');
47+
});
48+
49+
it('fails before enabling system CA, succeeds after', async function() {
50+
const url = `https://localhost:${server.address().port}/hello-world`;
51+
52+
// First attempt should fail without system certificates.
53+
await assert.rejects(
54+
fetch(url),
55+
(err) => {
56+
assert.strictEqual(err.cause.code, 'UNABLE_TO_VERIFY_LEAF_SIGNATURE');
57+
return true;
58+
},
59+
);
60+
61+
// Now enable system CA certificates
62+
tls.useSystemCA();
63+
64+
// Second attempt should succeed.
65+
const response = await fetch(url);
66+
assert.strictEqual(response.status, 200);
67+
const text = await response.text();
68+
assert.strictEqual(text, 'hello world\n');
69+
});
70+
71+
afterEach(async function() {
72+
server?.close();
73+
});
74+
});

0 commit comments

Comments
 (0)