Skip to content

tls: reject certificate chains containing RSA keys below 2048 bits - #38591

Open
robobun wants to merge 3 commits into
mainfrom
farm/2b924a23/tls-min-key-size
Open

tls: reject certificate chains containing RSA keys below 2048 bits#38591
robobun wants to merge 3 commits into
mainfrom
farm/2b924a23/tls-min-key-size

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Bun accepts TLS peer chains containing weak public keys. A chain root (RSA-2048, trusted via ca) -> intermediate CA with an RSA-512 key -> leaf is authorized on every client path (tls.connect reports authorized: true, https/fetch succeed, Bun.connect handshakes, wss:// opens), and so are an RSA-1024 leaf and an RSA-1024 trust anchor. An RSA-512 key can be factored in hours, after which any name can be minted under that CA and only Bun accepts it.
  • Node v26.3.0 rejects all three: authorized: false, authorizationError: "UNSPECIFIED", and with rejectUnauthorized the error is { code: "UNSPECIFIED", message: "CA certificate key too weak" } (or "EE certificate key too weak" for the leaf). That is OpenSSL's default security level 2 (112-bit: RSA-2048 / P-224), which OpenSSL applies to the leaf and to every issuer up to and including the trust anchor (crypto/x509/x509_vfy.c, check_auth_level / check_key_level).
  • Cause: BoringSSL's verifier has no security levels or key-size floor at all (its evp.h tells callers to bound RSA sizes themselves with EVP_PKEY_bits), and uSockets hands chain verification straight to it (packages/bun-usockets/src/crypto/openssl.c, the SSL_CTX_set_verify / us_verify_callback setup). Nothing on Bun's side looked at key sizes.

Fix

  • us_internal_x509_verify_cert (openssl.c) wraps X509_verify_cert: when the chain verified cleanly it walks the built chain and fails any certificate whose RSA key is below 2048 bits or whose EC key is below 224 bits, with two uSockets-defined results, US_X509_V_ERR_EE_KEY_TOO_SMALL (chain[0]) and US_X509_V_ERR_CA_KEY_TOO_SMALL (any issuer, trust anchor included). BoringSSL's own X509_V_ERR_* numbers end at 67 and 66/67 already mean something else there, so OpenSSL's numbers could not be reused.
  • Every SSL_CTX built by us_ssl_ctx_build_raw installs it through SSL_CTX_set_cert_verify_callback, so it covers all TCP clients and servers (server certs and requested client certs alike, including the duplex / named-pipe TLS wrappers, which build their contexts there too). The QUIC client's custom verify (quic.c), which calls the verifier by hand, calls the same function.
  • A policy failure is delivered the way BoringSSL delivers its own: the error is set on the X509_STORE_CTX and the connection's verify callback is invoked with ok = 0. That is what keeps the existing flows unchanged: us_verify_callback lets the handshake finish and JS reads the verdict (authorized / authorizationError / rejectUnauthorized), the inline-reject callback from tls: close_notify on end(), injected-socket upgrades, reject-handshake wire fix, duplex data-loss, SNI, ALPN (+14 tests, tls 81%→86%) #34598 records it and suppresses the client's final flight, and a context with no callback (the QUIC client's bare store ctx) simply fails. An error an earlier callback already waved through is left as the verdict.
  • Reporting: us_ssl_socket_verify_error_from_ssl supplies OpenSSL's reason strings for the two codes; the code string falls through us_X509_error_code's default, UNSPECIFIED, which is also what node reports (node's table has no names for these either). fetch, which names every X509 result, maps them to CA_KEY_TOO_SMALL / EE_KEY_TOO_SMALL (src/http/lib.rs, error.rs, FetchTasklet.rs). One sentence added to the fetch docs.
  • Fixtures: test/js/node/tls/fixtures/{ca2,agent3,agent10}-cert.pem were the pre-2024 node fixtures, anchored at a 1024-bit ca2, so dozens of existing tests in test/js/node/tls started failing with "CA certificate key too weak", exactly as they would on current node. They are replaced with node's current versions of the same three files (same private keys; byte-identical copies of what is already vendored under test/js/node/test/fixtures/keys). The only other sub-2048 certificate under test/ is node's agent11 fixture, which tests only load into a server and never verify.
  • Behavior change to be aware of: chains containing RSA-1024 certificates now fail, as they do on node 25+ and on OpenSSL 3.2+ / Debian and Ubuntu OpenSSL builds since 2019. The threshold is the US_MIN_RSA_KEY_BITS constant; a browser-style 1024 floor (which would still close the RSA-512/768 hole) is a one-constant change if preferred.
  • Verified by:
    • test/js/node/tls/node-tls-cert.test.ts ("certificate chains containing a weak public key"): for an RSA-512 intermediate, an RSA-1024 leaf and an RSA-1024 anchor, rejectUnauthorized: false reports UNSPECIFIED, the default aborts tls.connect with node's code and message, and a server with requestCert rejects the same chains as client certificates; two all-RSA-2048 controls stay authorized. 9 of 11 cases fail on the unfixed build.
    • test/js/node/tls/fetch-tls-cert.test.ts: the same three server chains reject with CA_KEY_TOO_SMALL / EE_KEY_TOO_SMALL and the control still fetches; 3 of 4 fail on the unfixed build.
    • bun bd test test/js/node/tls/ (after the fixture refresh): everything passes except two tests that fail identically on the released binary in this container (localhost resolving to ::1 for listen but 127.0.0.1 for connect, and a root-store Worker test that needs more than 5 s on a debug+ASAN build).
    • Bun.connect probed by hand against the same fixtures: authorized / UNSPECIFIED: CA certificate key too weak / EE certificate key too weak, handshake failure when rejecting; the released binary authorizes all three.
    • Vendored node tests test-tls-client-verify, test-tls-client-reject, test-tls-ca-concat, test-tls-peer-certificate, test-https-strict, test-tls-multiple-cas-as-string, test-tls-add-context, test-tls-sni-option pass on the debug build (node's current fixtures are all 2048-bit or P-256+, so they double as a regression net for the threshold).
  • Related: tls: reject SHA-1-signed certificates during client chain verification #35184 (rejecting SHA-1 signatures) wraps X509_verify_cert the same way; its digest check slots into us_x509_chain_policy_error once either lands. tls: make handshake success match socket.authorized; extend X509 error-code table #35186 proposes naming every X509 code on the node:tls paths; if that direction is taken, these two codes should get names there too. node:quic builds its own SSL_CTX in Rust and does not go through uSockets, so it is not covered here.

Background

  • Chain verification: when a TLS peer presents its certificate(s), BoringSSL builds a chain from the peer's leaf through any intermediates up to a certificate in the trust store (the trust anchor: the ca option or the bundled roots) and checks signatures, validity, names, and so on. X509_verify_cert is that routine; X509_STORE_CTX is its per-verification state, and X509_STORE_CTX_get0_chain returns the chain it built, leaf first.
  • Security level: OpenSSL's name for a minimum strength applied uniformly to keys and signatures; level 2 means 112 bits of security, which RSA reaches at 2048 bits and EC at a 224-bit curve. Node does not configure one and so gets OpenSSL's default, which has been 2 since OpenSSL 3.2. BoringSSL, which Bun uses, does not implement the concept.
  • Verify callback (SSL_set_verify): a per-connection hook BoringSSL calls for each problem it finds during verification; returning 1 continues anyway. Bun always returns 1 and reads the final verdict afterwards with SSL_get_verify_result, so that JS-level rejectUnauthorized can decide. The "inline reject" variant (tls: close_notify on end(), injected-socket upgrades, reject-handshake wire fix, duplex data-loss, SNI, ALPN (+14 tests, tls 81%→86%) #34598) additionally notes the failure so a rejecting client never sends its final handshake flight. The error set last on the store ctx is what SSL_get_verify_result returns, which is why this change sets its error there and then calls the callback.
  • SSL_CTX_set_cert_verify_callback: a BoringSSL hook that replaces the X509_verify_cert call itself for every connection on a context (distinct from the per-problem verify callback above, which BoringSSL still passes along). It is how the policy runs once, in one place, for all connections.
Repro against the released binary (1.4.0-canary) vs node v26.3.0

Bun server serving each chain (node servers refuse to load them); client probes with ca set to the chain's root.

=== chain: ca512 (RSA-512 intermediate) ===
node rejectUnauthorized=false: {"connected":true,"authorized":false,"authorizationError":"UNSPECIFIED"}
node rejectUnauthorized=true:  {"connected":false,"code":"UNSPECIFIED","message":"CA certificate key too weak"}
bun  rejectUnauthorized=false: {"connected":true,"authorized":true,"authorizationError":null}
bun  rejectUnauthorized=true:  {"connected":true,"authorized":true,"authorizationError":null}
=== chain: ee1024 (RSA-1024 leaf) ===
node rejectUnauthorized=true:  {"connected":false,"code":"UNSPECIFIED","message":"EE certificate key too weak"}
bun  rejectUnauthorized=true:  {"connected":true,"authorized":true,"authorizationError":null}
=== chain: root1024 (RSA-1024 trust anchor) ===
node rejectUnauthorized=true:  {"connected":false,"code":"UNSPECIFIED","message":"CA certificate key too weak"}
bun  rejectUnauthorized=true:  {"connected":true,"authorized":true,"authorizationError":null}

With this change, Bun's output for all three is identical to node's, and fetch rejects with CA_KEY_TOO_SMALL / EE_KEY_TOO_SMALL carrying the same messages. openssl verify -auth_level 2 gives the same three verdicts (errors 67, 66, 67) and accepts the control chain.


[review] gate passed · iteration 4 · 21 files touched

fails on main (without fix)
ASAN without fix: 12 failed, 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/fetch-tls-cert.test.ts test/js/node/tls/node-tls-cert.test.ts
bun test v1.4.0 (bd4f8f0b0)

test/js/node/tls/fetch-tls-cert.test.ts:
(pass) complete cert chains sent to peer. [115.86ms]
(pass) rejects a client cert the server's CA cannot verify, every time [243.66ms]
(todo) complete cert chains sent to peer, but without requesting client's cert.
(todo) Request cert from TLS1.2 client that doesn't have one.
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. [31.75ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM [30.90ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM in an array [33.44ms]
(pass) Fail to complete server's chain [30.05ms]
(pass) Fail to complete client's chain. [28.64ms]
(pass) Fail to find CA for server
... (truncated)

release without fix: 12 failed, 6 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/tls/fetch-tls-cert.test.ts:
(pass) complete cert chains sent to peer. [12.00ms]
(pass) rejects a client cert the server's CA cannot verify, every time [36.04ms]
(todo) complete cert chains sent to peer, but without requesting client's cert.
(todo) Request cert from TLS1.2 client that doesn't have one.
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. [2.56ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM [2.52ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM in an array [2.28ms]
(pass) Fail to complete server's chain [1.93ms]
(pass) Fail to complete client's chain. [2.00ms]
(pass) Fail to find CA for server. [1.82ms]
(pass) Server sent their CA, but CA cannot be trusted if it is not locally known. [1.76ms]
(pass) Server sent their CA, wrongly, but its OK since we know the CA locally. [1.88ms]
(pass) certificate c
... (truncated)
passes on PR (with fix)
ASAN with fix: 6 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/tls/fetch-tls-cert.test.ts test/js/node/tls/node-tls-cert.test.ts
bun test v1.4.0 (bd4f8f0b0)

test/js/node/tls/fetch-tls-cert.test.ts:
(pass) complete cert chains sent to peer. [125.62ms]
(pass) rejects a client cert the server's CA cannot verify, every time [219.21ms]
(todo) complete cert chains sent to peer, but without requesting client's cert.
(todo) Request cert from TLS1.2 client that doesn't have one.
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. [28.28ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM [29.65ms]
(pass) Typical configuration error, incomplete cert chains sent, we have to know the peer's subordinate CAs in order to verify the peer. But using multi-PEM in an array [28.02ms]
(pass) Fail to complete server's chain [27.63ms]
(pass) Fail to complete client's chain. [26.23ms]
(pass) Fail to find CA for server
... (truncated)

release with fix: 6 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     bd4f8f0b0d
  features     baseline

22 deps, 123 codegen, 1176 objects in 744ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [11.00ms]
[2/1238] gen bindgenv2
[3/1238] gen ErrorCode+*.h
[4/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [1.00ms]
[5/1238] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [13.00ms]
[6/1238] fetch tinycc
[tinycc] up to date
[7/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[8/1237] fetch zlib
[zlib] up to date
[9/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[10/1237] gen .bind.ts → GeneratedBindings.cpp
... (truncated)
diff hotspot
docs/runtime/networking/fetch.mdx                  |  2 +
 packages/bun-usockets/src/crypto/openssl.c         | 75 +++++++++++++++-
 packages/bun-usockets/src/internal/internal.h      |  5 ++
 packages/bun-usockets/src/libusockets.h            |  8 ++
 packages/bun-usockets/src/quic.c                   |  6 +-
 src/http/error.rs                                  |  4 +
 src/http/lib.rs                                    |  2 +
 src/runtime/webcore/fetch/FetchTasklet.rs          |  6 ++
 test/js/node/tls/fetch-tls-cert.test.ts            | 41 ++++++++-
 test/js/node/tls/fixtures/agent10-cert.pem         | 80 +++++++++--------
 test/js/node/tls/fixtures/agent3-cert.pem          | 35 ++++----
 test/js/node/tls/fixtures/ca2-cert.pem             | 33 +++++---
 test/js/node/tls/fixtures/weak-key-ca512-chain.pem | 31 +++++++
 test/js/node/tls/fixtures/weak-key-ee1024-cert.pem | 17 ++++
 test/js/node/tls/fixtures/weak-key-ee1024-key.pem  | 16 ++++
 test/js/node/tls/fixtures/weak-key-leaf-key.pem    | 28 ++++++
 test/js/node/tls/fixtures/weak-key-ok-chain.pem    | 41 +++++++++
 test/js/node/tls/fixtures/weak-key-root-cert.pem   | 20 +++++
 .../node/tls/fixtures/weak-key-root1024-cert.pem   | 14 +++
 .../tls/fixtures/weak-key-under-root1024-cert.pem  | 18 ++++
 test/js/node/tls/node-tls-cert.test.ts             | 99 ++++++++++++++++++++++
 21 files changed, 509 insertions(+), 72 deletions(-)

gate history · 2 passed · 0 rejected · iteration 4

evidence per changed file
file                                                reads  edits  tests
docs/runtime/networking/fetch.mdx                       1      2      0
packages/bun-usockets/src/crypto/openssl.c              4      6      0
packages/bun-usockets/src/internal/internal.h           1      1      0
packages/bun-usockets/src/libusockets.h                 1      1      0
packages/bun-usockets/src/quic.c                        1      2      0
src/http/error.rs                                       1      1      0
src/http/lib.rs                                         3      5      0
src/runtime/webcore/fetch/FetchTasklet.rs               1      1      0
test/js/node/tls/fetch-tls-cert.test.ts                 1      2      0
test/js/node/tls/fixtures/agent10-cert.pem              0      0      0
test/js/node/tls/fixtures/agent3-cert.pem               0      0      0
test/js/node/tls/fixtures/ca2-cert.pem                  0      0      0
test/js/node/tls/fixtures/weak-key-ca512-chain.pem      0      0      0
test/js/node/tls/fixtures/weak-key-ee1024-cert.pem      0      0      0
test/js/node/tls/fixtures/weak-key-ee1024-key.pem       0      0      0
test/js/node/tls/fixtures/weak-key-leaf-key.pem         0      0      0
(+ 5 more files)

root cause · written by the author bot

Bun's TLS chain verification used BoringSSL's default verifier, which unlike Node's OpenSSL security level applies no minimum public-key size, so a chain containing an RSA-512 or similarly weak key as the trust anchor, an intermediate, or the leaf was accepted on every client path. The fix installs a cert-verify wrapper on every context that, after X509_verify_cert succeeds, walks the verified chain and rejects RSA keys under 2048 bits or EC keys under 224 bits with two new error codes that flow through the existing verify callback and rejectUnauthorized handling, with the QUIC client route…

BoringSSL's X.509 verifier has no minimum public key size, so a chain
built through an RSA-512 intermediate, an RSA-1024 leaf or an RSA-1024
trust anchor verified cleanly on every Bun client and server path.
Node (OpenSSL at its default security level 2) rejects all of them.

uSockets now verifies every peer chain through
us_internal_x509_verify_cert, installed on each SSL_CTX with
SSL_CTX_set_cert_verify_callback and shared with the QUIC client's
custom verify. After a clean X509_verify_cert it walks the built chain
and fails any certificate whose RSA key is below 2048 bits or whose EC
key is below 224 bits, reporting the failure through the connection's
verify callback exactly like one of BoringSSL's own errors, so the
rejectUnauthorized / inline-reject / authorizationError flows are
unchanged. node:tls and Bun.connect report code UNSPECIFIED with
OpenSSL's reason strings, matching node; fetch reports
CA_KEY_TOO_SMALL / EE_KEY_TOO_SMALL.

test/js/node/tls/fixtures/{ca2,agent3,agent10}-cert.pem were the
pre-2024 node fixtures anchored at a 1024-bit ca2; they are replaced
with node's current versions (same keys), already vendored under
test/js/node/test/fixtures/keys.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7c16731a-715a-4261-9687-a30c9a90954c

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and bd4f8f0.

⛔ Files ignored due to path filters (11)
  • test/js/node/tls/fixtures/agent10-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/agent3-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/ca2-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-ca512-chain.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-ee1024-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-ee1024-key.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-leaf-key.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-ok-chain.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-root-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-root1024-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/weak-key-under-root1024-cert.pem is excluded by !**/*.pem
📒 Files selected for processing (10)
  • docs/runtime/networking/fetch.mdx
  • packages/bun-usockets/src/crypto/openssl.c
  • packages/bun-usockets/src/internal/internal.h
  • packages/bun-usockets/src/libusockets.h
  • packages/bun-usockets/src/quic.c
  • src/http/error.rs
  • src/http/lib.rs
  • src/runtime/webcore/fetch/FetchTasklet.rs
  • test/js/node/tls/fetch-tls-cert.test.ts
  • test/js/node/tls/node-tls-cert.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fixed; the diff is green on every lane that ran it, and CI is blocked on infrastructure rather than on this change.

Reproduced against the released binary (1.4.0-canary) with a Bun server presenting each of the three fixture chains (RSA-512 intermediate, RSA-1024 leaf, RSA-1024 trust anchor): tls.connect reported authorized: true for all three, while node v26.3.0 reported authorized: false / UNSPECIFIED and, with rejectUnauthorized, CA certificate key too weak / EE certificate key too weak. The new cases in test/js/node/tls/node-tls-cert.test.ts (9 of 11) and test/js/node/tls/fetch-tls-cert.test.ts (3 of 4) fail on the unfixed build and pass with this change; the rest of test/js/node/tls passes after the ca2 fixture refresh described in the PR body.

CI: build 96249 (first revision) passed 177 of 177 executed lanes; its macOS 14 arm64 test job expired in the agent queue. Build 96678 (current revision, comment-only delta) passed 161 lanes; the Windows arm64 and Windows 2019 x64 test jobs were cancelled because agents could not be created (leaked arm64 runner VMs have the quota pinned, reported separately), and one Debian x64 ASAN agent timed out three unrelated tests (fs.test.ts readdir x100, inspect-error-leak, html-rewriter-leak), none of which open a TLS connection. Retrying the cancelled and failed jobs once the runner fleet recovers is all that is left; the remaining decision for a maintainer is the 2048 vs 1024 floor discussed above.

@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.

I reviewed this PR and didn't find any bugs. Because it changes the TLS certificate-verification path for every connection (installing SSL_CTX_set_cert_verify_callback on every context) and tightens a user-facing security policy — RSA-1024 chains that Bun previously accepted will now fail — a maintainer should confirm the 2048-bit floor (vs. the browser-style 1024 floor the description offers as an alternative) and the overall approach.

What was reviewed:

  • us_internal_x509_verify_cert wrapping and its interaction with us_verify_callback / the inline-reject callback — the policy error is set on the store ctx and the per-connection callback still decides whether the handshake continues.
  • Error-code plumbing: 1001/1002 fall through us_X509_error_code to UNSPECIFIED (matching Node) and are named on the fetch path.
  • DSA/Ed25519 handling in us_x509_key_too_small — the default-accept for non-RSA/EC key types was examined and ruled out as a gap.
  • Fixture refresh (ca2/agent3/agent10) keeps the same subjects and private keys, so existing tests that load them still verify.
Extended reasoning...

Overview

This PR closes a real security gap: BoringSSL's X.509 verifier has no key-size floor, so Bun accepted TLS chains built through RSA-512/1024 keys that Node (via OpenSSL security level 2) rejects. The fix wraps X509_verify_cert with a post-verification chain walk that rejects RSA < 2048 and EC < 224, installed on every SSL_CTX via SSL_CTX_set_cert_verify_callback and called directly from the QUIC client's custom verify. Error reporting matches Node's UNSPECIFIED code with OpenSSL's reason strings on node:tls, and adds named EE_KEY_TOO_SMALL/CA_KEY_TOO_SMALL codes on the fetch path. Three existing 1024-bit test fixtures are refreshed to Node's current 2048-bit versions, and eight new fixtures plus 15 tests cover weak intermediates, leaves, and trust anchors as both server and client certificates.

Security risks

This is a security hardening that fails closed — the change only adds rejection cases on top of a chain that already verified. The implementation only runs the policy check after X509_verify_cert returns success and the store ctx error is still X509_V_OK, so it cannot mask a real verification failure or turn one into a success. The verify-callback dispatch mirrors how BoringSSL surfaces its own errors, preserving the existing rejectUnauthorized / authorized / inline-reject flows. The DSA-falls-through concern was examined: DSA is not in BoringSSL's default sigalgs and is effectively unusable in TLS 1.2+, so accepting it in us_x509_key_too_small is not a practical bypass. No new attack surface is introduced.

Level of scrutiny

High. This installs a cert_verify_callback that replaces BoringSSL's default verification entry point on every SSL_CTX built by uSockets, affecting tls.connect, https, fetch, Bun.connect, Bun.serve, WebSocket, and the duplex/named-pipe TLS wrappers. It also changes user-visible behavior: RSA-1024 certificates that previously worked will now be rejected. The PR description explicitly flags the 2048 vs. 1024 threshold as a maintainer decision. Security-policy changes of this scope should be signed off by a human.

Other factors

The implementation is careful and well-documented, test coverage is thorough (positive and negative controls, both client- and server-side chains, both tls.connect and fetch, fixture-generation recipe recorded), and the PR description enumerates every path it covers and the one it does not (node:quic's Rust-built contexts). No bugs were found in the automated review. The fixture refresh is a mechanical swap to Node's current copies of the same three files. Nonetheless, the combination of security-critical C code, a breaking behavior change, and an open policy question makes this unsuitable for auto-approval.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

No code changes from the automated review; the one thing it asks a maintainer to confirm is the threshold, which is deliberately left as a single decision point:

  • US_MIN_RSA_KEY_BITS / US_MIN_EC_KEY_BITS in packages/bun-usockets/src/crypto/openssl.c are the only place the policy lives. As pushed they are 2048 / 224, which is what node 26 (OpenSSL default security level 2) enforces.
  • If a 1024 / 160 floor (what browsers enforce) is preferred instead, that is the constant change plus regenerating the two 1024-bit fixtures (weak-key-ee1024-*, weak-key-root1024-* / weak-key-under-root1024-cert.pem) at 512 or 768 bits, since the tests pin whichever floor is chosen; the RSA-512 intermediate case and the ca2 fixture refresh are unaffected either way.

Comment thread src/http/lib.rs 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.

I reviewed this PR and didn't find any bugs. Because it modifies the core TLS certificate-verification path (installing an SSL_CTX_set_cert_verify_callback on every context) and introduces a user-visible behavior change whose threshold the author explicitly flagged for maintainer confirmation, a human look is warranted before merge.

What was reviewed:

  • us_internal_x509_verify_cert — the wrapper only tightens after X509_verify_cert succeeds and preserves an error a verify callback already waved through, so it fails closed and doesn't loosen anything.
  • Error-code plumbing (1001/1002) — checked they don't collide with BoringSSL's X509_V_ERR_* range and are mapped consistently across us_X509_error_reason, get_cert_error_from_no, and FetchTasklet.
  • Fixture refresh — the replaced ca2/agent3/agent10 certs keep the same subjects/keys and the existing tests in the two touched files still pass.
  • The comment-cop flag on src/http/lib.rs:1517 is on a pre-existing doc comment that this PR only extended by a few words; it does not describe a workaround.
Extended reasoning...

Overview

This PR hardens TLS peer-chain verification by rejecting certificates with RSA keys < 2048 bits or EC keys < 224 bits, matching Node's OpenSSL security-level-2 default. It touches packages/bun-usockets/src/crypto/openssl.c (the new us_internal_x509_verify_cert wrapper and SSL_CTX_set_cert_verify_callback installation), quic.c (routes the QUIC client's manual verify through the same function), libusockets.h / internal.h (two new US_X509_V_ERR_* codes and the internal prototype), and Rust-side error mapping in src/http/{lib,error}.rs and FetchTasklet.rs. It also refreshes three vendored Node fixtures from 1024-bit to their current 2048-bit versions, adds eight new weak-key fixtures, adds test suites in node-tls-cert.test.ts and fetch-tls-cert.test.ts, and adds one sentence to the fetch docs.

Security risks

The change is security-hardening, and the implementation only adds checks after X509_verify_cert has already returned success with X509_V_OK, so it cannot make an invalid chain valid. However, it replaces the cert-verify entry point on every SSL_CTX Bun builds, so any subtle mistake in how the verify callback is threaded (e.g. SSL_get_verify_callback retrieval, error propagation to SSL_get_verify_result) would affect all TLS connections. I traced the flow and it looks correct: the policy error is set on the store ctx, then delivered through the connection's own verify callback exactly as BoringSSL delivers native X509_V_ERR_* values, so us_verify_callback / the inline-reject callback / rejectUnauthorized semantics are preserved. The new error numbers (1001/1002) are outside BoringSSL's 0–67 range so X509_verify_cert_error_string and us_X509_error_code fall through to their defaults as intended.

Level of scrutiny

High. This is the TLS trust path — the guidelines explicitly exclude crypto/auth code from auto-approval. Beyond that, the PR itself surfaces a policy decision for a maintainer: whether the floor should be 2048/224 (Node-compat, as pushed) or 1024/160 (browser-compat). That is a product-level call about how much real-world breakage (RSA-1024 chains still exist in some private/enterprise PKIs) is acceptable, and the author left it as a one-constant knob precisely so a maintainer can weigh in.

Other factors

  • The github-actions comment-cop flagged src/http/lib.rs:1517; that flag is on the pre-existing three-line doc comment above get_cert_error_from_no, which this PR only minimally reworded. It's not a paragraph justifying a workaround, so I read it as a false positive rather than something requiring a code change.
  • Test coverage is thorough (three weak-chain shapes × three delivery modes + two positive controls, plus fetch coverage), and the gate evidence shows the new tests fail on main and pass on the PR under both ASAN and release.
  • Because the fixture refresh touches ca2/agent3/agent10, other TLS tests across the tree that consume those fixtures are indirectly affected; the PR body reports test/js/node/tls/ passes, but that's another reason for a human/CI check.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed bd4f8f0: the comment block above get_cert_error_from_no is back to exactly what main has, and the two new arms carry the pointer to US_X509_V_ERR_{EE,CA}_KEY_TOO_SMALL in libusockets.h instead. No functional change; node-tls-cert.test.ts and fetch-tls-cert.test.ts still pass on the rebuilt debug binary (57 pass, 6 pre-existing todos).

The second automated review also found nothing to change; the only open item remains the 2048 vs 1024 floor noted above. The earlier CI build (96249) had every executed lane green, with only the macOS 14 arm64 test lane expiring in the agent queue, so the retrigger push is there to get that lane a run.

@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.

I reviewed this PR and the automated bug hunt found no issues. Because it rewires the TLS certificate-verification path (installing SSL_CTX_set_cert_verify_callback on every uSockets context) and introduces a user-visible behavior change whose threshold the PR itself flags for maintainer sign-off (2048-bit RSA / 224-bit EC vs. a browser-style 1024 floor), a human review is still warranted.

What was reviewed:

  • us_internal_x509_verify_cert: only runs the policy after X509_verify_cert succeeds and the store-ctx error is still X509_V_OK, so it can't mask a real BoringSSL failure or override an error a verify callback already waved through; sets the error on the store ctx and re-invokes the connection's verify callback so rejectUnauthorized / inline-reject semantics are preserved.
  • Error-code plumbing: the new US_X509_V_ERR_* values (1001/1002) are outside BoringSSL's 0–67 range and are mirrored in get_cert_error_from_no; us_X509_error_code falls through to UNSPECIFIED (matching Node), and us_X509_error_reason supplies OpenSSL's wording.
  • DSA keys falling through us_x509_key_too_small was raised and ruled out — BoringSSL's verifier already rejects DSA-keyed certificates before this policy runs.
  • Fixture refresh: the replaced ca2/agent3/agent10 PEMs keep the same subjects and private keys (only the CA key size changed), so existing tests that reference them by CN/key are unaffected.
Extended reasoning...

Overview

This PR closes a Node-compat security gap: BoringSSL has no OpenSSL-style "security level", so Bun accepted TLS chains built through arbitrarily weak RSA/EC keys (including factorable RSA-512 intermediates). The fix wraps X509_verify_cert in us_internal_x509_verify_cert, which walks the verified chain and rejects any certificate with an RSA key < 2048 bits or EC key < 224 bits. The wrapper is installed on every SSL_CTX built by us_ssl_ctx_build_raw via SSL_CTX_set_cert_verify_callback, and the QUIC client's hand-rolled verify is switched to call the same wrapper. Two new verify-result codes are plumbed through libusockets.h, src/http/{lib,error}.rs, and FetchTasklet.rs; us_X509_error_reason supplies OpenSSL's reason strings so node:tls messages match Node. Three pre-2024 Node fixtures (ca2/agent3/agent10) are refreshed to their 2048-bit versions, eight new weak-key fixtures are added, and 15 new test cases cover tls.connect (both rejectUnauthorized states), server-side client-cert verification, and fetch.

Security risks

This is squarely security-sensitive code — it replaces the certificate-verification entry point for every TLS connection Bun makes or accepts. The direction is a strict tightening (fail closed), and the wrapper is careful to only add restrictions after BoringSSL's own verify succeeded with X509_V_OK, so it cannot accidentally accept a chain BoringSSL would have rejected. The policy failure is delivered by setting the store-ctx error and calling the connection's existing verify callback with ok=0, which preserves the us_verify_callback / inline-reject / SSL_get_verify_result flow. I didn't find a bypass, but any change at this layer deserves a maintainer's eyes.

Level of scrutiny

High. Beyond the security-critical path, this is an intentional user-visible behavior change: RSA-1024 chains that Bun currently accepts will start failing. The PR author explicitly leaves the 2048/224 threshold as a maintainer decision (vs. a browser-style 1024 floor), and the robobun follow-up comment reiterates that a maintainer should confirm it. That's a product/compat call an automated review shouldn't make.

Other factors

Test coverage is thorough (control chains, three weak-key positions, both client and server verification, both tls.connect and fetch) and the gate evidence shows 12 failures on the unfixed build vs. clean on the fix. The comment-cop bot flagged src/http/lib.rs, but the actual diff there is two one-line trailing comments pointing at the constant definitions (added in commit bd4f8f0), not a paragraph-length justification — that flag looks like noise. The one candidate the finder agents raised (DSA keys not checked) was correctly ruled out: BoringSSL's chain builder rejects DSA-keyed certificates outright, so the policy check never sees one.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

For the record on the DSA question the latest automated review raised and dismissed, the exact reason it cannot reach this check: BoringSSL still parses DSA SubjectPublicKeyInfos, but its EVP DSA method has no signing or verification implementation (vendor/boringssl/crypto/evp/p_dsa.cc:251, pkey_method = nullptr), so a certificate signed by a DSA issuer fails signature verification inside X509_verify_cert, and a DSA leaf cannot complete a TLS handshake in the first place. The same applies to EVP_PKEY_RSA_PSS keys, which BoringSSL does not produce from certificates by default; the arm is there so the floor holds if that ever changes. Nothing else in that review calls for a change.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:04 PM PT - Aug 14th, 2026

@robobun, your commit bd4f8f0 has 4 failures in Build #96678 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38591

That installs a local version of the PR into your bun-38591 executable, so you can run:

bun-38591 --bun

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.

2 participants