Skip to content

tls: report the session ticket from getTLSTicket() on TLS 1.3 - #33514

Closed
robobun wants to merge 3 commits into
mainfrom
farm/8f322475/tls13-gettlsticket
Closed

tls: report the session ticket from getTLSTicket() on TLS 1.3#33514
robobun wants to merge 3 commits into
mainfrom
farm/8f322475/tls13-gettlsticket

Conversation

@robobun

@robobun robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

TLSSocket.getTLSTicket() returns undefined for every TLS 1.3 connection, including a resumed one, where Node returns the ticket as a Buffer. TLS 1.2 already matched Node, so the divergence covers the default protocol version.

Repro

import tls from "node:tls";

const server = tls.createServer({ cert, key }, s => s.end("x"));
server.listen(0, "127.0.0.1");

let session;
const first = tls.connect({ port: server.address().port, rejectUnauthorized: false });
first.once("session", s => {
  session = s;
  console.log("after NewSessionTicket:", first.getTLSTicket()); // node: <Buffer ...>  bun: undefined
  first.end();
});
first.on("close", () => {
  const second = tls.connect({ port: server.address().port, rejectUnauthorized: false, session }, () => {
    console.log("resumed:", second.isSessionReused()); // true on both
    console.log("ticket: ", second.getTLSTicket()); // node: <Buffer ...>  bun: undefined
    second.end();
  });
  second.resume();
});
first.resume();

Cause

getTLSTicket() reads SSL_SESSION_get0_ticket(SSL_get_session(ssl)), the same call Node makes. Under OpenSSL, s->session is the session the connection holds: the one the client offered while it is being resumed, and the one a NewSessionTicket replaces it with. Under BoringSSL, neither ticket is reachable from that session:

  • resumption keeps only authentication data and then releases the offered session, so the ticket is gone (tls13_client.cc):
    ssl->s3->session_reused = true;
    // Only authentication information carries over in TLS 1.3.
    hs->new_session = SSL_SESSION_dup(session, SSL_SESSION_DUP_AUTH_ONLY);
    ...
    ssl_set_session(ssl, nullptr);
  • a NewSessionTicket produces a separate session, handed to the new-session callback; established_session is left alone.

