Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 40 additions & 20 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,8 @@ static int us_sni_ex_idx = -1;
static int us_ctx_cache_ex_idx = -1;
/* Marks an SSL_CTX whose verification store holds user-provided CAs (the
* ca/caFile options or a later addCACert): the per-socket client attach must
* not replace such a store with the process-shared default roots. */
* not replace such a store with the process-shared default roots, and
* tls.setDefaultCACertificates() must not override it either. */
static int us_ctx_user_ca_ex_idx = -1;
static int us_ssl_reneg_state_idx = -1;
/* Per-connection async-SNI suspension state (select_certificate_cb retry). */
Expand Down Expand Up @@ -948,28 +949,26 @@ int us_ssl_ctx_add_ca_cert(SSL_CTX *ctx, const char *content) {
if (!ctx || !content) {
return 0;
}
us_ex_idx_ensure();
X509_STORE *store = SSL_CTX_get_cert_store(ctx);
/* Clone-on-write: a context that shares the process-wide default root
* store must get its own copy before a CA is appended, or the addition
* would be visible to every other context in the process - the same
* root_cert_store check Node's SecureContext::AddCACert performs.
* us_get_shared_default_ca_store() up-refs before returning, so release
* the reference taken just for this comparison. */
X509_STORE *shared = us_get_shared_default_ca_store();
int store_is_shared = store && store == shared;
X509_STORE_free(shared);
/* A default context built without ca/requestCert keeps the empty store from
* SSL_CTX_new() (verification for it normally comes from the per-socket
* shared-root override). addCACert must EXTEND the default trust set the
* way Node does, so when the store is the shared one - or still empty -
* replace it with a fresh full default store (bundled roots, NODE_EXTRA_CA
* certificates, system CAs when enabled) before appending the user's CA. */
int store_is_empty = 0;
if (store && !store_is_shared) {
const STACK_OF(X509_OBJECT) *objs = X509_STORE_get0_objects(store);
store_is_empty = objs == NULL || sk_X509_OBJECT_num(objs) == 0;
}
if (store_is_shared || store_is_empty) {
*
* us_ctx_user_ca_ex_idx is the contract here: it is set exactly when this
* SSL_CTX has a PRIVATE store (built from an explicit ca/caFile option or
* by an earlier addCACert on this context). When the flag is clear, the
* store is either the process-wide shared-default X509_STORE (attached by
* the request_cert-without-ca branch above) or the still-empty store from
* SSL_CTX_new(); both require swapping in a fresh private default store
* before appending. Do NOT compare `store` against
* us_get_shared_default_ca_store() by pointer identity:
* tls.setDefaultCACertificates() invalidates and rebuilds that cache, so a
* CTX built before the override would compare its stale shared pointer
* against the new one, fall through, and mutate a store other CTXs still
* share. */
if (!SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) {
X509_STORE *own = us_get_default_ca_store();
if (!own) {
return 0;
Expand All @@ -980,7 +979,6 @@ int us_ssl_ctx_add_ca_cert(SSL_CTX *ctx, const char *content) {
if (!store) {
return 0;
}
us_ex_idx_ensure();
SSL_CTX_set_ex_data(ctx, us_ctx_user_ca_ex_idx, (void *)1);
return add_ca_cert_to_ctx_store(ctx, content, store);
}
Expand Down Expand Up @@ -1164,9 +1162,9 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx,
* bundle without touching the CTX (servers using the same CTX never pay
* the ~150-root build). us_verify_callback returns 1 so the handshake
* never aborts here — JS reads verify_error and decides. */
us_ex_idx_ensure();
if (SSL_CTX_get_verify_mode(ctx) == SSL_VERIFY_NONE) {
SSL_set_verify(ssl, SSL_VERIFY_PEER, us_verify_callback);
us_ex_idx_ensure();
if (!SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) {
/* Default context: give this socket the process-shared root bundle.
* A context whose store holds user-provided CAs (ca/caFile options or
Expand All @@ -1175,13 +1173,35 @@ void us_internal_ssl_attach(struct us_socket_t *s, SSL_CTX *ctx,
X509_STORE *roots = us_get_shared_default_ca_store();
if (roots) SSL_set0_verify_cert_store(ssl, roots);
}
} else if (us_has_user_root_certs()
&& !SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) {
/* CTX was built against the process defaults (request_cert without an
* explicit `ca`), but tls.setDefaultCACertificates() has since replaced
* those defaults. Override the verify store for this SSL only so the
* new roots take effect without rebuilding the cached SSL_CTX
* (fetch()/https.request() cache their HTTPS context for the process
* lifetime). CTXs with user CAs are left alone. */
X509_STORE *roots = us_get_shared_default_ca_store();
if (roots) SSL_set0_verify_cert_store(ssl, roots);
}
} else {
SSL_set_accept_state(ssl);
SSL_set_renegotiate_mode(ssl, ssl_renegotiate_never);
/* sni_cb recovers ls per-SSL — never via the shared SSL_CTX. */
us_ex_idx_ensure();
SSL_set_ex_data(ssl, us_ssl_listener_ex_idx, listener);
/* Same refresh as the client path: an mTLS server built with
* request_cert before tls.setDefaultCACertificates() was called still
* holds the stale shared store on its SSL_CTX; hand each accepted SSL
* the current process defaults so client-cert verification follows the
* override. A CTX that has its own CA set (explicit `ca` option or a
* later addCACert) is left alone. */
if (us_has_user_root_certs()
&& SSL_CTX_get_verify_mode(ctx) != SSL_VERIFY_NONE
&& !SSL_CTX_get_ex_data(ctx, us_ctx_user_ca_ex_idx)) {
X509_STORE *roots = us_get_shared_default_ca_store();
if (roots) SSL_set0_verify_cert_store(ssl, roots);
}
}

s->ssl = ssl;
Expand Down
114 changes: 102 additions & 12 deletions packages/bun-usockets/src/crypto/root_certs.cpp
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#include "./root_certs.h"
#include "./root_certs_header.h"
#include "./internal/internal.h"
#include <atomic>
#include <mutex>
#include <string.h>
#include "./default_ciphers.h"
Expand Down Expand Up @@ -187,6 +188,67 @@ STACK_OF(X509) *us_get_root_extra_cert_instances() {
return us_get_default_ca_certificates()->root_extra_cert_instances;
}

// ---------------------------------------------------------------------------
// User-overridden default CA certificates (tls.setDefaultCACertificates)
//
// Node.js lets JS replace the default trust root set at runtime. Once set,
// *every* consumer of the "default" store — us_get_default_ca_store() and
// us_get_shared_default_ca_store() — must ignore the bundled/system/extra
// sources and build the store purely from this user-supplied set. The
// override is process-global here (Node.js uses thread_local so Workers are
// isolated; we accept the simpler process-wide semantics for now) and guarded
// by shared_store_mutex because the shared store can be read from socket
// I/O paths on other threads while JS swaps it.
// ---------------------------------------------------------------------------
static std::mutex shared_store_mutex;
static X509_STORE *shared_store = nullptr;
static STACK_OF(X509) *user_root_certs = nullptr;
// Atomic so the lock-free fast-path check in us_has_user_root_certs()
// (called per-SSL from us_internal_ssl_attach) is well-defined when a
// Worker is concurrently setting the override. Relaxed is enough: a
// stale false falls through to the shared-store path (which also honours
// the override under the mutex), a stale true just costs one extra
// SSL_set0_verify_cert_store.
static std::atomic<bool> has_user_root_certs { false };

extern "C" int us_has_user_root_certs() {
return has_user_root_certs.load(std::memory_order_relaxed) ? 1 : 0;
}

extern "C" void us_set_user_root_certs(STACK_OF(X509) *certs) {
std::lock_guard<std::mutex> lock(shared_store_mutex);
if (user_root_certs) {
sk_X509_pop_free(user_root_certs, X509_free);
}
user_root_certs = certs; // may be nullptr for an explicit empty set
has_user_root_certs = true;

// Drop the cached shared store so the next consumer rebuilds from the
// override. Existing SSL*s already hold their own X509_STORE reference via
// SSL_set0_verify_cert_store, so this only affects new connections.
if (shared_store) {
X509_STORE_free(shared_store);
shared_store = nullptr;
}
Comment thread
robobun marked this conversation as resolved.
}

STACK_OF(X509) *us_dup_user_root_certs(bool *out_has_override) {
// Hand back an owned, up-ref'd snapshot so the caller can serialise the
// certs to PEM without racing us_set_user_root_certs() on another Worker.
// *out_has_override distinguishes "no override installed" from "empty
// override installed" (both return nullptr). Caller frees the returned
// stack via sk_X509_pop_free(.., X509_free).
std::lock_guard<std::mutex> lock(shared_store_mutex);
if (out_has_override) *out_has_override = has_user_root_certs;
if (user_root_certs == nullptr) return nullptr;
STACK_OF(X509) *dup = sk_X509_dup(user_root_certs);
if (dup == nullptr) return nullptr;
for (size_t i = 0; i < sk_X509_num(dup); i++) {
X509_up_ref(sk_X509_value(dup, i));
}
return dup;
}

// Single source of truth for the OS trust store. Loaded on first demand,
// independent of --use-system-ca / NODE_USE_SYSTEM_CA, so that
// tls.getCACertificates('system') matches Node.js (which always reads the
Expand All @@ -207,12 +269,28 @@ STACK_OF(X509) *us_get_root_system_cert_instances() {
return system_certs;
}

extern "C" X509_STORE *us_get_default_ca_store() {
static X509_STORE *us_build_default_ca_store_locked() {
X509_STORE *store = X509_STORE_new();
if (store == NULL) {
return NULL;
}

// If JS overrode the defaults via tls.setDefaultCACertificates(), honour
// that exclusively — Node.js does not merge bundled/system/extra back in.
// X509_STORE_add_cert() takes its own reference, so no up_ref here —
// unlike the bundled/extra/system blocks below (whose certs are
// process-lifetime statics so the extra ref is harmless), user_root_certs
// is freed on every subsequent setDefaultCACertificates() and an extra
// ref would leak.
if (has_user_root_certs) {
if (user_root_certs) {
for (int i = 0; i < (int)sk_X509_num(user_root_certs); i++) {
X509_STORE_add_cert(store, sk_X509_value(user_root_certs, i));
}
Comment thread
robobun marked this conversation as resolved.
}
return store;
}

if (!X509_STORE_set_default_paths(store)) {
X509_STORE_free(store);
return NULL;
Expand Down Expand Up @@ -253,18 +331,30 @@ extern "C" X509_STORE *us_get_default_ca_store() {
return store;
}

// Process-wide immutable default store. Safe to share across SSL_CTXs that
// don't add per-config CAs (the user-`ca` path in build_raw populates the
// SSL_CTX's own private, initially-empty store instead). This makes the
// ~150-root build a once-per-process cost instead of once-per-SSL_CTX, which
// is what kept Bun.connect({tls:true}) under the node-tls-server.test.ts
// 100ms cold-path budget in debug+ASAN.
extern "C" X509_STORE *us_get_default_ca_store() {
// Serialise with us_set_user_root_certs() so a Worker swapping the
// override can't race another Worker building a per-config store.
std::lock_guard<std::mutex> lock(shared_store_mutex);
return us_build_default_ca_store_locked();
}

// Process-wide default store cached behind a mutex. Safe to share across
// SSL_CTXs that don't add per-config CAs (the user-`ca` path in build_raw
// populates the SSL_CTX's own private, initially-empty store instead). This
// makes the ~150-root build a once-per-process cost instead of
// once-per-SSL_CTX, which is what kept Bun.connect({tls:true}) under the
// node-tls-server.test.ts 100ms cold-path budget in debug+ASAN.
//
// Not std::call_once: tls.setDefaultCACertificates() must be able to
// invalidate the cached store so subsequent connections see the override.
// us_set_user_root_certs() takes the same mutex and nulls shared_store.
extern "C" X509_STORE *us_get_shared_default_ca_store() {
static X509_STORE *shared = nullptr;
static std::once_flag once;
std::call_once(once, []() { shared = us_get_default_ca_store(); });
if (shared) X509_STORE_up_ref(shared);
return shared;
std::lock_guard<std::mutex> lock(shared_store_mutex);
if (shared_store == nullptr) {
shared_store = us_build_default_ca_store_locked();
}
if (shared_store) X509_STORE_up_ref(shared_store);
return shared_store;
}

extern "C" const char *us_get_default_ciphers() {
Expand Down
3 changes: 3 additions & 0 deletions packages/bun-usockets/src/crypto/root_certs_header.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@

STACK_OF(X509) *us_get_root_extra_cert_instances();
STACK_OF(X509) *us_get_root_system_cert_instances();
STACK_OF(X509) *us_dup_user_root_certs(bool *out_has_override);

#else
#define CPPDECL extern
#endif

CPPDECL X509_STORE *us_get_default_ca_store();
CPPDECL X509_STORE *us_get_shared_default_ca_store();
CPPDECL void us_set_user_root_certs(STACK_OF(X509) *certs);
CPPDECL int us_has_user_root_certs();
Loading
Loading