Skip to content

node:tls: return the ticket-bearing session from getSession()/getTLSTicket() on TLS 1.3 - #36475

Merged
Jarred-Sumner merged 1 commit into
mainfrom
farm/414b8885/tls13-getsession-resumable
Jul 31, 2026
Merged

node:tls: return the ticket-bearing session from getSession()/getTLSTicket() on TLS 1.3#36475
Jarred-Sumner merged 1 commit into
mainfrom
farm/414b8885/tls13-getsession-resumable

Conversation

@robobun

@robobun robobun commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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

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:

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.


[review] gate passed · iteration 0 · 4 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
bun test v1.4.0 (e09ce2add)

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 (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 [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)
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
bun test v1.4.0 (e09ce2add)

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)
diff hotspot
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(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
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

…icket() on TLS 1.3

BoringSSL delivers a TLS 1.3 NewSessionTicket only to the new-session
callback; it never updates the SSL's established_session, so
SSL_get_session() returns a snapshot without the ticket. The blob
getSession() returned could not resume (full handshake on reconnect),
and getTLSTicket() returned undefined. Node's OpenSSL updates the
connection's session with each ticket, so both accessors work there.

Keep a reference to the most recent SSL_SESSION the new-session callback
saw in an ex_data slot on the SSL, and read from it in getSession() and
getTLSTicket() (falling back to SSL_get_session() before any ticket
arrives). The reference is released on SSL_free via the ex_data free
hook. TLS 1.2 is unchanged: the new-session callback there receives the
established_session itself.
@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. Auto-enable TLS1.2 and TLS1.3 Session Resumption #25185 - This PR makes getSession()/getTLSTicket() return valid TLS 1.3 sessions, enabling client-side session resumption via tls.connect({ session }) which is the core mechanism this feature request depends on.

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

Fixes #25185

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

TLS session retrieval

Layer / File(s) Summary
Native session tracking
packages/bun-usockets/src/crypto/openssl.c, packages/bun-usockets/src/libusockets.h
Tracks the latest SSL_SESSION delivered through the TLS new-session callback, manages its reference lifetime, and exposes a borrowed accessor.
Runtime session selection
src/runtime/socket/tls_socket_functions.rs
Adds the FFI binding and uses the tracked session for getSession() and getTLSTicket(), falling back to SSL_get_session.
Session retrieval validation
test/js/node/tls/node-tls-connect.test.ts
Tests session and ticket capture, resumption, and duplex-wrapped TLS connections for TLS 1.2 and TLS 1.3.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly describes the main TLS 1.3 session/ticket fix and matches the changeset.
Description check ✅ Passed The PR description covers the problem, repro, cause, fix, and verification, satisfying the template's intent.

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

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. tls: report the session ticket from getTLSTicket() on TLS 1.3 #33514 - Both fix getTLSTicket()/getSession() returning wrong data on TLS 1.3 due to BoringSSL not updating established_session with the NewSessionTicket; tls: report the session ticket from getTLSTicket() on TLS 1.3 #33514 stores the ticket in Rust, this PR stores the SSL_SESSION* in a BoringSSL ex_data slot

🤖 Generated with Claude Code

@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, but this touches BoringSSL SSL_SESSION refcounting in openssl.c — worth a human sign-off.

What was reviewed:

  • ex_data slot lifecycle: SSL_SESSION_up_ref on stash, SSL_SESSION_free on replace, free-hook on SSL_free — balanced on all paths, matches the neighboring us_ssl_pending_session_idx pattern.
  • Borrowed-session lifetime in get_session/get_tls_ticket: no user JS or network read runs between borrow and i2d_SSL_SESSION/get0_ticket, so the pointer stays valid across the JS buffer allocation.
  • Other SSL_get_session callers (openssl.c:1613, ncrypto.cpp:2983) only read the protocol version — established_session is correct there, no sibling fix needed.
  • Tests cover TLS 1.2/1.3 × direct/duplex-wrapped, assert isSessionReused() on both ends, wire error to reject.
Extended reasoning...

Overview

Fixes TLSSocket.getSession() / getTLSTicket() on TLS 1.3 clients to return the ticket-bearing session (matching Node), instead of BoringSSL's unresumable established_session snapshot. Touches:

  • packages/bun-usockets/src/crypto/openssl.c: new SSL ex_data slot holding a ref to the most recent SSL_SESSION* delivered to us_ssl_new_session_cb, with a free hook and a us_ssl_get_new_session() accessor.
  • packages/bun-usockets/src/libusockets.h: header decl for the accessor.
  • src/runtime/socket/tls_socket_functions.rs: current_session() helper that prefers the stashed session, falls back to SSL_get_session(); both get_session and get_tls_ticket now call it.
  • test/js/node/tls/node-tls-connect.test.ts: new describe.each over TLSv1.2/TLSv1.3 covering direct-TCP and duplex-wrapped resumption + ticket presence.

Security risks

This is a Node-compat fix for session-resumption accessors, not a change to any verification/auth path. The security-relevant aspect is native memory safety: the new ex_data slot holds a +1 SSL_SESSION ref. I traced the refcount — up_ref on stash, free of the previous on replace, and us_ssl_new_session_ref_free on SSL_free — and it's balanced on every terminal path. SSL_set_ex_data return isn't checked, but that matches every other ex_data write in the file and only fails on OOM. No security regression identified.

Level of scrutiny

High — this is C-level BoringSSL refcounting inside the TLS stack, the most-blocked category in REVIEW.md. Even though the pattern mirrors the adjacent us_ssl_pending_session_idx/us_ssl_pending_keylog_idx slots exactly and the diff is small, native crypto memory management warrants a maintainer's eyes rather than bot approval.

Other factors

  • The PR description cites BoringSSL's own ssl.h docs for why SSL_get_session() is unresumable on TLS 1.3, and correctly notes TLS 1.2 is unchanged (ssl_update_cache passes established_session itself to the callback).
  • I checked the two other in-tree SSL_get_session call sites (us_internal_verify_peer_certificate in openssl.c and SSLPointer::verifyPeerCertificate in ncrypto.cpp) — both only read SSL_SESSION_get_protocol_version, which established_session reports correctly, so they don't need the same treatment.
  • The Rust-side borrowed pointer from us_ssl_get_new_session is documented as valid until the next NewSessionTicket or SSL_free; both consumers use it synchronously with only a JS buffer allocation in between (no user callbacks, no network reads), so it can't be invalidated mid-use.
  • Related PR #33514 takes a different approach (per-socket ticket bytes in Rust); a maintainer should decide which lands.
  • No CODEOWNERS on the changed paths.

@Jarred-Sumner
Jarred-Sumner merged commit 60bee4a into main Jul 31, 2026
57 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/414b8885/tls13-getsession-resumable branch July 31, 2026 08:05
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