TLS 1.2 works because established_session there is the offered session itself (handshake_client.cc's UpRef(ssl->session)), ticket included.

Fix

Track the ticket of the session the connection holds next to the socket, writing it exactly where OpenSSL swaps s->session: in setSession() (the offered ticket, reported only while SSL_session_reused() says that session is the one in use) and on each NewSessionTicket dispatch (its ticket, which is this connection's either way). getTLSTicket() falls back to it only when BoringSSL's session has no ticket of its own, so the TLS 1.2 path is untouched. Servers record nothing: Node's ticket is a client-side concept.

Verification

Against node v26.3.0, every cell now agrees (it is read in the secureConnect callback, and in the first 'session' handler, where Node's own test-tls-ticket.js reads it):

node bun before bun after
TLS 1.2, handshake Buffer Buffer Buffer
TLS 1.2, resumed Buffer Buffer Buffer
TLS 1.3, handshake undefined undefined undefined
TLS 1.3, after NewSessionTicket Buffer undefined Buffer
TLS 1.3, resumed Buffer undefined Buffer
TLS 1.3, offered session rejected undefined undefined undefined

The resumed connection's ticket is byte-identical to the one its session carried, which is the invariant test-tls-ticket.js asserts.

Tests in test/js/node/tls/node-tls-connect.test.ts cover the three TLS 1.3 cells plus TLS 1.2; they fail on main and pass here. test/js/node/test/parallel/test-tls-*.js and test-https-*.js (166 files) stay green.


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

fails on main (without fix)
ASAN without fix: 2 failed, 18 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/node-tls-connect.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ee82b750e)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.93ms]
(pass) should thow ECONNRESET if FIN is received before handshake [565.48ms]
(pass) should be able to grab the JSStreamSocket constructor [39.20ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [81.82ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [125.29ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when
... (truncated)

release without fix: 4 failed, 18 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.05ms]
(pass) should thow ECONNRESET if FIN is received before handshake [11.16ms]
(pass) should be able to grab the JSStreamSocket constructor [0.20ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [3.18ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [3.68ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port and host correctly
(skip) tls.connect > should process port, host, and callback correctly
(skip) tls.connect > should handle the absence of a callback gracefully
(skip) tls.connect > should timeout
(skip) tls.connect > should be able to transfer data
(skip) tls.connect using duplex proxy > should work with alpnProtocols
(pass) tls.connect using dupl
... (truncated)
passes on PR (with fix)
ASAN with fix: 18 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/node-tls-connect.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (ee82b750e)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.51ms]
(pass) should thow ECONNRESET if FIN is received before handshake [397.48ms]
(pass) should be able to grab the JSStreamSocket constructor [21.68ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [51.64ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [86.18ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when 
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ee82b750ee
  features     (none)

22 deps, 105 codegen, 1168 objects in 781ms

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

Checked 124 installs across 170 packages (no changes) [14.00ms]
[2/1231] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[3/1231] gen ErrorCode+*.h
[4/1231] gen bindgenv2
[5/1231] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [16.00ms]
[6/1231] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants
... (truncated)
diff hotspot
src/runtime/node/node_net_binding.rs       |   1 +
 src/runtime/socket/Listener.rs             |  11 +++
 src/runtime/socket/socket_body.rs          |  37 ++++++++
 src/runtime/socket/tls_socket_functions.rs |  72 ++++++++++++----
 test/js/node/tls/node-tls-connect.test.ts  | 130 +++++++++++++++++++++++++++++
 5 files changed, 234 insertions(+), 17 deletions(-)

gate history · 2 passed · 0 rejected · iteration 4

evidence per changed file
file                                        reads  edits  tests
src/runtime/node/node_net_binding.rs            1      2      0
src/runtime/socket/Listener.rs                  3      4      0
src/runtime/socket/socket_body.rs               9     21      0
src/runtime/socket/tls_socket_functions.rs      3      8      0
test/js/node/tls/node-tls-connect.test.ts       3      3      0

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

@github-actions github-actions Bot added the claude label Jul 6, 2026
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Auto-enable TLS1.2 and TLS1.3 Session Resumption #25185 - Requests auto-enabling TLS 1.2/1.3 session resumption; this PR fixes getTLSTicket() for TLS 1.3, a key piece of session resumption support
  2. Document and test all TCPSocket/TLSSocket class methods and options #10192 - Tracks documenting/testing all TLSSocket methods; this PR fixes and tests getTLSTicket(), one of the methods explicitly listed in that issue

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #25185
Fixes #10192

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 6, 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: 1176db4c-4ad1-45c2-91f4-f04af7304afe

📥 Commits

Reviewing files that changed from the base of the PR and between 9c27f4d and 434de6d.

📒 Files selected for processing (4)
  • src/runtime/node/node_net_binding.rs
  • src/runtime/socket/Listener.rs
  • src/runtime/socket/socket_body.rs
  • src/runtime/socket/tls_socket_functions.rs
💤 Files with no reviewable changes (1)
  • src/runtime/socket/socket_body.rs

Walkthrough

This PR adds TLS session ticket storage to socket objects, initializes and clears that state across socket lifecycle paths, updates TLS session ticket capture and lookup logic, and adds tests for getTLSTicket() behavior across TLS versions and resumption cases.

Changes

TLS session ticket tracking

Layer / File(s) Summary
tls_ticket field and helpers
src/runtime/socket/socket_body.rs
NewSocket gains tls_ticket: JsCell<Option<Box<[u8]>>>; Flags gains TLS_TICKET_FROM_NEW_SESSION; helper methods store and query remembered ticket state.
Socket allocation and teardown initialize tls_ticket
src/runtime/node/node_net_binding.rs, src/runtime/socket/Listener.rs, src/runtime/socket/socket_body.rs
Socket construction sites initialize tls_ticket to None or clear it on teardown and upgrade paths.
TLS session ticket capture and lookup
src/runtime/socket/socket_body.rs, src/runtime/socket/tls_socket_functions.rs
on_session records client-side tickets, set_session copies ticket bytes from the active session, remember_new_session_ticket decodes serialized sessions, and get_tls_ticket falls back to remembered tickets when needed.
getTLSTicket() coverage
test/js/node/tls/node-tls-connect.test.ts
A new getTLSTicket() test suite covers TLS 1.3 resumption, TLS 1.2 ticket behavior, and a TLS 1.3 non-resumption case.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main change: reporting TLSTicket on TLS 1.3.
Description check ✅ Passed The description explains the bug, fix, and verification, though it does not use the template's exact section headings.
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.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Leaving both out of the description, since neither is actually closed by this:

Comment thread src/runtime/socket/Listener.rs
@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

Good catch, fixed in 9c27f4d.

Those two branches reset every other per-connection field on the wrapper they reuse (connection, local_binding, protos, server_name, owned_ssl_ctx), so the ticket has to go the same way. Both now call prev.set_tls_ticket(None, false) next to the server_name reset.

I could not reach it from JS: a TLSSocket cannot re-enter connect() today, it throws socket must be an instance of net.Socket or Duplex before doConnect is called, so there is no test for the reset itself. Node, where the wrapper can be reconnected, happens to return the previous ticket there as well, because it keeps the old SSL rather than re-handshaking. Either way the reset is what keeps the field honest the moment that path opens up.

While in there, the ticket and its origin flag now move together through set_tls_ticket() / tls_ticket_in_use() instead of being poked field by field from three places, which is what let them drift apart to begin with.

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

🤖 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-connect.test.ts`:
- Around line 697-792: Add coverage in the getTLSTicket() test suite to assert
the server-side socket never exposes a ticket: update the listen() helper or its
connection handler to check socket.getTLSTicket() returns undefined when the
server accepts a connection. Keep the assertion close to the existing
TLSv1.2/TLSv1.3 setup so the behavior is locked in for both versions and tied to
the getTLSTicket() flow.
- Around line 698-701: The explanatory comment in the TLS handshake test is too
long and must be shortened to fit the 3-line limit. Trim the multi-line comment
near the TLS 1.3 handshake logic in node-tls-connect.test.ts so it keeps only
the essential point about BoringSSL session handling and Node’s getTLSTicket()
behavior, while preserving the meaning and clarity.
- Around line 713-746: Cleanup in both harvest and resume is only executed after
await promise succeeds, so a rejected promise can leave TLSSocket instances
open. Move the client.end() and await once(client, "close") path into a
try/finally in both helper functions so cleanup always runs, even when the error
listener rejects. Keep the existing Promise.withResolvers, tlsConnect, and
once(client, "close") flow, but ensure every failure path still ends and closes
the socket.
🪄 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: 712d81a2-1b56-4253-816a-566c195475e5

📥 Commits

Reviewing files that changed from the base of the PR and between 48ff9eb and 9c27f4d.

📒 Files selected for processing (5)
  • src/runtime/node/node_net_binding.rs
  • src/runtime/socket/Listener.rs
  • src/runtime/socket/socket_body.rs
  • src/runtime/socket/tls_socket_functions.rs
  • test/js/node/tls/node-tls-connect.test.ts

Comment thread test/js/node/tls/node-tls-connect.test.ts
Comment thread test/js/node/tls/node-tls-connect.test.ts Outdated
Comment thread test/js/node/tls/node-tls-connect.test.ts

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

Thanks for addressing the prev-reuse reset — 9c27f4d covers both branches and the set_tls_ticket()/tls_ticket_in_use() helpers keep the field and flag in lockstep. I don't have further concerns, but this adds new per-connection TLS state and works around a BoringSSL/OpenSSL divergence via unsafe FFI, so it's worth a human maintainer's sign-off.

Extended reasoning...

Overview

Fixes TLSSocket.getTLSTicket() returning undefined on TLS 1.3 (BoringSSL keeps only auth data in SSL_get_session()'s result, unlike OpenSSL). Adds a tls_ticket: JsCell<Option<Box<[u8]>>> field plus a TLS_TICKET_FROM_NEW_SESSION flag to NewSocket, written from setSession() and from each NewSessionTicket dispatch (via a new remember_new_session_ticket() that deserializes the parked session with d2i_SSL_SESSION). get_tls_ticket() falls back to this cached ticket only when the live session has none, gated so a setSession()-recorded ticket is reported only while SSL_session_reused() is true. Nine NewSocket construction sites initialize the new field; both prev-reuse branches reset it (added in the follow-up commit after my earlier comment). Three new tests cover TLS 1.3 fresh/resumed/rejected plus TLS 1.2.

Security risks

This is TLS session-ticket handling — security-adjacent. The change is read-only reporting of a ticket the connection already holds (copied into an owned Box<[u8]>); it doesn't alter handshake, verification, or resumption behavior. The d2i_SSL_SESSION input is uSockets' own i2d_SSL_SESSION output, not attacker-controlled, and the +1 reference is released via scopeguard on every path. I don't see an exploitable issue, but TLS state management warrants maintainer eyes.

Level of scrutiny

Medium-high. New mutable state on a core struct with many init/reset sites, unsafe BoringSSL FFI with manual lifetime management, and a design choice (cache the ticket on the Rust wrapper, deserializing the whole session per NewSessionTicket to extract it) that a maintainer may want to weigh against alternatives.

Other factors

My earlier inline comment (missing reset in the two prev-reuse branches) was addressed in 9c27f4d, and the author additionally centralized the field+flag mutation into set_tls_ticket()/tls_ticket_in_use() so they can't drift apart again. The bug hunter found nothing on the current revision. Test coverage matches the verification matrix in the description, and the author reports the 166 vendored test-tls-*/test-https-* files stay green.

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

Thanks for addressing the earlier feedback — the reuse-path reset and test cleanup look right, and I found no further issues. That said, this threads new per-connection TLS session-ticket state through the native socket lifecycle (BoringSSL FFI, multiple reset/teardown paths), so it's worth a human pass before merge.

Extended reasoning...

Overview

This PR fixes TLSSocket.getTLSTicket() to return the session ticket on TLS 1.3 connections, matching Node's behavior. BoringSSL's SSL_get_session() doesn't retain the ticket the way OpenSSL's does under TLS 1.3, so the fix adds a tls_ticket: JsCell<Option<Box<[u8]>>> field to NewSocket plus a TLS_TICKET_FROM_NEW_SESSION flag, populated from setSession() (the offered ticket) and from the NewSessionTicket dispatch in on_session(). get_tls_ticket() falls back to this stored ticket when the live session has none, gated on SSL_session_reused() so a rejected offered session doesn't leak through. Touches socket_body.rs, tls_socket_functions.rs, Listener.rs, node_net_binding.rs (~200 lines native), plus four new tests in node-tls-connect.test.ts.

Since my earlier inline comment, the author (9c27f4d) added set_tls_ticket(None, false) to both prev-reuse branches and centralized the field+flag updates in set_tls_ticket()/tls_ticket_in_use(); cd05066 moved test cleanup into finally, trimmed the 4-line comment, and added the server-side no-ticket test — addressing all outstanding review threads including CodeRabbit's unresolved server-coverage note.

Security risks

TLS session tickets are security-relevant: they encode resumption secrets. This change is read-only from the JS surface (reporting a ticket the connection already holds), and the stored bytes are a client-side copy of what BoringSSL already handed to the 'session' callback, so exposure surface is unchanged. The main risk class is state-lifecycle correctness: a stale ticket surviving a wrapper reuse could be reported for the wrong connection. The two known reuse paths now clear it, and deinit_and_destroy frees it. The remember_new_session_ticket FFI path (d2i_SSL_SESSION → scopeguard SSL_SESSION_free) mirrors the existing set_session pattern and looks balanced. I don't see an injection, auth-bypass, or data-exposure vector introduced here.

Level of scrutiny

High. This is native TLS/BoringSSL FFI code in the runtime, adds mutable per-connection state that must be correctly initialized at every construction site (7 sites) and reset at every reuse/teardown path, and encodes a subtle semantic (the from_new_session flag vs. SSL_session_reused gate) whose correctness depends on BoringSSL's TLS 1.3 handshake internals. The PR description's reasoning is thorough and the test matrix covers the documented cells, but the CLAUDE.md guidelines explicitly flag security-sensitive/crypto paths as outside auto-approval scope.

Other factors

The bug-hunting system found nothing this run. All prior review feedback (mine and CodeRabbit's three nitpicks) has been addressed in follow-up commits. Test coverage is good: TLS 1.2 + TLS 1.3 fresh/resumed/rejected + server-side, with the resumed-ticket byte-equality assertion matching Node's own test-tls-ticket.js invariant. The one unresolved CodeRabbit thread (server-side coverage) is actually satisfied by the new "reports no ticket on a server socket" test — the thread just wasn't marked resolved. Deferring because the subject matter warrants human eyes, not because of any specific concern with the current diff.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

State for whoever picks this up. Three commits: the fix, the wrapper-reuse reset from the first review, and the test tightening from the second. Every review thread is resolved; Format, Lint and clippy are green. Locally, against the debug (ASAN) build: the four getTLSTicket() tests, the whole test/js/node/tls/ suite, and the 166 vendored test-tls-*.js / test-https-*.js files. Every cell of the matrix was read off node v26.3.0 first, including the server side.

Two things worth a careful read:

The gate in get_tls_ticket(). A ticket recorded by setSession() is reported only while SSL_session_reused() says that session is the one in use; a NewSessionTicket's is reported unconditionally, because it is this connection's either way. That asymmetry is the whole correctness argument: without it, a server that refuses the offered ticket would have us report the stale one, where node reports undefined. reports no ticket when a TLSv1.3 handshake does not resume the offered session pins exactly that, and it is the case a naive "just return the offered ticket" fix gets wrong.

Parsing the session back in remember_new_session_ticket(). The other option is to lift the ticket in uSockets' us_ssl_new_session_cb, where the SSL_SESSION* is already in hand, and hang it off the SSL's ex_data. That avoids the d2i_SSL_SESSION, but it puts a node-compat emulation inside the shared C socket library, and needs a new exported accessor pair to read it back. I kept it in the compat layer instead: the parse runs once or twice per connection, on a blob uSockets produced with i2d_SSL_SESSION a moment earlier, and only for sockets that already serialize that same session to deliver the 'session' event. Happy to move it if you'd rather pay for it there.

@robobun

robobun commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI on cd05066 is red on ten test files, none of them mine, and I believe all of them are ambient flake. Evidence, since "it's flaky" deserves more than an assertion:

None of the ten is a TLS test, and the four getTLSTicket() tests this PR adds passed on every lane. The only plausible blast radius of adding a field to NewSocket is the three that touch node:net sockets, so I checked each directly:

file verdict
test/js/node/net/net-mongodb-pattern-leak.test.ts passes locally against this branch, including its TLS half; also fails right now on two unrelated PR builds (69079, 69075)
test/js/sql/sql-onconnect-onclose-throw.test.ts passes locally against this branch
test/js/node/http/node-http-uaf.test.ts flakier without this diff: I checked out origin/main -- src/, rebuilt, and it failed 4 and 5 of 6 on two consecutive runs, against 2 and 3 with the diff applied. It is a 5s-timeout subprocess test

The remaining seven do not reach NewSocket at all: a Chrome webview (on three lanes), bun install interactive / migration / proxy, hot reload's file watcher, a Next.js dev server, and S3 over the src/http client. There is no mechanism by which a TLS session ticket on the socket wrapper breaks bun install --interactive on Windows Server 2019.

Most of the weight is on the cross-PR comparison. Every PR build in this window is red on a shifting subset of the same pool: net-mongodb-pattern-leak and webview-chrome each fail on two unrelated PRs right now, complex-workspace and update_interactive_install on another, and all three of those builds also fail napi.test.ts and package.js, which this build does not. main itself cannot be used as the control, because main builds run no tests (55 jobs, zero of them test-bun); a PR build is the first place the suite runs at all.

Every entry in the annotation already carried (1 retry) or (2 retries), so these are post-retry failures, and the lanes skew to the usual noisy ones (Windows 2019 x64-baseline, Windows 11 aarch64, the baseline lanes).

I am not pushing a retrigger: the failing set is a different random draw on every build right now, so a re-roll would buy another red build and no information. The diff itself is green where it matters. Locally, against the debug (ASAN) build: the four new tests, the whole test/js/node/tls/ suite, and the 166 vendored test-tls-*.js / test-https-*.js files. Format, Lint and clippy are green, and every review thread is resolved.

@robobun
robobun force-pushed the farm/8f322475/tls13-gettlsticket branch from cd05066 to 434de6d Compare July 9, 2026 01:27
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (434de6d) to clear the merge conflict with #31859, which moved the Handlers callback slots behind a GC-visited cell. Three textual conflicts, all the same shape: my field/call inserted next to a line that PR rewrote. Resolved each in favour of main's new spelling (handlers.on_session() accessor, ThisPtr<Self> in on_session, OWNS_HANDLERS gone from the default flags) with my addition kept alongside. The four getTLSTicket() tests, the full node-tls-connect.test.ts file, the five vendored node ticket/session tests, and rust:check-all across all ten targets are green on the rebased branch.

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

No issues found on re-review — the prev-reuse reset and test cleanup all look right. This touches TLS session-ticket state and BoringSSL FFI with a non-obvious correctness gate (SSL_session_reused vs the from-new-session flag), so I'd like a human to sign off.

Extended reasoning...

Overview

This PR fixes TLSSocket.getTLSTicket() returning undefined on TLS 1.3 connections where Node returns the ticket. The root cause is a BoringSSL/OpenSSL divergence: BoringSSL's SSL_get_session() on TLS 1.3 keeps only auth data (no ticket), whereas OpenSSL's s->session holds the offered/replaced ticket. The fix tracks the ticket alongside the socket (tls_ticket: JsCell<Option<Box<[u8]>>> + TLS_TICKET_FROM_NEW_SESSION flag), written from setSession() and from each NewSessionTicket dispatch, and read as a fallback in get_tls_ticket() gated on SSL_session_reused(). Changes span socket_body.rs (field, flag, accessors, on_session hook, teardown), tls_socket_functions.rs (set_session, new session_ticket/remember_new_session_ticket helpers, rewritten get_tls_ticket), Listener.rs and node_net_binding.rs (initialize/reset the field at every construction and reuse site), plus four new tests in node-tls-connect.test.ts.

Since my prior inline comment (stale ticket on wrapper reuse), the author added prev.set_tls_ticket(None, false) to both prev-reuse branches, centralized the ticket+flag mutation behind set_tls_ticket()/tls_ticket_in_use(), tightened test cleanup into try/finally, added server-side coverage, and rebased over #31859. All review threads are resolved.

Security risks

TLS session tickets are security-relevant material. The change is read-only reporting (matching Node's existing getTLSTicket() surface — the ticket was already exposed via the 'session' event's serialized session), so no new exposure. Memory handling in remember_new_session_ticket (d2i_SSL_SESSION +1 ref freed via scopeguard, ticket bytes copied into an owned Box<[u8]> before the session is freed) and the borrowed-slice lifetime in session_ticket() both look correct. No new attack surface I can identify.

Level of scrutiny

This warrants human review: it's TLS/BoringSSL FFI with unsafe blocks, per-connection state added to a widely-shared struct, and a correctness argument that hinges on a subtle asymmetry (a setSession()-recorded ticket is only reported while SSL_session_reused() is true; a NewSessionTicket-recorded one is reported unconditionally). The reasoning is well-documented and the test matrix (TLS 1.2/1.3 × fresh/resumed/rejected × client/server) is thorough, but crypto-adjacent state machines are exactly where a second pair of eyes on the gate logic and the on_session server-mode guard is worthwhile.

Other factors

The bug-hunting system found nothing on the current revision. Tests are hermetic (local servers, port: 0, await using, error-wired promises, try/finally cleanup). The author verified every cell of the behavior matrix against Node v26.3.0, ran the full test/js/node/tls suite and 166 vendored test-tls-*/test-https-* files under ASAN, and rust:check-all across all targets post-rebase. CI reds were analyzed as ambient flake unrelated to TLS. The design tradeoff (parse the serialized session in the compat layer vs. lift the ticket in uSockets' new-session callback) is called out in the PR thread for the reviewer to weigh.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Build 70770 on the rebased head (434de6d) is red with the same pattern as 69172: twelve files, none of them TLS, eight of which also fail on unrelated PR builds from the same window (70693, 70691, 70682). The four that only appear here are bun-install.test.ts, serve.test.ts (Windows 11 aarch64), the napi-finalizer regression 30205.test.ts, and solc.test.ts. The four getTLSTicket() tests this PR adds passed on every lane, and they pass locally on this commit.

Not pushing a retrigger: every PR build in the window is red on a different random draw from the same pool, so a re-roll buys another red and no information. The diff is ready for a maintainer when one has a moment.

robobun added 3 commits July 10, 2026 15:00
BoringSSL keeps only authentication data in the session SSL_get_session()
returns after a TLS 1.3 handshake: resumption drops the session the client
offered (SSL_SESSION_DUP_AUTH_ONLY), and a NewSessionTicket's ticket never
lands in it. OpenSSL holds both on s->session, which is where Node answers
getTLSTicket() from, so TLSSocket.getTLSTicket() returned undefined for every
TLS 1.3 connection.

Track the ticket of the session the connection holds alongside it: the one
setSession() offered, while that session is the one in use, and then the one
each NewSessionTicket brings.
The reconnect-in-place branches reset every other per-connection field on the
wrapper they reuse; the ticket has to go the same way, or getTLSTicket() on the
next handshake answers with the previous connection's ticket.

Route both the writes and the read through set_tls_ticket/tls_ticket_in_use so
the ticket and its origin flag can only move together.
Close the client on every path, not only when the awaited handshake resolved,
and assert the other half of the contract: a server socket reports no ticket,
including after it has minted and cached a session of its own.
@robobun
robobun force-pushed the farm/8f322475/tls13-gettlsticket branch from 434de6d to ee82b75 Compare July 10, 2026 15:13
@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (ee82b75) to clear the conflict with #33755, which added three Flags bits and the acts_as_tls_server() helper. Three textual conflicts: moved TLS_TICKET_FROM_NEW_SESSION to 1 << 15 (still u16), and switched the on_session server gate from handlers.mode.is_server() to the new acts_as_tls_server() so an upgradeTLS({ isServer: true }) socket skips it the same way a Listener server does. Re-verified: four getTLSTicket() tests, full node-tls-connect.test.ts, the four vendored node session/ticket tests, and rust:check-all across all ten targets.

Jarred-Sumner pushed a commit that referenced this pull request Jul 31, 2026
…icket() on TLS 1.3 (#36475)

On a TLS 1.3 client, `socket.getSession()` called after the `'session'`
events have fired returns a session that cannot resume: passing it to
`tls.connect({ session })` results in a full handshake
(`isSessionReused() === false` on both ends). The Buffer the `'session'`
event carries does resume. `getTLSTicket()` returns `undefined` on the
same connection. Node returns the latest ticket-bearing session from
both accessors and it resumes. TLS 1.2 already matched Node.

### Repro

```js
import tls from 'node:tls';
import { once } from 'node:events';

const srv = tls.createServer({ key, cert }, s => {
  s.write(`reused=${s.isSessionReused()}`);
}).listen(0, '127.0.0.1');
await once(srv, 'listening');
const port = srv.address().port;

const first = tls.connect({ port, ca: cert, servername: 'localhost' });
await once(first, 'session');          // NewSessionTicket processed
const viaGet = first.getSession();     // bun: 1095 bytes, no ticket
console.log(first.getTLSTicket());     // bun: undefined   node: <Buffer ...>
first.destroy();

const second = tls.connect({ port, ca: cert, servername: 'localhost', session: viaGet });
await once(second, 'secureConnect');
console.log(second.isSessionReused()); // bun: false   node: true
```

### Cause

BoringSSL's `SSL_get_session()` returns `ssl->s3->established_session`,
set once at handshake completion. When a TLS 1.3 NewSessionTicket
arrives post-handshake, BoringSSL builds a new `SSL_SESSION`
(`tls13_create_session_with_ticket`) and hands it only to the
new-session callback; `established_session` is never updated. This is
documented in
[`ssl.h`](https://github.com/oven-sh/bun/blob/main/vendor/boringssl/include/openssl/ssl.h):

> using the callback is required as of TLS 1.3. For compatibility, this
function will return an unresumable session which may be cached, but
will never be resumed.

Node uses OpenSSL, which replaces the connection's session with the
ticket-bearing one, so `SSL_get_session()` reflects it there.

Bun already receives these sessions in `us_ssl_new_session_cb` (where
the `'session'` event's payload comes from) but only serialized them for
the event queue.

### Fix

Keep a reference to the most recent `SSL_SESSION*` the new-session
callback saw in an ex_data slot on the SSL, and have `getSession()` /
`getTLSTicket()` read from it (falling back to `SSL_get_session()`
before any ticket arrives or on sockets that never opted into session
events). The reference is released on `SSL_free` via the ex_data free
hook, and replaced on each subsequent ticket.

TLS 1.2 is unchanged: `new_session_cb` there receives
`established_session` itself (`ssl_update_cache`), so reading from the
ex_data slot yields the same session.

### Verification

Against node v26.3.0 on loopback, after the first `'session'` event:

| | node | bun before | bun after |
| --- | --- | --- | --- |
| TLS 1.3 `getSession()` resumes | true | **false** | true |
| TLS 1.3 `getTLSTicket()` | Buffer | **undefined** | Buffer |
| TLS 1.2 `getSession()` resumes | true | true | true |
| TLS 1.2 `getTLSTicket()` | Buffer | Buffer | Buffer |

New tests in `test/js/node/tls/node-tls-connect.test.ts` cover both
protocol versions on the direct-TCP and duplex-wrapped paths; the TLS
1.3 cases fail on `main` (`clientReused: false`, `serverReused: [false,
false]`) and pass here.

Related: #33514 fixes `getTLSTicket()` by tracking ticket bytes in Rust
per-socket state; this change addresses both accessors at the usockets
layer with no per-socket state.

<!-- robobun:evidence:begin -->

---

**[review]** gate passed · iteration 0 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 2 failed, 18 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/node-tls-connect.test.ts
bun test v1.4.0 (e09ce2a)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.38ms]
(pass) should thow ECONNRESET if FIN is received before handshake [349.95ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [7.51ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [109.31ms]
(pass) should be able to grab the JSStreamSocket constructor [13.74ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [78.05ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [76.77ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port a
... (truncated)

release without fix: 16 failed, 18 skipped
bun test v1.4.0-canary.1 (1498d7b)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [0.05ms]
(pass) should thow ECONNRESET if FIN is received before handshake [14.75ms]
161 | it("initializes authorizationError to null in the TLSSocket constructor", () => {
162 |   // https://github.com/nodejs/node/blob/v26.3.0/lib/internal/tls/wrap.js#L556
163 |   // Node's onServerSocketSecure/onConnectSecure only assign on failure; a
164 |   // clean handshake leaves the constructor's null untouched.
165 |   const socket = new tls.TLSSocket();
166 |   expect({ value: socket.authorizationError, hasOwn: "authorizationError" in socket }).toEqual({
                                                                                             ^
error: expect(received).toEqual(expected)

  {
-   "hasOwn": true,
-   "value": null,
+   "hasOwn": false,
+   "value": undefined,
  }

- Expected  - 2
+ Received  + 2

      at <anonymous> (/workspace/bun/test/js/node/tls/node-tls-connect.test.ts:166:88)
(fail) initializes authorizationError to null in the TLSSocket constructor [0.44ms]
182 |     connected.resolve,
183 |   );
184 |   client.on("error", connecte
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: 18 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/node-tls-connect.test.ts
bun test v1.4.0 (e09ce2a)

test/js/node/tls/node-tls-connect.test.ts:
(pass) should have checkServerIdentity [2.14ms]
(pass) should thow ECONNRESET if FIN is received before handshake [345.13ms]
(pass) initializes authorizationError to null in the TLSSocket constructor [7.26ms]
(pass) setMaxSendFragment mirrors OpenSSL's [512, 16384] acceptance without throwing [109.22ms]
(pass) should be able to grab the JSStreamSocket constructor [14.00ms]
(skip) tls.connect > should work with alpnProtocols
(pass) tls.connect > Bun.serve() should work with tls and Bun.file() [75.89ms]
(pass) tls.connect > should have peer certificate when using self asign certificate [78.84ms]
(skip) tls.connect > should have peer certificate
(skip) tls.connect > getCipher, getProtocol, getEphemeralKeyInfo, getSharedSigalgs, getSession, exportKeyingMaterial and isSessionReused should work
(skip) tls.connect > should process options correctly when connect is called with only options
(skip) tls.connect > should process port a
... (truncated)

release with fix: 18 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 655ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/45] cxx obj/src/jsc/bindings/webcrypto/CryptoAlgorithmAES_KW.cpp.o
[2/45] cxx obj/unified/UnifiedSource-src_jsc_bindings-4.cpp.o
[3/45] cxx obj/unified/UnifiedSource-src_runtime_webview-0.cpp.o
[4/45] cxx obj/src/jsc/bindings/bindings.cpp.o
[5/45] cxx obj/unified/UnifiedSource-src_jsc_bindings-5.cpp.o
[6/45] cxx obj/unified/UnifiedSource-packages_bun_usockets_src_crypto-0.cpp.o
[7/45] cxx obj/unified/UnifiedSource-src_jsc_bindings_node-0.cpp.o
[8/45] cxx obj/unified/UnifiedSource-src_jsc_bindings-1.cpp.o
[9/45] cxx obj/unified/UnifiedSource-src_jsc_bindings-3.cpp.o
[10/45] cxx obj/unified/UnifiedSource-src_uws_sys-0.cpp.o
[11/45] cxx obj/src/simdutf_sys/bun-simdutf.cpp.o
[12/45] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 239 extern-C blocks audited
[12/45] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Com
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
packages/bun-usockets/src/crypto/openssl.c | 27 +++++++++
 packages/bun-usockets/src/libusockets.h    |  3 +
 src/runtime/socket/tls_socket_functions.rs | 17 +++++-
 test/js/node/tls/node-tls-connect.test.ts  | 96 ++++++++++++++++++++++++++++++
 4 files changed, 141 insertions(+), 2 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                        reads  edits  tests
packages/bun-usockets/src/crypto/openssl.c      2      5      0
packages/bun-usockets/src/libusockets.h         1      2      0
src/runtime/socket/tls_socket_functions.rs      2      3      0
test/js/node/tls/node-tls-connect.test.ts       1      4      0
```

</details>

<!-- robobun:evidence:end -->
@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Closing this since #36475 (node:tls: return the ticket-bearing session from getSession()/getTLSTicket() on TLS 1.3) merged and covers the same ground. Thank you @robobun for the PR — if there's a piece of this that #36475 didn't pick up, please say so and we'll take another look.

(This comment was written by Claude, on behalf of the Bun team.)

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