Skip to content

tls: implement SecureContext.addCACert#30486

Closed
robobun wants to merge 19 commits into
mainfrom
farm/e15c357d/tls-add-ca-cert
Closed

tls: implement SecureContext.addCACert#30486
robobun wants to merge 19 commits into
mainfrom
farm/e15c357d/tls-add-ca-cert

Conversation

@robobun

@robobun robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #30485

Reproduction

$ bun -e 'require("tls").createSecureContext().context.addCACert("some CA")'
TypeError: tls.createSecureContext().context.addCACert is not a function.

Root cause

The native SecureContext class in SecureContext.classes.ts declared an
empty proto: {}, so none of the methods Node exposes on
secureContext.context (addCACert, addCRL, setCert, …) existed.

Secondary: tls.createSecureContext() with no argument threw
TLSOptions must be an object because bindgen's generated converter is
strict; WebIDL treats missing options as an empty dictionary.

Fix

  • Add addCACert to the SecureContext prototype, backed by a new
    us_ssl_ctx_add_ca_pem helper in packages/bun-usockets/src/crypto/openssl.c
    that mirrors the existing options.ca path: on first call against a
    VERIFY_NONE context, install us_get_default_ca_store() so the added CA
    joins (not replaces) OS/baked-in roots, flip verify_mode to
    SSL_VERIFY_PEER so the per-socket client override in
    us_internal_ssl_attach doesn't discard it, and append each PEM block.
  • Match Node's leniency: strings and Buffer/TypedArray/ArrayBuffer
    accepted; empty/malformed/duplicate input silently ignored; multi-cert
    bundles walked to completion.
  • Before mutating, drop the SecureContext from both the per-VM
    SSLContextCache and the per-global Bun__SecureContextCache so a later
    createSecureContext(sameOptions) builds a fresh SSL_CTX instead of
    handing back the mutated one. Preserves the "one config, one SSL_CTX" hot
    path for the pure createSecureContext({ca, cert, key}) pattern.
  • Handle undefined/null at intern()/constructor() to skip bindgen's
    non-object check (matches Node's empty-dictionary semantics).

Verification

  • 10 new tests in test/js/node/tls/node-tls-context.test.ts covering
    the basic surface, trust-chain acceptance, rejection of unrelated CAs,
    Buffer input, multi-cert bundles, preservation of default trust anchors,
    duplicate-idempotency, and the no-arg throw.
  • Existing 7 SecureContext-adjacent tests in the same file still pass,
    plus ssl-ctx-cache.test.ts (5), node-tls-connect.test.ts (26+),
    node-tls-create-secure-context-args.test.ts (5).

Rebase note (main merged the Rust rewrite #30412)

Rebasing onto main surfaced two large upstream changes:

  1. Rust rewrite (Rewrite Bun in Rust #30412): SecureContext, SSLContextCache, and the
    SSLWrapper client-attach path are now Rust. The implementation was ported
    from the (now-dead) .zig files to SecureContext.rs / SSLContextCache.rs
    / uws/lib.rs / uws_sys/SocketContext.rs. The C helpers
    (us_ssl_ctx_add_ca_pem, us_ctx_has_user_ca) live in openssl.c, which the
    rewrite didn't touch, so they carried over unchanged.

  2. Strict CA semantics on main: an explicit ca / ca_file_name /
    requestCert option now replaces the default trust store (Node.js
    semantics — chains validate only against supplied CAs) instead of merging with
    it. The openssl.c conflict was resolved by keeping main's strict-replace
    behavior and layering the addCACert marker (us_ctx_user_ca_idx) on top, so
    a later addCACert appends to the construction-time store rather than swapping
    in the default roots.

All 20 addCACert tests pass after the rebase; ssl-ctx-cache,
node-tls-server, node-tls-cert, and the Duplex/upgrade SSLWrapper tests
stay green.

@robobun

robobun commented May 11, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai

coderabbitai Bot commented May 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds SecureContext.prototype.addCACert: C API to append PEM CA certs to SSL_CTX, cache invalidation (native + JS), Zig/JSC bindings, JS exposure, internal test helper, and comprehensive tests validating behavior and trust-anchor preservation.

Changes

TLS Secure Context addCACert Implementation

Layer / File(s) Summary
Low-level OpenSSL CA Append API
packages/bun-usockets/src/crypto/openssl.c, packages/bun-usockets/src/libusockets.h, src/uws_sys/SocketContext.zig
Adds us_ssl_ctx_add_ca_pem(SSL_CTX *ctx, const char *pem, size_t pem_len), ex_data marker, client attach change to preserve user CAs, and required header include for INT_MAX.
Cache Invalidation (native)
src/runtime/api/bun/SSLContextCache.zig
Adds invalidate() to remove a cached SSL_CTX entry, clear its ex_data under the cache mutex, and destroy the Entry so subsequent gets rebuild a fresh SSL_CTX.
SecureContext JS Cache Bindings
src/jsc/bindings/BunSecureContextCache.h, src/jsc/bindings/BunSecureContextCache.cpp
Adds SecureContextCache::remove(uint64_t) and exported Bun__SecureContextCache__remove to delete JS cache entries by key.
SecureContext Entry Point Defaults & addCACert (Zig)
src/runtime/api/bun/SecureContext.zig
Treats createSecureContext() undefined/null as default config; implements addCACert that validates input, invalidates native and JS caches, calls us_ssl_ctx_add_ca_pem(...), and adds jsVerifyMode test helper with required cpp extern.
JavaScript API Binding
src/runtime/api/SecureContext.classes.ts
Exposes addCACert on SecureContext.prototype (accepts string/Buffer, multi-cert PEM, lenient on malformed inputs).
Internal Testing Export
src/js/internal-for-testing.ts
Exports secureContextVerifyMode to read underlying SSL_CTX verify mode for tests.
Test Suite
test/js/node/tls/node-tls-context.test.ts
Adds regression and integration tests verifying method presence, input handling (string/Buffer/multi-cert/malformed), idempotency, trust-anchor preservation with NODE_EXTRA_CA_CERTS, required-argument behavior, and that verify_mode is unchanged by addCACert.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title 'tls: implement SecureContext.addCACert' accurately and concisely describes the main change: implementing a new addCACert method on the SecureContext prototype.
Linked Issues check ✅ Passed All primary objectives from #30485 are met: addCACert is exposed on SecureContext.context, accepts Node-compatible input types, preserves default trust anchors, maintains SSL_CTX caching, and supports no-argument calls.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing addCACert and fixing related input-handling. Changes include the new OpenSSL C helper, JavaScript bindings, Zig cache invalidation, and comprehensive test coverage with no extraneous modifications.
Description check ✅ Passed The pull request description comprehensively covers the issue, root causes, fix details, and verification approach with substantial technical depth.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/runtime/api/bun/SecureContext.zig`:
- Around line 168-175: The JS SecureContext entry must be removed from every
JSGlobal in the VM, not just the current global; instead of calling
cpp.Bun__SecureContextCache__remove only with the current global, iterate all
globals owned by global.bunVM() and call cpp.Bun__SecureContextCache__remove for
each using the same key derived from this.digest (the u64 little-endian from
this.digest[0..8]) after invalidating the native sslCtxCache via
global.bunVM().rareData().sslCtxCache().invalidate(this.ctx, this.digest), so
every global’s WeakGCMap entry for that digest is cleared and future
createSecureContext(sameOptions) calls will rebuild.

In `@test/js/node/tls/node-tls-context.test.ts`:
- Around line 667-690: The test "preserves OS/default trust anchors" currently
depends on external network (tls.connect to bun.sh), make it deterministic by
replacing the external connection with a local TLS server (use tls.createServer
or an HTTPS server) and assert the same behavior: call
tls.createSecureContext(), use ctx.context.addCACert(ca2) as before, connect to
the local server with tls.connect and check client.authorized via the same
Promise.withResolvers pattern; alternatively, if you want minimal change, catch
network errors from tls.connect and skip/mark the test as flaky on network
failure — adjust the test around tls.createSecureContext, ctx.context.addCACert,
tls.connect, and Promise.withResolvers to implement the local-server approach or
conditional skip.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: b077ca78-42d3-4432-aefe-73de2e4c33b8

📥 Commits

Reviewing files that changed from the base of the PR and between 450072b and 3bb6558.

📒 Files selected for processing (9)
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/libusockets.h
  • src/jsc/bindings/BunSecureContextCache.cpp
  • src/jsc/bindings/BunSecureContextCache.h
  • src/runtime/api/SecureContext.classes.ts
  • src/runtime/api/bun/SSLContextCache.zig
  • src/runtime/api/bun/SecureContext.zig
  • src/uws_sys/SocketContext.zig
  • test/js/node/tls/node-tls-context.test.ts

Comment thread src/runtime/api/bun/SecureContext.zig Outdated
Comment thread test/js/node/tls/node-tls-context.test.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
test/js/node/tls/node-tls-context.test.ts (1)

691-714: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid hard external-network dependency in this trust-anchor test.

Line 701 reaches bun.sh:443, which can fail due to DNS/network/transient CI conditions unrelated to addCACert, making this test flaky. Please make this deterministic (preferred) or explicitly handle network-unavailable errors so CI stability is not coupled to internet reachability.

As per coding guidelines: “Do not write flaky tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/js/node/tls/node-tls-context.test.ts` around lines 691 - 714, The test
"preserves OS/default trust anchors" currently relies on connecting to
bun.sh:443 which is flaky; change it to avoid external network by either (A)
making the assertion against a local TLS server: create a local tls.createServer
using a certificate signed by your unrelated CA (ca2), call
tls.createSecureContext().context.addCACert(ca2) and connect to the local server
with tls.connect to verify client.authorized is true, or (B) if you must keep
the external check, catch network/DNS errors on client.on("error") (e.g.,
ENOTFOUND, EAI_AGAIN, ECONNREFUSED, ENETUNREACH) and treat them as a skipped
test (log a warning and resolve/return without failing) so CI flakiness is
avoided; update the test around tls.createSecureContext, ctx.context.addCACert,
tls.connect and client.on("error") accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Duplicate comments:
In `@test/js/node/tls/node-tls-context.test.ts`:
- Around line 691-714: The test "preserves OS/default trust anchors" currently
relies on connecting to bun.sh:443 which is flaky; change it to avoid external
network by either (A) making the assertion against a local TLS server: create a
local tls.createServer using a certificate signed by your unrelated CA (ca2),
call tls.createSecureContext().context.addCACert(ca2) and connect to the local
server with tls.connect to verify client.authorized is true, or (B) if you must
keep the external check, catch network/DNS errors on client.on("error") (e.g.,
ENOTFOUND, EAI_AGAIN, ECONNREFUSED, ENETUNREACH) and treat them as a skipped
test (log a warning and resolve/return without failing) so CI flakiness is
avoided; update the test around tls.createSecureContext, ctx.context.addCACert,
tls.connect and client.on("error") accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3e94d8a0-6e11-4386-956d-b82c6e4f4350

📥 Commits

Reviewing files that changed from the base of the PR and between 3bb6558 and f639bbc.

📒 Files selected for processing (1)
  • test/js/node/tls/node-tls-context.test.ts

Comment thread packages/bun-usockets/src/crypto/openssl.c Outdated
Comment thread packages/bun-usockets/src/crypto/openssl.c

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/js/node/tls/node-tls-context.test.ts`:
- Around line 743-747: The test currently asserts exitCode before checking the
child process payload; change the assertion order so you validate
stdout/stderr/parsed result first and only then assert exitCode. Specifically,
after awaiting proc.stdout.text(), proc.stderr.text(), and proc.exited into
[stdout, stderr, exitCode], parse and assert stdout/ stderr and the JSON
result.authorized (using stdout.trim() → JSON.parse → result.authorized) before
calling expect(exitCode).toBe(0); keep the same variables (proc, stdout, stderr,
exitCode, result) and assertions but move the exitCode check to the end.
- Around line 632-643: The test "silently ignores non-PEM input (Node parity)"
currently asserts that ctx.context.addCACert accepts null, undefined, numbers
and plain objects; narrow it to the intended input surface by removing those
assertions and only keep byte-like/malformed inputs (e.g., string values like ""
and "not pem" and Buffer/TypedArray/ArrayBuffer cases such as Buffer.alloc(0))
when calling ctx.context.addCACert; leave zero-arg and other invalid-type
behavior to their dedicated tests elsewhere.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c5449b41-03a2-4846-b008-0e1330dee39a

📥 Commits

Reviewing files that changed from the base of the PR and between c571f12 and 209d4c5.

📒 Files selected for processing (1)
  • test/js/node/tls/node-tls-context.test.ts

Comment thread test/js/node/tls/node-tls-context.test.ts Outdated
Comment thread test/js/node/tls/node-tls-context.test.ts
Comment thread test/js/node/tls/node-tls-context.test.ts
robobun added a commit that referenced this pull request May 11, 2026
Two review findings on the addCACert implementation:

1. The previous revision called SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, ...)
   so that us_internal_ssl_attach's client branch wouldn't override the
   CTX's cert store with the shared default roots. But SecureContext is
   mode-neutral — the same object may back both tls.connect and
   tls.createServer — and VERIFY_PEER on a server CTX makes BoringSSL send
   CertificateRequest in every handshake, even when the user never set
   requestCert. This broke the invariant documented in us_ssl_ctx_from_options
   ("MUST NOT force VERIFY_PEER here") and diverged from Node's
   SecureContext::AddCACert, which leaves verify_mode alone.

   Replace the verify_mode flip with an ex_data marker (us_ctx_user_ca_idx)
   set when addCACert installs a non-default cert store. The client branch
   of us_internal_ssl_attach reads the marker and skips its per-socket
   SSL_set0_verify_cert_store override when set, so the CTX's own store
   (default roots + user CAs) is the one consulted for verification.

2. Also call SSL_CTX_add_client_CA(ctx, x) inside the PEM loop, matching
   Node's SecureContext::AddCACert and Bun's own construction-time
   add_ca_cert_to_ctx_store. This surfaces the CA's DN in the
   CertificateRequest's certificate_authorities list for servers that do
   set requestCert — picky clients (browsers, smartcard middleware) use it
   to pick among client certs.

Adds bun:internal-for-testing.secureContextVerifyMode so the regression
is gate-able: a test asserting the CTX stays SSL_VERIFY_NONE after
addCACert fails before the fix (received 1 = VERIFY_PEER) and passes
after (0 = VERIFY_NONE).

Resolves the two findings on #30486.
Comment thread packages/bun-usockets/src/crypto/openssl.c
Comment thread test/js/node/tls/node-tls-context.test.ts Outdated
Comment thread packages/bun-usockets/src/libusockets.h Outdated
Comment thread packages/bun-usockets/src/crypto/openssl.c
Comment thread packages/bun-usockets/src/crypto/openssl.c
Comment thread test/js/node/tls/node-tls-context.test.ts
Comment thread test/js/node/tls/node-tls-context.test.ts Outdated
Comment thread src/runtime/api/bun/SecureContext.zig
Comment thread src/runtime/api/bun/SecureContext.zig
@robobun robobun force-pushed the farm/e15c357d/tls-add-ca-cert branch from 48e454c to 05bd65b Compare May 11, 2026 13:55
Comment thread packages/bun-usockets/src/crypto/openssl.c Outdated
robobun added a commit that referenced this pull request May 26, 2026
Two review findings on the addCACert implementation:

1. The previous revision called SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, ...)
   so that us_internal_ssl_attach's client branch wouldn't override the
   CTX's cert store with the shared default roots. But SecureContext is
   mode-neutral — the same object may back both tls.connect and
   tls.createServer — and VERIFY_PEER on a server CTX makes BoringSSL send
   CertificateRequest in every handshake, even when the user never set
   requestCert. This broke the invariant documented in us_ssl_ctx_from_options
   ("MUST NOT force VERIFY_PEER here") and diverged from Node's
   SecureContext::AddCACert, which leaves verify_mode alone.

   Replace the verify_mode flip with an ex_data marker (us_ctx_user_ca_idx)
   set when addCACert installs a non-default cert store. The client branch
   of us_internal_ssl_attach reads the marker and skips its per-socket
   SSL_set0_verify_cert_store override when set, so the CTX's own store
   (default roots + user CAs) is the one consulted for verification.

2. Also call SSL_CTX_add_client_CA(ctx, x) inside the PEM loop, matching
   Node's SecureContext::AddCACert and Bun's own construction-time
   add_ca_cert_to_ctx_store. This surfaces the CA's DN in the
   CertificateRequest's certificate_authorities list for servers that do
   set requestCert — picky clients (browsers, smartcard middleware) use it
   to pick among client certs.

Adds bun:internal-for-testing.secureContextVerifyMode so the regression
is gate-able: a test asserting the CTX stays SSL_VERIFY_NONE after
addCACert fails before the fix (received 1 = VERIFY_PEER) and passes
after (0 = VERIFY_NONE).

Resolves the two findings on #30486.
@robobun robobun force-pushed the farm/e15c357d/tls-add-ca-cert branch from 05bd65b to 43afd6f Compare May 26, 2026 01:20
Comment thread packages/bun-usockets/src/crypto/openssl.c Outdated
robobun added a commit that referenced this pull request May 26, 2026
Two review findings on the addCACert implementation:

1. The previous revision called SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, ...)
   so that us_internal_ssl_attach's client branch wouldn't override the
   CTX's cert store with the shared default roots. But SecureContext is
   mode-neutral — the same object may back both tls.connect and
   tls.createServer — and VERIFY_PEER on a server CTX makes BoringSSL send
   CertificateRequest in every handshake, even when the user never set
   requestCert. This broke the invariant documented in us_ssl_ctx_from_options
   ("MUST NOT force VERIFY_PEER here") and diverged from Node's
   SecureContext::AddCACert, which leaves verify_mode alone.

   Replace the verify_mode flip with an ex_data marker (us_ctx_user_ca_idx)
   set when addCACert installs a non-default cert store. The client branch
   of us_internal_ssl_attach reads the marker and skips its per-socket
   SSL_set0_verify_cert_store override when set, so the CTX's own store
   (default roots + user CAs) is the one consulted for verification.

2. Also call SSL_CTX_add_client_CA(ctx, x) inside the PEM loop, matching
   Node's SecureContext::AddCACert and Bun's own construction-time
   add_ca_cert_to_ctx_store. This surfaces the CA's DN in the
   CertificateRequest's certificate_authorities list for servers that do
   set requestCert — picky clients (browsers, smartcard middleware) use it
   to pick among client certs.

Adds bun:internal-for-testing.secureContextVerifyMode so the regression
is gate-able: a test asserting the CTX stays SSL_VERIFY_NONE after
addCACert fails before the fix (received 1 = VERIFY_PEER) and passes
after (0 = VERIFY_NONE).

Resolves the two findings on #30486.
@robobun robobun force-pushed the farm/e15c357d/tls-add-ca-cert branch from de74a06 to fd18185 Compare May 26, 2026 01:58
Comment thread src/uws/lib.rs Outdated
Comment thread packages/bun-usockets/src/crypto/openssl.c Outdated
Comment thread test/js/node/tls/node-tls-context.test.ts Outdated
Comment thread src/jsc/bindings/BunSecureContextCache.cpp Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All prior review findings have been addressed and nothing new turned up, but this touches TLS trust-store semantics across C/BoringSSL, C++, and Rust with several subtle invariants — worth a human pass before merging.

Extended reasoning...

Overview

This PR implements Node's SecureContext.prototype.addCACert across 15 files: a new us_ssl_ctx_add_ca_pem helper and us_ctx_user_ca_idx ex_data marker in packages/bun-usockets/src/crypto/openssl.c (with hooks in three construction-time branches of us_ssl_ctx_build_raw and the client branch of us_internal_ssl_attach), C++ SecureContextCache::remove, Rust SecureContext::add_ca_cert / SSLContextCache::invalidate / the ssl_wrapper client-attach gate, parallel (dead-reference) Zig changes, JS class definitions, an internal-for-testing helper, and ~380 lines of tests.

Security risks

This is squarely security-sensitive: it mutates the X.509 trust store used for TLS peer verification and changes when the per-SSL default-roots override is applied. Earlier revisions had real correctness issues (CTX-level verify_mode flip leaking as server CertificateRequest; first addCACert on a {ca: …} context discarding construction-time CAs; the Duplex/SSLWrapper path discarding added CAs) — all fixed and now gated by tests, but the class of risk is exactly the kind that warrants human eyes.

Level of scrutiny

High. The change is neither simple nor mechanical: it threads an invariant ("CTX store is authoritative, do not override") through four producers and two consumers across three languages, interacts with two layers of caching keyed by config digest, and documents explicit Node-parity compromises (pre-mutation cells sharing an SSL_CTX, cross-global cache invalidation limits). Those are design decisions a maintainer should sign off on.

Other factors

No new findings from the bug-hunting system this run; every prior inline comment from me and CodeRabbit is resolved. Test coverage is good (12 new cases including the verify_mode gate, construction-time-CA preservation, and the Duplex/SSLWrapper path). I have not posted a review body on this PR before, so this deferral signals the bot review cycle is complete but auto-approval is not appropriate for TLS trust-store code of this scope.

@robobun

robobun commented May 26, 2026

Copy link
Copy Markdown
Collaborator Author

CI on c8f59c00: 73/74 checks green — all build lanes (every platform/arch, asan/baseline/musl), cargo clippy, lint, and the sibling macOS test lanes (darwin-14-x64-test-bun, darwin-26-aarch64-test-bun) all pass. The one red is darwin-14-aarch64-test-bun with status Expired — a Buildkite agent/queue timeout, not a test result; the job never completed. The last commit on this branch is a comment-only change to BunSecureContextCache.cpp, so it can't be a real test regression.

I already spent my one CI re-roll earlier (a6147010a1), so I'm not pushing another retrigger. The diff is green; this just needs a maintainer to re-run the expired darwin-14-aarch64 lane (or merge as-is).

robobun and others added 2 commits June 7, 2026 01:34
tls.createSecureContext().context.addCACert was undefined — the native
SecureContext class had an empty prototype. Add it to the proto surface
so it matches Node's API.

Also fix tls.createSecureContext() (no arg) throwing 'TLSOptions must
be an object': Bindgen's generated converter requires an object, but
Node's WebIDL treats missing options as an empty dictionary.

Implementation mirrors Node's lenient behavior:
- Accepts strings and Buffers/TypedArrays/ArrayBuffers, coerces
  anything else to a silent no-op
- Empty / malformed / duplicate PEM input is silently ignored
- Multi-cert bundles in a single call are walked to completion

The SSL_CTX path mirrors the existing 'options.ca' flow in
us_ssl_ctx_build_raw: on first call against a VERIFY_NONE context,
install us_get_default_ca_store() (OS roots + baked-in Bun roots) so
the added CA joins (not replaces) the existing trust anchors, and
flip verify_mode to SSL_VERIFY_PEER so the per-socket client override
in us_internal_ssl_attach doesn't discard the mutation.

Before mutating, drop the SecureContext from both the per-VM
SSLContextCache and the per-global Bun__SecureContextCache so a later
createSecureContext(sameOptions) builds a fresh SSL_CTX instead of
handing back the mutated one. Preserves the 'one config, one SSL_CTX'
hot path for the pure createSecureContext({ca, cert, key}) pattern.

Fixes #30485
robobun and others added 17 commits June 7, 2026 01:34
…TS fixture

The bun.sh test was flagged as a CI flake vector (network, cert rotation).
Replace with a subprocess test that stashes ca1 in NODE_EXTRA_CA_CERTS so
the 'default store' under test is deterministic and local. Still asserts
ADD-not-REPLACE semantics: addCACert(ca2) on a fresh context must keep
ca1-signed agent1 trusted.

Also expand the addCACert doc comment to flag the cross-global caveat
coderabbit raised — Bun is one-global-per-VM in production, so the
sibling-global cache-staleness edge is sandbox-only and not worth the
iteration machinery right now.
Two review findings on the addCACert implementation:

1. The previous revision called SSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, ...)
   so that us_internal_ssl_attach's client branch wouldn't override the
   CTX's cert store with the shared default roots. But SecureContext is
   mode-neutral — the same object may back both tls.connect and
   tls.createServer — and VERIFY_PEER on a server CTX makes BoringSSL send
   CertificateRequest in every handshake, even when the user never set
   requestCert. This broke the invariant documented in us_ssl_ctx_from_options
   ("MUST NOT force VERIFY_PEER here") and diverged from Node's
   SecureContext::AddCACert, which leaves verify_mode alone.

   Replace the verify_mode flip with an ex_data marker (us_ctx_user_ca_idx)
   set when addCACert installs a non-default cert store. The client branch
   of us_internal_ssl_attach reads the marker and skips its per-socket
   SSL_set0_verify_cert_store override when set, so the CTX's own store
   (default roots + user CAs) is the one consulted for verification.

2. Also call SSL_CTX_add_client_CA(ctx, x) inside the PEM loop, matching
   Node's SecureContext::AddCACert and Bun's own construction-time
   add_ca_cert_to_ctx_store. This surfaces the CA's DN in the
   CertificateRequest's certificate_authorities list for servers that do
   set requestCert — picky clients (browsers, smartcard middleware) use it
   to pick among client certs.

Adds bun:internal-for-testing.secureContextVerifyMode so the regression
is gate-able: a test asserting the CTX stays SSL_VERIFY_NONE after
addCACert fails before the fix (received 1 = VERIFY_PEER) and passes
after (0 = VERIFY_NONE).

Resolves the two findings on #30486.
Review found a real regression in the f1ddc9e ex_data-marker design:
the first addCACert() on a SecureContext built with options.ca /
options.ca_file_name / options.request_cert would swap in a fresh
us_get_default_ca_store(), silently discarding the construction-time
CAs (SSL_CTX_set_cert_store frees the previous store).

Fix: set us_ctx_user_ca_idx in all three build_raw branches that install
a populated trust store. That way the marker means 'this CTX's own
store already has default roots + user CAs' regardless of which path
supplied them, and addCACert correctly appends to the existing store
instead of replacing it.

Gated by a new regression test that fails before the fix (agent1 cert
signed by construction-time ca1 no longer authorized after
addCACert(ca2)) and passes after.

Also resolves the remaining coderabbit/claude review nits:
- Narrow the lenient-input test to the documented byte-like surface
  (string/Buffer/TypedArray/ArrayBuffer); drop the null/undefined/number/
  plain-object assertions that went past the PR contract.
- Static import for secureContextVerifyMode; no await-import in tests.
- Assert the child payload before the exit code in the subprocess
  fixture, per CLAUDE.md.
- Update stale doc comments in libusockets.h and SocketContext.zig to
  describe the ex_data-marker mechanism instead of the old VERIFY_PEER
  flip.
…mantics

Doc-only: the 'Second+ addCACert' branch now also covers contexts whose
user CAs were installed at construction by us_ssl_ctx_build_raw. Reword
so future readers don't assume the marker means 'prior addCACert only'.
Review caught that src/runtime/socket/ssl_wrapper.zig's client branch
mirrors us_internal_ssl_attach's per-SSL trust-store override but wasn't
updated in the f1ddc9e redesign. Result: TLS run on top of a Duplex
stream (tls.connect({socket: duplex}) / UpgradedDuplex / named pipes)
discarded addCACert'd CAs because the wrapper still unconditionally
called SSL_set0_verify_cert_store with shared default roots when the
CTX's verify_mode was VERIFY_NONE — which is the post-f1ddc9e state.

Expose us_ctx_has_user_ca as a public usockets C symbol and have the
SSLWrapper honor it the same way. Gated by a new test that runs a
tls.connect over a Duplex-proxied socket using a context whose only
trust anchor is the addCACert'd CA; the test fails before this fix
(client.authorized == false) and passes after.

Also repurpose the duplicate 'accepts no arg options' test — byte-for-
byte identical to the 'is a function' test above it — to cover the
createSecureContext(null) branch, which was previously unexercised.
Per test/CLAUDE.md, dynamic imports are only for tests that exercise
dynamic-import semantics. The Duplex-wrapped client test I added in
479c8ed used await import() for convenience; move them to static
imports alongside the other node: imports.
Two review nits on cache invalidation edge cases:

1. Bail before touching either cache when bytes.len == 0.
   us_ssl_ctx_add_ca_pem short-circuits on empty input without
   mutating the SSL_CTX, so eviction just forces an unnecessary
   SSL_CTX rebuild on the next createSecureContext(sameOptions) with
   no actual state change to drop.

2. Guard the JS-cache remove with Bun__SecureContextCache__get(key)
   == callframe.this(), mirroring SSLContextCache.invalidate's
   'if (entry.ctx == ctx)' guard. Otherwise the pattern

       const a = createSecureContext(X);  // cache[X]=A
       a.context.addCACert(Y);            // both caches invalidated
       const b = createSecureContext(X);  // cache[X]=B (fresh)
       a.context.addCACert(Z);            // native no-ops (correct); JS
                                          // evicted B (wrong)

   breaks === identity on the next createSecureContext(X) and costs
   one extra wrapper allocation. No TLS-correctness impact either
   way — the underlying SSL_CTX* is still shared via the native
   cache — but the guards bring the two paths into parity and make
   the 'idempotent on repeat calls' doc comment fully accurate.
Main merged the Rust rewrite (#30412): SecureContext, SSLContextCache,
and the SSLWrapper client-attach path are now Rust. Port the addCACert
implementation from the (now-dead) .zig files to their .rs counterparts:

- SecureContext.rs: add_ca_cert host_fn (StringOrBuffer input, empty-bytes
  and JS-cache-identity guards, dual cache invalidation), js_verify_mode
  test helper, Bun__SecureContextCache__remove extern; handle
  undefined/null in constructor() and intern() so createSecureContext()
  with no args doesn't throw 'TLSOptions must be an object'.
- SSLContextCache.rs: invalidate() — drop a mutated SSL_CTX from the
  per-VM cache, guarded on entry.ctx == ctx.
- uws/lib.rs (SSLWrapper): gate the per-SSL trust-store override on
  us_ctx_has_user_ca so addCACert'd CAs survive the Duplex/UpgradedDuplex
  TLS path, mirroring us_internal_ssl_attach.
- uws_sys/SocketContext.rs: us_ssl_ctx_add_ca_pem / us_ctx_has_user_ca
  externs.

The C helpers (us_ssl_ctx_add_ca_pem, us_ctx_has_user_ca) and the
construction-time marker-setting in us_ssl_ctx_build_raw are unchanged —
they live in openssl.c, which the Rust rewrite didn't touch. Resolved
the rebase conflict in openssl.c by keeping main's strict 'explicit ca
replaces the default trust store' (Node.js) semantics and layering the
addCACert marker on top.

All 20 addCACert tests pass; ssl-ctx-cache, node-tls-server,
node-tls-cert, and the Duplex/upgrade SSLWrapper tests stay green.
Describe all setters (addCACert + the three build_raw construction-time
branches) and both readers (us_internal_ssl_attach and Rust SSLWrapper
via us_ctx_has_user_ca), matching the unified marker semantics.
- SSLContextCache::invalidate takes an SSL_CTX* used only as a comparison
  key / forwarded by value to SSL_CTX_set_ex_data (never dereferenced),
  so annotate not_unsafe_ptr_arg_deref as a false positive.
- Move the SAFETY comment in add_ca_cert to immediately precede its
  unsafe block (a debug_assert sat between them, tripping
  undocumented_unsafe_blocks).
The us_ctx_user_ca_idx declaration and the us_ssl_ctx_add_ca_pem if(marked)
branch still described the pre-rebase 'default roots + user CAs' for every
producer. Post strict-CA-rebase the options.ca / ca_file_name branches install
user CAs only, requestCert installs default roots only, and only a prior
addCACert yields both. Reword both comments to state the real per-producer
contents; the marker's invariant (CTX store is authoritative, don't override)
is unchanged. Comment-only, no runtime effect.
- us_ctx_has_user_ca was declared `safe fn` in src/uws/lib.rs, but the C
  impl dereferences ctx via SSL_CTX_get_ex_data, so a null arg is UB. Drop
  `safe` to match both its unsafe sibling in SocketContext.rs and the
  adjacent us_ssl_socket_verify_error_from_ssl. The sole call site is already
  inside an unsafe block, so no call-site change.
- Two comments still pointed at the pre-rebase Zig SSLWrapper: openssl.c's
  us_ctx_has_user_ca doc and the Duplex-path test comment. Point both at the
  live Rust ssl_wrapper module in src/uws/lib.rs.
Comment/signature only, no runtime effect.
The comment referenced the pre-rebase Zig paths and camelCase
`isUndefinedOrNull`; the live short-circuit is Rust `is_undefined_or_null()`
in SecureContext.rs. Comment-only.
The "Called from Zig" doc on Bun__SecureContextCache__get/__remove and the
"Zig SecureContext" reference predate/ignore the #30412 rewrite; the live
callers are Rust (SecureContext::intern / add_ca_cert). Comment-only.
@robobun robobun force-pushed the farm/e15c357d/tls-add-ca-cert branch from c8f59c0 to 77d563d Compare June 7, 2026 01:40

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my prior inline findings have been addressed and the latest pass found nothing new, but this touches TLS trust-store/verify-mode handling across the C attach path, the Rust SSLWrapper, and two-level SSL_CTX caching — security-sensitive enough that it should get a human sign-off before merge.

Extended reasoning...

Overview

This PR implements SecureContext.prototype.addCACert for Node.js tls parity. It spans 15 files across five languages: a new us_ssl_ctx_add_ca_pem helper plus an us_ctx_user_ca_idx ex_data marker in packages/bun-usockets/src/crypto/openssl.c; gating of the per-socket trust-store override in both us_internal_ssl_attach (C) and the Rust ssl_wrapper module; cache-invalidation plumbing in SSLContextCache.{rs,zig} and BunSecureContextCache.{cpp,h}; the JS-facing prototype method in SecureContext.{rs,zig} / SecureContext.classes.ts; a test-only secureContextVerifyMode introspection helper; and ~380 lines of new tests in node-tls-context.test.ts. It also relaxes createSecureContext(undefined|null) to match Node's empty-dictionary semantics.

Security risks

The change directly manipulates the BoringSSL X509 trust store and the conditions under which the per-SSL client override replaces it with shared default roots. The review history surfaced three real correctness bugs in earlier revisions (CTX verify_mode flip leaking as server CertificateRequest; first addCACert on a {ca: ...}-built context discarding construction-time CAs; the parallel SSLWrapper/Duplex client-attach path not honoring the marker), all now fixed and gated by tests. The remaining design accepts a documented limitation: two JS cells obtained for the same digest before the first addCACert still share one SSL_CTX, so a mutation through one is visible through the other. That's a deliberate trade-off (no SSL_CTX_dup), but it's exactly the kind of trust-boundary nuance a maintainer should ratify.

Level of scrutiny

High. This is production TLS verification logic, not a config tweak — it changes what certificate chains are trusted and under what conditions, and it modifies construction-time behavior for all {ca} / {caFile} / {requestCert} contexts (they now set the ex_data marker). The PR went through ~18 rounds of inline review with multiple substantive fixes; that history argues for, not against, a final human pass.

Other factors

Test coverage is thorough (12 new tests including a Duplex-path regression, verify_mode gate, construction-time-CA-preservation gate, and a deterministic NODE_EXTRA_CA_CERTS subprocess fixture). All prior inline comments — mine and CodeRabbit's — are resolved. CI on the prior head was 73/74 green with one expired (not failed) macOS lane; the current head 77d563d is building. The bug-hunting pass on the latest revision found nothing. None of that changes the bar: TLS trust-store code should not be auto-approved.

@robobun

robobun commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 77d563d: the only hard failure is test/cli/install/bunx.test.ts "should handle package that requires node 24", which runs bunx --bun @angular/cli@latest --help against the live npm registry and now exits 3. It fails identically on debian-13-x64-asan, windows-2019-x64, and windows-2019-x64-baseline, so it tracks whatever @angular/cli@latest published, not this branch (this PR touches only TLS/SecureContext code and one TLS test file). The flaky-warning annotation (bun-install trustedDependencies, spawn-pipe-leak RSS) passed on retry.

I already used my one retrigger earlier, so I'm not pushing another. The TLS diff itself is green: all build lanes, cargo clippy, and the TLS test suite pass. Needs a maintainer to re-run or merge as-is.

@robobun

robobun commented Jun 17, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #31155, which merged to main and already implements both things this PR set out to fix:

  • SecureContext.prototype.addCACert is now exposed (via the C helper us_ssl_ctx_add_ca_cert and Rust SecureContext::add_ca_cert), so issue TLS Secure Context tls.createSecureContext().context.addCACert is undefined #30485 (addCACert is undefined) is resolved.
  • tls.createSecureContext() with no args no longer throws "TLSOptions must be an object"intern/constructor now default opts to undefined and fall back to SSLConfig::zero.

Rebasing this branch onto main now conflicts directly on SecureContext.classes.ts, SecureContext.rs, and openssl.c because the feature exists there through a different (simpler) implementation. Resolving in my favor would revert #31155; resolving in main's favor empties this PR. So I am not force-pushing a competing implementation.

A few Node-compat refinements from this branch are not in main's version, in case they're wanted as a follow-up:

  • Lenient input (Node parity): Node silently ignores empty / malformed / duplicate PEM; main throws "addCACert requires a certificate" / "Invalid CA certificate". The reporter's literal repro addCACert("some CA") would throw on main.
  • Trust-anchor preservation: ensure a first addCACert on a fresh context adds to (not replaces) the default roots, and preserves construction-time ca.
  • verify_mode neutrality: avoid a server CertificateRequest leak when the shared mode-neutral CTX has user CAs.
  • Cache invalidation + Duplex/SSLWrapper parity: evict the per-digest cache after mutation and honor user CAs on the tls.connect({ socket: duplex }) path.

Closing this out as a duplicate; happy to re-open a focused follow-up for any of the above if a maintainer wants it.

@robobun robobun closed this Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TLS Secure Context tls.createSecureContext().context.addCACert is undefined

1 participant