Skip to content

tls: prefer time-valid issuer candidates when building certificate chains - #33094

Closed
robobun wants to merge 5 commits into
mainfrom
farm/868461e3/tls-prefer-time-valid-issuer
Closed

robobun wants to merge 5 commits into
mainfrom
farm/868461e3/tls-prefer-time-valid-issuer

Conversation

@robobun

@robobun robobun commented Jun 29, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

TLS verification in Bun rejects connections with CERT_HAS_EXPIRED when an expired copy of an issuer certificate is present anywhere in the candidate pool, even though a time-valid copy that completes a valid chain is right next to it. Node (OpenSSL) accepts these chains. The behavior is identical across fetch, node:tls, and node:https, and it is order dependent, so it presents as "works on one machine, fails on another".

Two lookups in BoringSSL's legacy X.509 verifier return the first subject/AKID match and never consider validity dates:

  • find_issuer() in crypto/x509/x509_vfy.cc, over the peer-supplied chain. Servers send both a current and a deprecated intermediate during CA transitions, and RFC 8446 (section 4.4.2) tells clients to tolerate extraneous certificates and arbitrary orderings. If the expired copy comes first in the Certificate message, the chain gets built through it and verification fails.
  • X509_STORE_CTX_get1_issuer() in crypto/x509/x509_lu.cc, over the trust store. An expired CA sitting next to its reissued replacement (same subject, same key) in the ca option, NODE_EXTRA_CA_CERTS, or the system store wins or loses the lookup depending on the store's internal sort order. Because trusted-first lookup consults the store before the peer chain, this also rejects servers that present a perfectly valid chain.

OpenSSL has preferred a time-valid candidate in both of these lookups since 1.0.2, which is exactly the compatibility gap. BoringSSL's own test suite describes its TRUSTED_FIRST/alt-chains handling as "workarounds for not having an actual path-building verifier" (crypto/x509/x509_test.cc), and Chrome does not rely on this code path at all (it verifies with the path builder in BoringSSL's pki/), so the gap never surfaces in Google's products.

Reproduction

Create a root, one intermediate key with two certificates (same subject and key, one valid, one expired), and a localhost leaf signed by that key. Serve with node:https and connect with each runtime.

scenario                         server presents              client ca pool            node 26   bun (all versions)
sent_expired_first               [leaf, int_EXP, int_ok]      [root]                    OK        CERT_HAS_EXPIRED
sent_valid_first                 [leaf, int_ok, int_EXP]      [root]                    OK        OK
pool_both_expired_first          [leaf]                       [root, int_EXP, int_ok]   OK        CERT_HAS_EXPIRED
pool_both_valid_first            [leaf]                       [root, int_ok, int_EXP]   OK        OK
pool_poisons_valid_server        [leaf, int_ok]               [root, int_EXP, int_ok]   OK        CERT_HAS_EXPIRED
expired_only_no_valid_path       [leaf, int_EXP]              [root]                    rejected  rejected (correct)

Results are identical for bun 1.2.18, 1.3.14, and current main, via fetch(url, { tls: { ca } }), tls.connect({ ca }), and https.get({ ca }).

The fix

