diff --git a/packages/bun-usockets/src/crypto/openssl.c b/packages/bun-usockets/src/crypto/openssl.c index 06d4c66fa7cf..32d451e322be 100644 --- a/packages/bun-usockets/src/crypto/openssl.c +++ b/packages/bun-usockets/src/crypto/openssl.c @@ -186,6 +186,11 @@ extern const unsigned char BUN_SOCKET_KIND_UWS_HTTP_TLS; * SSL stack unwinds; freed with the SSL if never delivered. */ static int us_ssl_pending_session_idx = -1; static int us_ssl_pending_keylog_idx = -1; +/* The SSL_SESSION* most recently delivered to the new-session callback. Under + * TLS 1.3 BoringSSL never updates the SSL's own established_session with a + * received NewSessionTicket, so SSL_get_session() alone gives an unresumable + * snapshot; node:tls's getSession()/getTLSTicket() read from here instead. */ +static int us_ssl_new_session_ref_idx = -1; #ifdef _WIN32 static INIT_ONCE us_ex_idx_once = INIT_ONCE_STATIC_INIT; #else @@ -272,6 +277,11 @@ static void us_ssl_pending_session_free(void *parent, void *ptr, CRYPTO_EX_DATA pending = next; } } +static void us_ssl_new_session_ref_free(void *parent, void *ptr, CRYPTO_EX_DATA *ad, + int index, long argl, void *argp) { + (void)parent; (void)ad; (void)index; (void)argl; (void)argp; + if (ptr) SSL_SESSION_free((SSL_SESSION *)ptr); +} /* NSS key-log lines are produced from inside SSL_do_handshake/SSL_read, so * they are parked on the SSL the same way new sessions are and delivered once * the read unwinds. The stored bytes already carry the trailing newline Node @@ -333,6 +343,14 @@ static int us_ssl_new_session_cb(SSL *ssl, SSL_SESSION *session) { if (!SSL_get_ex_data(ssl, us_ssl_is_socket_ex_idx)) { return 0; } + /* Stash the latest session for getSession()/getTLSTicket(): BoringSSL only + * hands a TLS 1.3 NewSessionTicket to this callback, never to the SSL's + * established_session. Do this before the serialize/park step so a session + * too large to queue is still reachable. */ + SSL_SESSION_up_ref(session); + SSL_SESSION *prev = SSL_get_ex_data(ssl, us_ssl_new_session_ref_idx); + SSL_set_ex_data(ssl, us_ssl_new_session_ref_idx, session); + if (prev) SSL_SESSION_free(prev); int length = i2d_SSL_SESSION(session, NULL); if (length <= 0 || length > US_SSL_PENDING_SESSION_MAX) { return 0; @@ -402,6 +420,7 @@ static void us_ex_idx_init(void) { us_ssl_inline_reject_err_ex_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, NULL); us_ssl_pending_session_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); us_ssl_pending_keylog_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_pending_session_free); + us_ssl_new_session_ref_idx = SSL_get_ex_new_index(0, NULL, NULL, NULL, us_ssl_new_session_ref_free); } #ifdef _WIN32 @@ -464,6 +483,14 @@ int us_ssl_pop_pending_keylog(SSL *ssl, unsigned char *out, int out_cap) { return us_ssl_pop_pending(ssl, us_ssl_pending_keylog_idx, out, out_cap); } +/* The resumable session most recently delivered via the new-session callback, + * or NULL if none has arrived. The returned pointer is borrowed from the SSL's + * ex_data and valid until the next NewSessionTicket or SSL_free. */ +SSL_SESSION *us_ssl_get_new_session(SSL *ssl) { + if (us_ssl_new_session_ref_idx < 0) return NULL; + return SSL_get_ex_data(ssl, us_ssl_new_session_ref_idx); +} + int us_ssl_ctx_cache_ex_idx(void) { us_ex_idx_ensure(); return us_ctx_cache_ex_idx; diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index d3431d30a323..105fdd599670 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -551,6 +551,9 @@ int us_ssl_ctx_add_ca_cert(struct ssl_ctx_st *ctx, const char *content); void us_ssl_enable_pending_events(struct ssl_st *ssl); int us_ssl_pop_pending_session(struct ssl_st *ssl, unsigned char *out, int out_cap); int us_ssl_pop_pending_keylog(struct ssl_st *ssl, unsigned char *out, int out_cap); +/* The resumable session most recently delivered via the new-session callback, + * or NULL if none. Borrowed; valid until the next NewSessionTicket or SSL_free. */ +struct ssl_session_st *us_ssl_get_new_session(struct ssl_st *ssl); /* Public interfaces for loops */ diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index dbe8543a619c..7d7da9fa4bdc 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -104,6 +104,8 @@ pub(super) mod ffi { // ── SSL_SESSION ─────────────────────────────────────────────────── pub(crate) safe fn SSL_get_session(ssl: &SSL) -> *mut SSL_SESSION; + // Borrowed from the SSL's ex_data; no caller-side precondition. + pub(crate) safe fn us_ssl_get_new_session(ssl: &SSL) -> *mut SSL_SESSION; // Both handles are opaque-ZST refs (`UnsafeCell` body); BoringSSL bumps // `session`'s refcount internally — no caller-side precondition. pub(crate) safe fn SSL_set_session(ssl: &SSL, session: &SSL_SESSION) -> c_int; @@ -1104,6 +1106,17 @@ pub(super) fn get_alpn_protocol(this: &This, global: &JSGlobalObject) -> JsResul Ok(ZigString::from_utf8(slice).to_js(global)) } +/// The session Node's `getSession()`/`getTLSTicket()` read: the one most +/// recently delivered to the new-session callback (the only place BoringSSL +/// surfaces a TLS 1.3 NewSessionTicket), falling back to the SSL's own. +fn current_session(ssl: &boringssl::SSL) -> *mut ffi::SSL_SESSION { + let new = ffi::us_ssl_get_new_session(ssl); + if !new.is_null() { + return new; + } + ffi::SSL_get_session(ssl) +} + pub(super) fn get_session( this: &This, global: &JSGlobalObject, @@ -1112,7 +1125,7 @@ pub(super) fn get_session( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::UNDEFINED); }; - let session = ffi::SSL_get_session(boringssl::SSL::opaque_ref(ssl_ptr)); + let session = current_session(boringssl::SSL::opaque_ref(ssl_ptr)); if session.is_null() { return Ok(JSValue::UNDEFINED); } @@ -1193,7 +1206,7 @@ pub(super) fn get_tls_ticket( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::UNDEFINED); }; - let session = ffi::SSL_get_session(boringssl::SSL::opaque_ref(ssl_ptr)); + let session = current_session(boringssl::SSL::opaque_ref(ssl_ptr)); if session.is_null() { return Ok(JSValue::UNDEFINED); } diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 4050b066e815..0dbbfac097aa 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -739,6 +739,102 @@ it("delivers 'session' even when the data handler destroys the socket immediatel await once(server, "close"); }); +describe.each(["TLSv1.2", "TLSv1.3"] as const)("%s: getSession() / getTLSTicket()", version => { + // BoringSSL never folds a TLS 1.3 NewSessionTicket into the SSL's own + // established_session; it only hands the ticket-bearing session to the + // new-session callback. getSession()/getTLSTicket() must read from that + // session once it arrives, or the blob they return cannot resume. + type Snapshot = { + protocol: string | null; + viaGetSession: Buffer | undefined; + ticket: Buffer | undefined; + }; + + function onFirstSession(client: TLSSocket) { + const { promise, resolve, reject } = Promise.withResolvers(); + client.on("error", reject); + client.on("data", () => {}); + client.once("session", () => + resolve({ + protocol: client.getProtocol(), + viaGetSession: client.getSession(), + ticket: client.getTLSTicket(), + }), + ); + return promise; + } + + it("returns a resumable session once the NewSessionTicket arrives", async () => { + const serverReused: boolean[] = []; + const twoConnections = Promise.withResolvers(); + await using server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, socket => { + serverReused.push(socket.isSessionReused()); + if (serverReused.length === 2) twoConnections.resolve(); + socket.write("x"); + socket.on("data", () => {}); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + const opts = { + port, + host: "127.0.0.1", + servername: "localhost", + ca: COMMON_CERT_.cert, + minVersion: version, + maxVersion: version, + } as const; + + const first = tlsConnect(opts); + const { protocol, viaGetSession, ticket } = await onFirstSession(first); + first.destroy(); + await once(first, "close"); + + expect(protocol).toBe(version); + expect(Buffer.isBuffer(viaGetSession)).toBe(true); + + // Second connection: the getSession() blob resumes on both ends. + const second = tlsConnect({ ...opts, session: viaGetSession }); + second.on("data", () => {}); + await once(second, "secureConnect"); + const clientReused = second.isSessionReused(); + await twoConnections.promise; + second.destroy(); + await once(second, "close"); + + expect({ clientReused, serverReused }).toEqual({ clientReused: true, serverReused: [false, true] }); + expect(Buffer.isBuffer(ticket)).toBe(true); + expect(ticket!.length).toBeGreaterThan(0); + }); + + it("over a duplex-wrapped TLSSocket", async () => { + await using server = tls.createServer({ ...COMMON_CERT_, minVersion: version, maxVersion: version }, socket => { + socket.on("data", () => socket.end()); + }); + await once(server.listen(0, "127.0.0.1"), "listening"); + const port = (server.address() as AddressInfo).port; + + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const client = tls.connect({ + socket: new SocketProxy(raw), + servername: "localhost", + ca: COMMON_CERT_.cert, + minVersion: version, + maxVersion: version, + }); + const snapshot = onFirstSession(client); + await once(client, "secureConnect"); + client.write("x"); + const { viaGetSession, ticket } = await snapshot; + client.destroy(); + await once(client, "close"); + + expect(Buffer.isBuffer(viaGetSession)).toBe(true); + expect(Buffer.isBuffer(ticket)).toBe(true); + expect(ticket!.length).toBeGreaterThan(0); + }); +}); + it("a write before 'secureConnect' still reports the handshake's own failure", async () => { // An early write drives the handshake from inside SSL_write. The fatal // reason that write hit used to be dropped, so the handshake dispatch had