Implement require('tls').getCACertificates() (#20364)

Co-authored-by: pfgithub <6010774+pfgithub@users.noreply.github.com>
This commit is contained in:
pfg
2025-06-13 17:30:04 -07:00
committed by GitHub
parent 9499f21518
commit e0924ef226
18 changed files with 341 additions and 27 deletions

View File

@@ -44,10 +44,7 @@ void *sni_find(void *sni, const char *hostname);
#include <wolfssl/options.h>
#endif
#include "./root_certs.h"
/* These are in root_certs.cpp */
extern X509_STORE *us_get_default_ca_store();
#include "./root_certs_header.h"
struct loop_ssl_data {
char *ssl_read_input, *ssl_read_output;

View File

@@ -1,10 +1,9 @@
// MSVC doesn't support C11 stdatomic.h propertly yet.
// so we use C++ std::atomic instead.
#include "./root_certs.h"
#include "./root_certs_header.h"
#include "./internal/internal.h"
#include <atomic>
#include <openssl/pem.h>
#include <openssl/x509.h>
#include <string.h>
static const int root_certs_size = sizeof(root_certs) / sizeof(root_certs[0]);
@@ -134,6 +133,23 @@ extern "C" int us_internal_raw_root_certs(struct us_cert_string_t **out) {
return root_certs_size;
}
struct us_default_ca_certificates {
X509 *root_cert_instances[root_certs_size];
STACK_OF(X509) *root_extra_cert_instances;
};
us_default_ca_certificates* us_get_default_ca_certificates() {
static us_default_ca_certificates default_ca_certificates = {{NULL}, NULL};
us_internal_init_root_certs(default_ca_certificates.root_cert_instances, default_ca_certificates.root_extra_cert_instances);
return &default_ca_certificates;
}
STACK_OF(X509) *us_get_root_extra_cert_instances() {
return us_get_default_ca_certificates()->root_extra_cert_instances;
}
extern "C" X509_STORE *us_get_default_ca_store() {
X509_STORE *store = X509_STORE_new();
if (store == NULL) {
@@ -145,10 +161,9 @@ extern "C" X509_STORE *us_get_default_ca_store() {
return NULL;
}
static X509 *root_cert_instances[root_certs_size] = {NULL};
static STACK_OF(X509) *root_extra_cert_instances = NULL;
us_internal_init_root_certs(root_cert_instances, root_extra_cert_instances);
us_default_ca_certificates *default_ca_certificates = us_get_default_ca_certificates();
X509** root_cert_instances = default_ca_certificates->root_cert_instances;
STACK_OF(X509) *root_extra_cert_instances = default_ca_certificates->root_extra_cert_instances;
// load all root_cert_instances on the default ca store
for (size_t i = 0; i < root_certs_size; i++) {

View File

@@ -0,0 +1,14 @@
#include <openssl/pem.h>
#include <openssl/x509.h>
#ifdef __cplusplus
#define CPPDECL extern "C"
STACK_OF(X509) *us_get_root_extra_cert_instances();
#else
#define CPPDECL extern
#endif
CPPDECL X509_STORE *us_get_default_ca_store();

View File

@@ -6,22 +6,23 @@
#include "libusockets.h"
#include "ZigGlobalObject.h"
#include "ErrorCode.h"
#include "openssl/base.h"
#include "openssl/bio.h"
#include "../../packages/bun-usockets/src/crypto/root_certs_header.h"
namespace Bun {
using namespace JSC;
BUN_DECLARE_HOST_FUNCTION(Bun__canonicalizeIP);
JSC::JSValue createNodeTLSBinding(Zig::GlobalObject* globalObject)
JSC_DEFINE_HOST_FUNCTION(getBundledRootCertificates, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
VM& vm = globalObject->vm();
auto* obj = constructEmptyObject(globalObject);
struct us_cert_string_t* out;
auto size = us_raw_root_certs(&out);
if (size < 0) {
return jsUndefined();
return JSValue::encode(jsUndefined());
}
auto rootCertificates = JSC::JSArray::create(vm, globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), size);
for (auto i = 0; i < size; i++) {
@@ -29,12 +30,46 @@ JSC::JSValue createNodeTLSBinding(Zig::GlobalObject* globalObject)
auto str = WTF::String::fromUTF8(std::span { raw.str, raw.len });
rootCertificates->putDirectIndex(globalObject, i, JSC::jsString(vm, str));
}
obj->putDirect(
vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "rootCertificates"_s)), JSC::objectConstructorFreeze(globalObject, rootCertificates), 0);
obj->putDirect(
vm, JSC::PropertyName(JSC::Identifier::fromString(vm, "canonicalizeIP"_s)), JSC::JSFunction::create(vm, globalObject, 1, "canonicalizeIP"_s, Bun__canonicalizeIP, ImplementationVisibility::Public, NoIntrinsic), 0);
return obj;
return JSValue::encode(JSC::objectConstructorFreeze(globalObject, rootCertificates));
}
JSC_DEFINE_HOST_FUNCTION(getExtraCACertificates, (JSC::JSGlobalObject * globalObject, JSC::CallFrame* callFrame))
{
auto scope = DECLARE_THROW_SCOPE(globalObject->vm());
VM& vm = globalObject->vm();
STACK_OF(X509)* root_extra_cert_instances = us_get_root_extra_cert_instances();
auto size = sk_X509_num(root_extra_cert_instances);
if (size < 0) size = 0; // root_extra_cert_instances is nullptr
auto rootCertificates = JSC::JSArray::create(vm, globalObject->arrayStructureForIndexingTypeDuringAllocation(JSC::ArrayWithContiguous), size);
for (auto i = 0; i < size; i++) {
BIO* bio = BIO_new(BIO_s_mem());
if (!bio) {
throwOutOfMemoryError(globalObject, scope);
return {};
}
if (PEM_write_bio_X509(bio, sk_X509_value(root_extra_cert_instances, i)) != 1) {
BIO_free(bio);
return throwError(globalObject, scope, ErrorCode::ERR_CRYPTO_OPERATION_FAILED, "X509 to PEM conversion"_str);
}
char* bioData = nullptr;
long bioLen = BIO_get_mem_data(bio, &bioData);
if (bioLen <= 0 || !bioData) {
BIO_free(bio);
return throwError(globalObject, scope, ErrorCode::ERR_CRYPTO_OPERATION_FAILED, "Reading PEM data"_str);
}
auto str = WTF::String::fromUTF8(std::span { bioData, static_cast<size_t>(bioLen) });
rootCertificates->putDirectIndex(globalObject, i, JSC::jsString(vm, str));
BIO_free(bio);
}
return JSValue::encode(JSC::objectConstructorFreeze(globalObject, rootCertificates));
}
} // namespace Bun

View File

@@ -3,6 +3,8 @@
namespace Bun {
JSC::JSValue createNodeTLSBinding(Zig::GlobalObject*);
BUN_DECLARE_HOST_FUNCTION(Bun__canonicalizeIP);
JSC_DECLARE_HOST_FUNCTION(getBundledRootCertificates);
JSC_DECLARE_HOST_FUNCTION(getExtraCACertificates);
}

View File

@@ -12,7 +12,7 @@ const fmtBinding = $bindgenFn("fmt.bind.ts", "fmtString");
export const highlightJavaScript = (code: string) => fmtBinding(code, "highlight-javascript");
export const escapePowershell = (code: string) => fmtBinding(code, "escape-powershell");
export const TLSBinding = $cpp("NodeTLS.cpp", "createNodeTLSBinding");
export const canonicalizeIP = $newCppFunction("NodeTLS.cpp", "Bun__canonicalizeIP", 1);
export const SQL = $cpp("JSSQLStatement.cpp", "createJSSQLStatementConstructor");

View File

@@ -5,10 +5,13 @@ const { Duplex } = require("node:stream");
const addServerName = $newZigFunction("socket.zig", "jsAddServerName", 3);
const { throwNotImplemented } = require("internal/shared");
const { throwOnInvalidTLSArray, DEFAULT_CIPHERS, validateCiphers } = require("internal/tls");
const { validateString } = require("internal/validators");
const { Server: NetServer, Socket: NetSocket } = net;
const { rootCertificates, canonicalizeIP } = $cpp("NodeTLS.cpp", "createNodeTLSBinding");
const getBundledRootCertificates = $newCppFunction("NodeTLS.cpp", "getBundledRootCertificates", 1);
const getExtraCACertificates = $newCppFunction("NodeTLS.cpp", "getExtraCACertificates", 1);
const canonicalizeIP = $newCppFunction("NodeTLS.cpp", "Bun__canonicalizeIP", 1);
const SymbolReplace = Symbol.replace;
const RegExpPrototypeSymbolReplace = RegExp.prototype[SymbolReplace];
@@ -31,6 +34,9 @@ const ArrayPrototypeForEach = Array.prototype.forEach;
const ArrayPrototypePush = Array.prototype.push;
const ArrayPrototypeSome = Array.prototype.some;
const ArrayPrototypeReduce = Array.prototype.reduce;
const ObjectFreeze = Object.freeze;
function parseCertString() {
// Removed since JAN 2022 Node v18.0.0+ https://github.com/nodejs/node/pull/41479
throwNotImplemented("Not implemented");
@@ -724,6 +730,59 @@ function convertALPNProtocols(protocols, out) {
}
}
let bundledRootCertificates: string[] | undefined;
function cacheBundledRootCertificates(): string[] {
bundledRootCertificates ||= getBundledRootCertificates() as string[];
return bundledRootCertificates;
}
let defaultCACertificates: string[] | undefined;
function cacheDefaultCACertificates() {
if (defaultCACertificates) return defaultCACertificates;
defaultCACertificates = [];
const bundled = cacheBundledRootCertificates();
for (let i = 0; i < bundled.length; ++i) {
ArrayPrototypePush.$call(defaultCACertificates, bundled[i]);
}
if (process.env.NODE_EXTRA_CA_CERTS) {
const extra = cacheExtraCACertificates();
for (let i = 0; i < extra.length; ++i) {
ArrayPrototypePush.$call(defaultCACertificates, extra[i]);
}
}
ObjectFreeze(defaultCACertificates);
return defaultCACertificates;
}
function cacheSystemCACertificates(): string[] {
throw new Error("getCACertificates('system') is not yet implemented in Bun");
}
let extraCACertificates: string[] | undefined;
function cacheExtraCACertificates(): string[] {
extraCACertificates ||= getExtraCACertificates() as string[];
return extraCACertificates;
}
function getCACertificates(type = "default") {
validateString(type, "type");
switch (type) {
case "default":
return cacheDefaultCACertificates();
case "bundled":
return cacheBundledRootCertificates();
case "system":
return cacheSystemCACertificates();
case "extra":
return cacheExtraCACertificates();
default:
throw $ERR_INVALID_ARG_VALUE("type", type);
}
}
export default {
CLIENT_RENEG_LIMIT,
CLIENT_RENEG_WINDOW,
@@ -742,6 +801,7 @@ export default {
TLSSocket,
checkServerIdentity,
get rootCertificates() {
return rootCertificates;
return cacheBundledRootCertificates();
},
getCACertificates,
} as any as typeof import("node:tls");

View File

@@ -3,6 +3,7 @@
'use strict';
const crypto = require('crypto');
const net = require('net');
const assert = require('assert');
exports.ccs = Buffer.from('140303000101', 'hex');
@@ -173,4 +174,16 @@ function P_hash(algo, secret, seed, size) {
return result;
}
exports.assertIsCAArray = function assertIsCAArray(certs) {
assert(Array.isArray(certs));
assert(certs.length > 0);
// The certificates looks PEM-encoded.
for (const cert of certs) {
const trimmed = cert.trim();
assert.match(trimmed, /^-----BEGIN CERTIFICATE-----/);
assert.match(trimmed, /-----END CERTIFICATE-----$/);
}
};
exports.TestTLSSocket = TestTLSSocket;

View File

@@ -0,0 +1,17 @@
'use strict';
const tls = require('tls');
const assert = require('assert');
const defaultSet = new Set(tls.getCACertificates('default'));
const extraSet = new Set(tls.getCACertificates('extra'));
console.log(defaultSet.size, 'default certificates');
console.log(extraSet.size, 'extra certificates')
// Parent process is supposed to call this with
// NODE_EXTRA_CA_CERTS set to test/fixtures/keys/ca1-cert.pem.
assert.strictEqual(extraSet.size, 1);
// Check that default set is a super set of extra set.
assert.deepStrictEqual(defaultSet.intersection(extraSet),
extraSet);

View File

@@ -0,0 +1,11 @@
'use strict';
// This fixture just writes tls.getCACertificates() outputs to process.env.CA_OUT
const tls = require('tls');
const fs = require('fs');
const assert = require('assert');
assert(process.env.CA_TYPE);
assert(process.env.CA_OUT);
const certs = tls.getCACertificates(process.env.CA_TYPE);
fs.writeFileSync(process.env.CA_OUT, JSON.stringify(certs), 'utf8');

View File

@@ -0,0 +1,17 @@
'use strict';
// Flags: --no-use-openssl-ca
// This tests that tls.getCACertificates() returns the bundled
// certificates correctly.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('assert');
const tls = require('tls');
const defaultSet = new Set(tls.getCACertificates('default'));
const bundledSet = new Set(tls.getCACertificates('bundled'));
// When --use-openssl-ca is false (i.e. bundled CA is sued),
// default is a superset of bundled certificates.
assert.deepStrictEqual(defaultSet.intersection(bundledSet), bundledSet);

View File

@@ -0,0 +1,20 @@
'use strict';
// This tests that tls.getCACertificates() returns the bundled
// certificates correctly.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('assert');
const tls = require('tls');
const { assertIsCAArray } = require('../common/tls');
const certs = tls.getCACertificates('bundled');
assertIsCAArray(certs);
// It's the same as tls.rootCertificates - both are
// Mozilla CA stores across platform.
assert.strictEqual(certs, tls.rootCertificates);
// It's cached on subsequent accesses.
assert.strictEqual(certs, tls.getCACertificates('bundled'));

View File

@@ -0,0 +1,20 @@
'use strict';
// This tests that tls.getCACertificates() returns the default
// certificates correctly.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('assert');
const tls = require('tls');
const { assertIsCAArray } = require('../common/tls');
const certs = tls.getCACertificates();
assertIsCAArray(certs);
const certs2 = tls.getCACertificates('default');
assert.strictEqual(certs, certs2);
// It's cached on subsequent accesses.
assert.strictEqual(certs, tls.getCACertificates('default'));

View File

@@ -0,0 +1,20 @@
'use strict';
// This tests that tls.getCACertificates() throws error when being
// passed an invalid argument.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const assert = require('assert');
const tls = require('tls');
for (const invalid of [1, null, () => {}, true]) {
assert.throws(() => tls.getCACertificates(invalid), {
code: 'ERR_INVALID_ARG_TYPE'
});
}
assert.throws(() => tls.getCACertificates('test'), {
code: 'ERR_INVALID_ARG_VALUE'
});

View File

@@ -0,0 +1,29 @@
'use strict';
// This tests that tls.getCACertificates('extra') returns an empty
// array if NODE_EXTRA_CA_CERTS is empty.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const tmpdir = require('../common/tmpdir');
const fs = require('fs');
const assert = require('assert');
const { spawnSyncAndExitWithoutError } = require('../common/child_process');
const fixtures = require('../common/fixtures');
tmpdir.refresh();
const certsJSON = tmpdir.resolve('certs.json');
// If NODE_EXTRA_CA_CERTS is not set, it should be an empty array.
spawnSyncAndExitWithoutError(process.execPath, [fixtures.path('tls-get-ca-certificates.js')], {
env: {
...process.env,
NODE_EXTRA_CA_CERTS: undefined,
CA_TYPE: 'extra',
CA_OUT: certsJSON,
}
});
const parsed = JSON.parse(fs.readFileSync(certsJSON, 'utf-8'));
assert.deepStrictEqual(parsed, []);

View File

@@ -0,0 +1,16 @@
'use strict';
// This tests that tls.getCACertificates('defulat') returns a superset
// of tls.getCACertificates('extra') when NODE_EXTRA_CA_CERTS is used.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const { spawnSyncAndExitWithoutError } = require('../common/child_process');
const fixtures = require('../common/fixtures');
spawnSyncAndExitWithoutError(process.execPath, [fixtures.path('tls-check-extra-ca-certificates.js')], {
env: {
...process.env,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'ca1-cert.pem'),
}
});

View File

@@ -0,0 +1,29 @@
'use strict';
// This tests that tls.getCACertificates('extra') returns the extra
// certificates from NODE_EXTRA_CA_CERTS correctly.
const common = require('../common');
if (!common.hasCrypto) common.skip('missing crypto');
const tmpdir = require('../common/tmpdir');
const fs = require('fs');
const assert = require('assert');
const { spawnSyncAndExitWithoutError } = require('../common/child_process');
const fixtures = require('../common/fixtures');
tmpdir.refresh();
const certsJSON = tmpdir.resolve('certs.json');
// If NODE_EXTRA_CA_CERTS is set, it should contain a list of certificates.
spawnSyncAndExitWithoutError(process.execPath, [fixtures.path('tls-get-ca-certificates.js')], {
env: {
...process.env,
NODE_EXTRA_CA_CERTS: fixtures.path('keys', 'ca1-cert.pem'),
CA_TYPE: 'extra',
CA_OUT: certsJSON,
}
});
const parsed = JSON.parse(fs.readFileSync(certsJSON, 'utf-8'));
assert.deepStrictEqual(parsed, [fixtures.readKey('ca1-cert.pem', 'utf8')]);

View File

@@ -1,9 +1,8 @@
import { TLSBinding } from "bun:internal-for-testing";
import { canonicalizeIP } from "bun:internal-for-testing";
import { createTest } from "node-harness";
import { rootCertificates } from "tls";
const { describe, expect } = createTest(import.meta.path);
const { canonicalizeIP, rootCertificates } = TLSBinding;
describe("NodeTLS.cpp", () => {
test("canonicalizeIP", () => {
expect(canonicalizeIP("127.0.0.1")).toBe("127.0.0.1");