patches/boringssl/prefer-time-valid-issuer.patch, applied to the vendored BoringSSL at fetch time like the other patches/* entries: both lookups now prefer a candidate that is valid at the verification time and only fall back to the first (expired) match when no time-valid candidate exists. Chains whose only path runs through an expired certificate still fail with CERT_HAS_EXPIRED. This mirrors OpenSSL's find_issuer and X509_STORE_CTX_get1_issuer.

If it is preferable to carry this in the oven-sh/boringssl fork rather than as a patch file, the same two hunks apply there. The remaining gap that this PR does not close (neither does Node): when the only trusted candidate is expired and the peer supplied a valid intermediate, a real path builder (BoringSSL pki/) is needed to recover.

Tests

test/js/node/tls/fixtures/altchain-*: root, the two same-key intermediates (valid to 2053, expired in 2024), a localhost leaf, and altchain-gen.sh to regenerate them. New tests in test/js/node/tls/node-tls-cert.test.ts (node:tls) and test/js/node/tls/fetch-tls-cert.test.ts (fetch):

  • peer sends expired and valid intermediates in either order: verifies
  • trust store contains expired and valid copies in either order: verifies
  • peer presents a valid chain while the store also holds an expired copy: verifies
  • peer sends only the expired intermediate but the store holds the valid one: verifies (already worked, guards the trusted-first path)
  • only an expired path exists: still rejected with CERT_HAS_EXPIRED

Without the patch, 5 of the 8 new tests fail with CERT_HAS_EXPIRED; with it, all pass. The rest of test/js/node/tls/ plus test/js/web/fetch/fetch.tls*.test.ts is unchanged: the only other failures on this machine (fetch timeout works on tls, Bun.connect TLS hostname verification > reports authorized=false ...) reproduce identically on the unmodified release binary, and two more are 5 second budget subprocess tests that only time out under a debug build on a slow container.

Note for reviewers: the runtime change lives in patches/ + scripts/build/deps/boringssl.ts, not src/, so the unfixed baseline for the new tests is USE_SYSTEM_BUN=1 bun test test/js/node/tls/node-tls-cert.test.ts (or a build with the patches: line removed).

…ains

When several certificates match an issuer lookup (same subject and key,
different validity windows), BoringSSL's legacy X.509 path builder commits
to whichever candidate it finds first and fails the chain with
CERT_HAS_EXPIRED if that copy is expired, even though a chain through the
time-valid copy was buildable. OpenSSL has preferred time-valid candidates
in both lookups since 1.0.2, so Node accepts these chains and Bun did not.

Two lookups are affected:
- find_issuer() over the peer-supplied chain: servers send both a current
  and a deprecated intermediate during CA transitions (RFC 8446, section
  4.4.2 says to tolerate this), and the expired one can come first.
- X509_STORE_CTX_get1_issuer() over the trust store: an expired CA next to
  its reissued replacement in the ca option, NODE_EXTRA_CA_CERTS, or the
  system store poisons the chain depending on store sort order, including
  for servers that present a perfectly valid chain.

Patch the vendored BoringSSL (patches/boringssl/prefer-time-valid-issuer.patch)
so both lookups prefer a time-valid candidate and fall back to an expired
one only when nothing valid matches. Chains whose only path goes through an
expired certificate still fail with CERT_HAS_EXPIRED.
@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:36 PM PT - Jun 29th, 2026

❌ @robobun, your commit 329b083 has 2 failures in Build #66933 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33094

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

bun-33094 --bun

@mintlify

mintlify Bot commented Jun 29, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 29, 2026, 7:27 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

The three child processes are independent; sequentially they exceed the
5 second test budget under a debug build.
@coderabbitai

coderabbitai Bot commented Jun 29, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9365fcb1-0850-4192-90c9-d46dafb448a0

📥 Commits

Reviewing files that changed from the base of the PR and between cfa9b93 and 329b083.

📒 Files selected for processing (1)
  • scripts/build/fetch-cli.ts

Walkthrough

Adds a BoringSSL patch that prefers time-valid issuer candidates during chain building, wires it into the build, expands TLS/fetch coverage for duplicate intermediates, and updates two documentation pages’ formatting.

Changes

Prefer time-valid issuer patch and TLS tests

Layer / File(s) Summary
BoringSSL issuer selection
patches/boringssl/prefer-time-valid-issuer.patch
Declares and implements x509_check_cert_time_valid, then updates issuer selection and trust-store lookup to prefer time-valid matches with expired fallback behavior.
Patch wiring and TLS coverage
scripts/build/deps/boringssl.ts, test/js/node/tls/fixtures/altchain-gen.sh, test/js/node/tls/node-tls-cert.test.ts, test/js/node/tls/fetch-tls-cert.test.ts
Adds the patch to the BoringSSL dependency config, regenerates altchain fixtures, and extends TLS/fetch tests for expired versus time-valid duplicate intermediates and CERT_HAS_EXPIRED rejection.

Documentation formatting updates

Layer / File(s) Summary
Docs table and example formatting
docs/guides/util/base64.mdx, docs/runtime/web-apis.mdx
Rewraps the base64 warning example as a fenced TypeScript block and updates the Web APIs support table formatting and link markup.

Possibly related PRs

  • oven-sh/bun#32521: Also changes scripts/build/deps/boringssl.ts, so it is directly connected to this patch wiring.

Suggested reviewers

  • Jarred-Sumner
  • dylan-conway
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main TLS issuer-selection change.
Description check ✅ Passed The description covers the change and verification details, though it uses custom headings instead of the template's exact verification section.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@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: 1

🤖 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 `@scripts/build/deps/boringssl.ts`:
- Around line 39-48: The patch application flow in applyPatch() currently treats
a zero git apply exit as success even when hunks are skipped, which lets
fetchDep() continue and write the .ref stamp against unpatched BoringSSL. Update
scripts/build/fetch-cli.ts so applyPatch() runs git apply with LC_ALL=C -v,
detects any “Skipped patch” output as a failure, and propagates that failure so
the fetch is aborted before writing the stamp.
🪄 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: 83371084-177e-4f9a-bf12-d0b5b74e03ef

📥 Commits

Reviewing files that changed from the base of the PR and between fb24aac and ea237c3.

⛔ Files ignored due to path filters (5)
  • test/js/node/tls/fixtures/altchain-int-expired-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/altchain-int-valid-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/altchain-leaf-cert.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/altchain-leaf-key.pem is excluded by !**/*.pem
  • test/js/node/tls/fixtures/altchain-root-cert.pem is excluded by !**/*.pem
📒 Files selected for processing (5)
  • patches/boringssl/prefer-time-valid-issuer.patch
  • scripts/build/deps/boringssl.ts
  • test/js/node/tls/fetch-tls-cert.test.ts
  • test/js/node/tls/fixtures/altchain-gen.sh
  • test/js/node/tls/node-tls-cert.test.ts

Comment thread scripts/build/deps/boringssl.ts Resolved
git apply can exit zero while skipping hunks; catching the LC_ALL=C
"Skipped patch" report keeps the .ref stamp from being written against
unpatched sources.

@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 didn't find any bugs, but this patches BoringSSL's X.509 chain-building logic (TLS certificate verification), which is security-critical enough that a human should review the patch and weigh in on the patch-file vs. fork question raised in the description.

Extended reasoning...

Overview

The core change is a new patches/boringssl/prefer-time-valid-issuer.patch that modifies find_issuer() in crypto/x509/x509_vfy.cc and X509_STORE_CTX_get1_issuer() in crypto/x509/x509_lu.cc to prefer time-valid issuer candidates over expired ones, mirroring OpenSSL behavior. The patch is wired into the BoringSSL dependency build via scripts/build/deps/boringssl.ts, and scripts/build/fetch-cli.ts was hardened (per CodeRabbit feedback, addressed in 9f803c6) to fail the build if git apply skips hunks. New altchain fixtures and 8 new tests in node-tls-cert.test.ts / fetch-tls-cert.test.ts cover both the peer-chain and trust-store cases plus the negative case. The two docs changes are pure autofix reformatting.

Security risks

This directly alters TLS certificate path building in the vendored crypto library. The change makes verification more permissive in the specific case where both an expired and a time-valid copy of the same issuer (same subject/key) are available — it does not accept any chain that lacks a fully valid path, and the negative test confirms CERT_HAS_EXPIRED is still raised when only an expired path exists. The approach matches OpenSSL's long-standing behavior, so the security posture should converge with Node's. That said, the x509_lu.cc hunk restructures a mutex-protected loop and adds a fallback that holds an X509_OBJECT* across the iteration before up-reffing it; correctness here depends on the store lock semantics, and any mistake in this area would affect every TLS connection in Bun.

Level of scrutiny

High. Patching a vendored BoringSSL verifier is exactly the kind of change that warrants a human reviewer with crypto/TLS context, both for the C++ itself (refcounting, locking, the bssl:: namespace definition placement) and for the maintenance question the author explicitly raised: whether this should live as a patch file or be committed to the oven-sh/boringssl fork.

Other factors

The PR is well-motivated, thoroughly documented, and comes with comprehensive regression tests plus a fixture-regeneration script. The bug-hunting system found no issues, and CodeRabbit's one suggestion has been addressed. The robobun CI comment shows widespread build-rust/build-cpp failures on the earlier commit cfa9b93 — worth confirming whether those are infrastructure-related or caused by this change before merging.

@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 didn't find any bugs, but this patches BoringSSL's X.509 chain-building/verification logic directly — security-critical code that warrants a human look (and a call on patch-file vs. landing in the oven-sh/boringssl fork).

Extended reasoning...

Overview

This PR adds a vendored patch to BoringSSL that changes how find_issuer() (in x509_vfy.cc) and X509_STORE_CTX_get1_issuer() (in x509_lu.cc) select among multiple issuer candidates with the same subject/key: instead of returning the first match, both now prefer a candidate whose notBefore/notAfter window covers the verification time, falling back to the first (possibly expired) match only when no time-valid candidate exists. The patch is wired into the build via scripts/build/deps/boringssl.ts, scripts/build/fetch-cli.ts is hardened to fail on git apply skipping hunks (per CodeRabbit feedback, addressed in 9f803c6), and new fixtures + tests cover both the peer-chain and trust-store cases for node:tls and fetch. The two docs changes are autofix.ci whitespace/table reflow.

Security risks

This is a direct modification to TLS certificate chain verification — the most security-sensitive code path in the runtime. The change is relaxing (accepts chains previously rejected with CERT_HAS_EXPIRED), which is the right direction only if it never accepts a chain that lacks a genuinely valid path. The design is sound on its face — it mirrors OpenSSL's long-standing behavior, the new helper x509_check_cert_time_valid is side-effect-free and honors X509_V_FLAG_NO_CHECK_TIME/X509_V_FLAG_USE_CHECK_TIME, the fallback preserves the original error reporting when no valid candidate exists, and ref-counting in the x509_lu.cc rewrite looks correct (the early-return path keeps obj's ref, the loop and fallback paths both X509_OBJECT_up_ref_count). But subtle mistakes here can be catastrophic, and I'm not the right reviewer to sign off on a crypto-library patch.

Level of scrutiny

High. Patching vendored BoringSSL X.509 verification is exactly the kind of change that should get eyes from someone comfortable with the OpenSSL/BoringSSL verifier internals. The author also explicitly flags a maintainer decision: whether to carry this as a patches/ file or land it in the oven-sh/boringssl fork instead.

Other factors

The PR is well-motivated and well-tested: the description includes a clear repro matrix vs. Node, the new tests cover both orderings in both the peer chain and the trust store plus a negative case confirming expired-only chains still fail, and the fixture generator script is checked in. CodeRabbit's only finding (silent patch skip) has been addressed. No prior comments from me on this PR.

@robobun

robobun commented Jun 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status across both runs of this PR (builds 66904 and 66933):

  • Every lane that runs the new TLS tests is green (node-tls-cert.test.ts and fetch-tls-cert.test.ts pass on linux glibc/musl, asan, windows, and darwin), and every build lane compiles the patched BoringSSL, with binary sizes unchanged.
  • The remaining failures are unrelated to this change:
    • test/js/node/test/parallel/test-net-connect-memleak.js on alpine x64 and x64-baseline (both runs): an upstream Node GC-precision test over plain net sockets, so none of the code this PR touches runs in it. The same test already has musl quarantine commits on other in-flight branches (40f5bac, e6ec58c) that have not landed on main.
    • test/js/bun/terminal/terminal.test.ts on darwin 14 x64 (second run only): a PTY spawn test that timed out at 90s.
    • darwin 26 aarch64 (first run only): buildkite-agent artifact download timed out before any test ran.
    • the auto-retried flaky bucket: test/cli/hot/hot.test.ts on Windows and one prettier tarball download failure.

CI was re-run once already; the alpine failure reproduces identically on both runs, on both musl lanes, and predates any interaction with this diff. Ready for review as is.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-06-29, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 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.

1 participant