Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions packages/bun-usockets/src/crypto/openssl.c
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions packages/bun-usockets/src/libusockets.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down
17 changes: 15 additions & 2 deletions src/runtime/socket/tls_socket_functions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
}
Expand Down Expand Up @@ -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);
}
Expand Down
96 changes: 96 additions & 0 deletions test/js/node/tls/node-tls-connect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Snapshot>();
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<void>();
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
Expand Down
Loading