From 5b7e5b36753669d725537336ed381a050a87b00e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 04:35:00 +0000 Subject: [PATCH 1/6] node:tls: fix getCipher().version, getEphemeralKeyInfo(), client STARTTLS, checkServerIdentity exceptions getCipher().version always reported BoringSSL's hardcoded "TLSv1/SSLv3" placeholder. Rebuild Node's strings from SSL_CIPHER_get_min_version plus the key-exchange NID for the pre-1.2 suites. getEphemeralKeyInfo() read the client's own private key (always empty on a client without a certificate). Report the negotiated (EC)DHE group via SSL_get_negotiated_group instead, matching Node for TLS <= 1.2 and its {} on TLS 1.3 and static-RSA key exchanges. An exception thrown from a user checkServerIdentity was caught by the native handshake handler's error routing and emitted as a socket 'error'. Node does not guard the callback; defer the rethrow so it escapes as an uncaught exception and 'secureConnect' does not fire. new tls.TLSSocket(socket, { isServer: false }) + _start() threw ERR_MISSING_ARGS because connect() received no port, path, or socket. Hand the wrapped socket to connect(), and honor the constructor's own rejectUnauthorized, which that path never routed through Socket.prototype.connect. Deleted the now-dead FFI: SSL_get_privatekey, EVP_PKEY_id, EVP_PKEY_bits, EVP_PKEY_get1_EC_KEY (which deliberately leaked the EC_KEY), EC_KEY_get0_group, EC_GROUP_get_curve_name, SSL_CIPHER_get_version, the EVP_PKEY/EC_KEY/EC_GROUP opaque handles, and the EVP_PKEY_DH/X25519/X448 constants. --- src/js/node/net.ts | 41 +++- src/js/node/tls.ts | 17 +- src/runtime/socket/tls_socket_functions.rs | 141 +++++------ test/js/node/test/common/boringssl.js | 20 +- .../test-tls-client-getephemeralkeyinfo.js | 2 +- test/js/node/tls/node-tls-connect.test.ts | 220 +++++++++++++++++- 6 files changed, 334 insertions(+), 107 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 8d2305b5ef50..766f803e3efe 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -254,6 +254,33 @@ function tlsHandshakeError(verifyError) { return new ConnResetException("socket hang up"); } +function rethrowUncaught(err) { + throw err; +} + +// Distinguishes "the user's checkServerIdentity threw" from any Error it +// returned; the handshake is abandoned the way Node abandons onConnectSecure. +const kCheckServerIdentityThrew = Symbol("kCheckServerIdentityThrew"); + +/** + * Run the user's `checkServerIdentity` and return the verify Error it + * produced (or undefined). Node does not guard the callback: an exception it + * throws escapes the handshake as an uncaught exception rather than being + * downgraded to a socket 'error' by the native handler error routing, so the + * rethrow is deferred out of this callback's try frame. + */ +function runCheckServerIdentity(self, checkServerIdentity) { + const hostname = self.servername || self._host || "localhost"; + const cert = self.getPeerCertificate(true); + if (!cert) return undefined; + try { + return checkServerIdentity(hostname, cert); + } catch (err) { + process.nextTick(rethrowUncaught, err); + return kCheckServerIdentityThrew; + } +} + const SocketHandlers: SocketHandler = { close(socket, err) { const self = socket.data; @@ -416,11 +443,8 @@ const SocketHandlers: SocketHandler = { self.alpnProtocol = socket.alpnProtocol; const { checkServerIdentity } = self[bunTLSConnectOptions]; if (!verifyError && typeof checkServerIdentity === "function") { - const hostname = self.servername || self._host || "localhost"; - const cert = self.getPeerCertificate(true); - if (cert) { - verifyError = checkServerIdentity(hostname, cert); - } + verifyError = runCheckServerIdentity(self, checkServerIdentity); + if (verifyError === kCheckServerIdentityThrew) return; } let rejectUnauthorized; if (self._requestCert || (rejectUnauthorized = self._rejectUnauthorized)) { @@ -1152,11 +1176,8 @@ const SocketHandlers2: SocketHandler *const c_char; + pub(crate) safe fn SSL_version(ssl: &SSL) -> c_int; + /// NID of the (EC)DHE group negotiated by the most recently completed + /// handshake, or `NID_undef` (0) when the key exchange used none (RSA). + pub(crate) safe fn SSL_get_negotiated_group(ssl: &SSL) -> c_int; pub(crate) safe fn SSL_get_peer_certificate(ssl: &SSL) -> *mut X509; pub(crate) safe fn SSL_get_certificate(ssl: &SSL) -> *mut X509; pub(crate) safe fn SSL_set_max_send_fragment(ssl: &SSL, max_send_fragment: usize) -> c_int; @@ -100,7 +108,6 @@ pub(super) mod ffi { use_context: c_int, ) -> c_int; pub(crate) safe fn SSL_session_reused(ssl: &SSL) -> c_int; - pub(crate) safe fn SSL_get_privatekey(ssl: &SSL) -> *mut EVP_PKEY; // ── SSL_SESSION ─────────────────────────────────────────────────── pub(crate) safe fn SSL_get_session(ssl: &SSL) -> *mut SSL_SESSION; @@ -129,7 +136,8 @@ pub(super) mod ffi { pub(crate) safe fn SSL_get_current_cipher(ssl: &SSL) -> *const SSL_CIPHER; pub(crate) safe fn SSL_CIPHER_get_name(cipher: &SSL_CIPHER) -> *const c_char; pub(crate) safe fn SSL_CIPHER_standard_name(cipher: &SSL_CIPHER) -> *const c_char; - pub(crate) safe fn SSL_CIPHER_get_version(cipher: &SSL_CIPHER) -> *const c_char; + pub(crate) safe fn SSL_CIPHER_get_min_version(cipher: &SSL_CIPHER) -> u16; + pub(crate) safe fn SSL_CIPHER_get_kx_nid(cipher: &SSL_CIPHER) -> c_int; // ── X509 ───────────────────────────────────────────────────────── pub(crate) safe fn X509_up_ref(x: &X509) -> c_int; @@ -145,17 +153,6 @@ pub(super) mod ffi { #[link_name = "sk_value"] pub(crate) safe fn sk_X509_value(sk: &struct_stack_st_X509, i: usize) -> *mut X509; - // ── EVP / EC ────────────────────────────────────────────────────── - pub(crate) safe fn EVP_PKEY_id(pkey: &EVP_PKEY) -> c_int; - pub(crate) safe fn EVP_PKEY_bits(pkey: &EVP_PKEY) -> c_int; - // Returns a +1 `EC_KEY*` (caller owns; the sole call site - // intentionally leaks it). The only pointer arg is an - // opaque-ZST `&EVP_PKEY`, so the call itself has no precondition. - pub(crate) safe fn EVP_PKEY_get1_EC_KEY(pkey: &EVP_PKEY) -> *mut EC_KEY; - // Result is borrowed from `key`; opaque-ZST ref ⇒ no caller precondition. - pub(crate) safe fn EC_KEY_get0_group(key: &EC_KEY) -> *const EC_GROUP; - pub(crate) safe fn EC_GROUP_get_curve_name(group: &EC_GROUP) -> c_int; - // ── OBJ ────────────────────────────────────────────────────────── // Pure NID→short-name lookup; takes a by-value int and returns a // pointer into BoringSSL's static OID table (or null). No pointer @@ -814,14 +811,22 @@ pub(super) fn get_cipher( ); } - let version = ffi::SSL_CIPHER_get_version(cipher); - if version.is_null() { - result.put(global, b"version", JSValue::NULL); - } else { - // SAFETY: SSL_CIPHER_get_version returns a static NUL-terminated C string. - let s = unsafe { bun_core::ffi::cstr(version) }.to_bytes(); - result.put(global, b"version", ZigString::from_utf8(s).to_js(global)); - } + // BoringSSL's `SSL_CIPHER_get_version` is hardcoded to "TLSv1/SSLv3". + // Node reports the cipher's minimum protocol version (OpenSSL's cipher + // table); rebuild the same strings from `SSL_CIPHER_get_min_version`. + // For the pre-1.2 suites OpenSSL reports "TLSv1.0" for the ECC ones + // (RFC 4492 defined them for TLS 1.0) and "SSLv3" for the rest. + let version: &[u8] = match ffi::SSL_CIPHER_get_min_version(cipher) { + ffi::TLS1_3_VERSION => b"TLSv1.3", + ffi::TLS1_2_VERSION => b"TLSv1.2", + _ if ffi::SSL_CIPHER_get_kx_nid(cipher) == ffi::NID_kx_ecdhe => b"TLSv1.0", + _ => b"SSLv3", + }; + result.put( + global, + b"version", + ZigString::from_utf8(version).to_js(global), + ); Ok(result) } @@ -1021,64 +1026,38 @@ pub(super) fn get_ephemeral_key_info( let Some(ssl_ptr) = this.socket.get().ssl() else { return Ok(JSValue::NULL); }; + let ssl = boringssl::SSL::opaque_ref(ssl_ptr); let result = JSValue::create_empty_object(global, 0); - // TODO: investigate better option or compatible way to get the key - // this implementation follows nodejs but for BoringSSL SSL_get_server_tmp_key will always return 0 - // wich will result in a empty object - // let mut raw_key: *mut boringssl::EVP_PKEY = core::ptr::null_mut(); - // if unsafe { boringssl::SSL_get_server_tmp_key(ssl_ptr, &mut raw_key) } == 0 { - // return Ok(result); - // } - let raw_key: *mut ffi::EVP_PKEY = ffi::SSL_get_privatekey(boringssl::SSL::opaque_ref(ssl_ptr)); - if raw_key.is_null() { + // BoringSSL has no `SSL_get_peer_tmp_key`, but the negotiated named group + // carries the same information for a TLS <= 1.2 (EC)DHE key exchange. + // Node returns {} on TLS 1.3 (its `SSL_get_peer_tmp_key` only surfaces the + // ServerKeyExchange key) and on a non-forward-secret (RSA) key exchange, + // where `SSL_get_negotiated_group` returns `NID_undef`; match both. + if ffi::SSL_version(ssl) >= i32::from(ffi::TLS1_3_VERSION) { return Ok(result); } - let pkey = ffi::EVP_PKEY::opaque_ref(raw_key); - - let kid = ffi::EVP_PKEY_id(pkey); - let bits = ffi::EVP_PKEY_bits(pkey); - - match kid { - ffi::EVP_PKEY_DH => { - result.put(global, b"type", BunString::static_("DH").to_js(global)?); - result.put(global, b"size", JSValue::js_number(f64::from(bits))); - } - ffi::EVP_PKEY_EC | ffi::EVP_PKEY_X25519 | ffi::EVP_PKEY_X448 => { - let curve_name: &[u8]; - if kid == ffi::EVP_PKEY_EC { - // `pkey` is non-null (guarded above) and `kid == EVP_PKEY_EC`, so - // BoringSSL guarantees a non-null EC_KEY with a group set; the - // `opaque_ref` chain panics (not UB) if that invariant ever broke. - let ec = ffi::EVP_PKEY_get1_EC_KEY(pkey); - let group = ffi::EC_KEY_get0_group(ffi::EC_KEY::opaque_ref(ec)); - let nid = ffi::EC_GROUP_get_curve_name(ffi::EC_GROUP::opaque_ref(group)); - let nid_str = ffi::OBJ_nid2sn(nid); - if !nid_str.is_null() { - // SAFETY: OBJ_nid2sn returns a static NUL-terminated C string. - curve_name = unsafe { bun_core::ffi::cstr(nid_str) }.to_bytes(); - } else { - curve_name = b""; - } - } else { - let kid_str = ffi::OBJ_nid2sn(kid); - if !kid_str.is_null() { - // SAFETY: OBJ_nid2sn returns a static NUL-terminated C string. - curve_name = unsafe { bun_core::ffi::cstr(kid_str) }.to_bytes(); - } else { - curve_name = b""; - } - } - result.put(global, b"type", BunString::static_("ECDH").to_js(global)?); - result.put( - global, - b"name", - ZigString::from_utf8(curve_name).to_js(global), - ); - result.put(global, b"size", JSValue::js_number(f64::from(bits))); - } - _ => {} + let nid = ffi::SSL_get_negotiated_group(ssl); + // `size` mirrors Node's `EVP_PKEY_bits` of the peer's ephemeral key: the + // field size for the NIST curves and the 253-bit X25519 group order. + let bits: i32 = match nid { + ffi::NID_X25519 => 253, + ffi::NID_X9_62_prime256v1 => 256, + ffi::NID_secp384r1 => 384, + ffi::NID_secp521r1 => 521, + _ => return Ok(result), + }; + let sn = ffi::OBJ_nid2sn(nid); + if sn.is_null() { + return Ok(result); } + // SAFETY: OBJ_nid2sn returns a static NUL-terminated C string. + let name = unsafe { bun_core::ffi::cstr(sn) }.to_bytes(); + // BoringSSL only offers ECDHE groups for TLS <= 1.2 (no DHE cipher suites), + // so every reachable group here is an ECDH exchange. + result.put(global, b"type", BunString::static_("ECDH").to_js(global)?); + result.put(global, b"name", ZigString::from_utf8(name).to_js(global)); + result.put(global, b"size", JSValue::js_number(f64::from(bits))); Ok(result) } diff --git a/test/js/node/test/common/boringssl.js b/test/js/node/test/common/boringssl.js index 46e0738d596b..5a9be717ebca 100644 --- a/test/js/node/test/common/boringssl.js +++ b/test/js/node/test/common/boringssl.js @@ -137,17 +137,17 @@ function testRenegotiationUnsupported() { } /** - * OpenSSL exposes the negotiated ephemeral key type, name, and size for TLS - * clients. With BoringSSL the same ECDHE TLS 1.2 handshake succeeds, but - * getEphemeralKeyInfo() returns null on the server side and an object whose - * fields are undefined on the client side. + * BoringSSL has no DHE cipher suites and Bun does not plumb the `ecdhCurve` + * option, so the original test's finite-field DH cases and per-curve + * selections cannot run. The ECDHE case it keeps: getEphemeralKeyInfo() + * reports the negotiated group (BoringSSL prefers X25519) on the client and + * null on the server, like Node. */ -function testEphemeralKeyInfoUnsupported() { +function testEphemeralKeyInfoEcdheOnly() { const server = tls.createServer({ key: fixtures.readKey('agent2-key.pem'), cert: fixtures.readKey('agent2-cert.pem'), ciphers: 'ECDHE-RSA-AES256-GCM-SHA384', - ecdhCurve: 'prime256v1', maxVersion: 'TLSv1.2', }, common.mustCall((socket) => { assert.strictEqual(socket.getEphemeralKeyInfo(), null); @@ -161,9 +161,9 @@ function testEphemeralKeyInfoUnsupported() { maxVersion: 'TLSv1.2', }, common.mustCall(() => { assert.deepStrictEqual(client.getEphemeralKeyInfo(), { - type: undefined, - name: undefined, - size: undefined, + type: 'ECDH', + name: 'X25519', + size: 253, }); server.close(); })); @@ -337,7 +337,7 @@ module.exports = { assertMultiKeyUnsupported, assertNoCipherMatch, assertOpenSSLSecurityLevelsUnsupported, - testEphemeralKeyInfoUnsupported, + testEphemeralKeyInfoEcdheOnly, testLegacyProtocolUnsupported, testMultiPfxSelectionDifference, testPskTls13Unsupported, diff --git a/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js index 0584e4d11e40..9d9266fdb1a3 100644 --- a/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js +++ b/test/js/node/test/parallel/test-tls-client-getephemeralkeyinfo.js @@ -4,7 +4,7 @@ if (!common.hasCrypto) common.skip('missing crypto'); if (process.features.openssl_is_boringssl) { - require('../common/boringssl').testEphemeralKeyInfoUnsupported(); + require('../common/boringssl').testEphemeralKeyInfoEcdheOnly(); return; } diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 096299fbcc22..d189218bbd34 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "bun:test"; import { once } from "events"; -import { bunEnv, bunExe, tls as COMMON_CERT_, isASAN } from "harness"; +import { bunEnv, bunExe, tls as COMMON_CERT_, isASAN, tempDir } from "harness"; import https from "https"; import net from "net"; import { join } from "path"; @@ -339,17 +339,17 @@ for (const { name, connect } of tests) { { name: "TLS_AES_128_GCM_SHA256", standardName: "TLS_AES_128_GCM_SHA256", - version: "TLSv1/SSLv3", + version: "TLSv1.3", }, { name: "TLS_AES_256_GCM_SHA384", standardName: "TLS_AES_256_GCM_SHA384", - version: "TLSv1/SSLv3", + version: "TLSv1.3", }, { name: "TLS_CHACHA20_POLY1305_SHA256", standardName: "TLS_CHACHA20_POLY1305_SHA256", - version: "TLSv1/SSLv3", + version: "TLSv1.3", }, ]; const socket = (await new Promise((resolve, reject) => { @@ -747,3 +747,215 @@ it("https.request reports an impossible version window as a TLS error, not a cer await once(response, "end"); expect(body).toBe("ok"); }); + +/** + * Start a TLS server over the harness cert, connect a client with + * `clientOptions`, await both handshakes, and hand the two TLSSockets to + * `fn`. Cleanup runs whether or not `fn`'s assertions pass. + */ +async function withTlsPair( + clientOptions: tls.ConnectionOptions, + fn: (client: TLSSocket, serverSide: TLSSocket) => void | Promise, + serverOptions: tls.TlsOptions = {}, +) { + const serverSocket = Promise.withResolvers(); + const server = tls.createServer({ ...COMMON_CERT_, ...serverOptions }, socket => { + socket.on("error", () => {}); + serverSocket.resolve(socket); + }); + server.on("tlsClientError", err => serverSocket.reject(err)); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const client = tlsConnect({ + port, + host: "127.0.0.1", + ca: COMMON_CERT_.cert, + servername: "localhost", + ...clientOptions, + }); + try { + const [, serverSide] = await Promise.all([once(client, "secureConnect"), serverSocket.promise]); + await fn(client, serverSide); + } finally { + client.destroy(); + server.close(); + await once(server, "close"); + } +} + +it("getCipher().version reports the cipher's protocol version, matching Node", async () => { + // Every TLS 1.3 suite reports "TLSv1.3"; the exact AEAD depends on AES-NI, + // so only `version` is pinned for this case. + await withTlsPair({ minVersion: "TLSv1.3" }, client => { + expect(client.getProtocol()).toBe("TLSv1.3"); + expect(client.getCipher().version).toBe("TLSv1.3"); + }); + // A SHA-2 AEAD suite is first defined for TLS 1.2, so Node reports + // "TLSv1.2" for it regardless of the negotiated protocol. + await withTlsPair({ maxVersion: "TLSv1.2", ciphers: "ECDHE-RSA-AES128-GCM-SHA256" }, (client, serverSide) => { + const expected = { + name: "ECDHE-RSA-AES128-GCM-SHA256", + standardName: "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256", + version: "TLSv1.2", + }; + expect(client.getProtocol()).toBe("TLSv1.2"); + expect(client.getCipher()).toEqual(expected); + expect(serverSide.getCipher()).toEqual(expected); + }); +}); + +it("getCipher().version separates the pre-1.2 ECC suites (TLSv1.0) from the SSLv3-era ones", async () => { + // OpenSSL reports each cipher's minimum protocol: the ECC suites were + // defined for TLS 1.0 (RFC 4492) and everything older is SSLv3. + for (const [ciphers, version] of [ + ["ECDHE-RSA-AES128-SHA", "TLSv1.0"], + ["AES128-SHA", "SSLv3"], + ] as const) { + await withTlsPair( + { maxVersion: "TLSv1.2", ciphers }, + client => { + expect(client.getProtocol()).toBe("TLSv1.2"); + expect(client.getCipher().name).toBe(ciphers); + expect(client.getCipher().version).toBe(version); + }, + { ciphers }, + ); + } +}); + +it("getEphemeralKeyInfo() reports the negotiated TLS 1.2 ECDHE group, matching Node", async () => { + // Node reports OBJ_nid2sn + EVP_PKEY_bits of the peer's ephemeral key. + // BoringSSL prefers X25519 on both sides of a TLS <= 1.2 key exchange. + await withTlsPair({ maxVersion: "TLSv1.2" }, (client, serverSide) => { + expect(client.getProtocol()).toBe("TLSv1.2"); + expect(client.getEphemeralKeyInfo()).toEqual({ type: "ECDH", name: "X25519", size: 253 }); + // Node reports null on the server side of the connection. + expect(serverSide.getEphemeralKeyInfo()).toBeNull(); + }); + // A static-RSA key exchange has no ephemeral key: {}. + await withTlsPair({ maxVersion: "TLSv1.2", ciphers: "AES128-SHA" }, client => { + expect(client.getEphemeralKeyInfo()).toEqual({}); + }); + // Node reports {} on TLS 1.3 (its SSL_get_peer_tmp_key only surfaces the + // <= 1.2 ServerKeyExchange key); match it. + await withTlsPair({ minVersion: "TLSv1.3" }, client => { + expect(client.getProtocol()).toBe("TLSv1.3"); + expect(client.getEphemeralKeyInfo()).toEqual({}); + }); +}); + +it("new tls.TLSSocket(socket, { isServer: false }) + _start() runs the handshake over the wrapped socket", async () => { + // The client STARTTLS pattern: wrap an already-connected plaintext socket, + // then start the handshake with the internal _start() entry point. This + // used to throw ERR_MISSING_ARGS because connect() got no port, path, or + // socket. + const server = tls.createServer({ ...COMMON_CERT_ }, socket => { + socket.on("error", () => {}); + socket.on("data", () => socket.end()); + socket.write("hi"); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const secure: any = new TLSSocket(raw, { + isServer: false, + ca: COMMON_CERT_.cert, + servername: "localhost", + } as tls.TLSSocketOptions); + try { + secure._start(); + await once(secure, "secureConnect"); + expect(secure.authorized).toBe(true); + expect(secure.getProtocol()).toBe("TLSv1.3"); + // The upgraded stream carries application data both ways. + const [chunk] = await once(secure, "data"); + expect(chunk.toString()).toBe("hi"); + secure.write("bye"); + await once(secure, "close"); + } finally { + secure.destroy(); + server.close(); + await once(server, "close"); + } +}); + +it("new tls.TLSSocket(socket, { isServer: false, rejectUnauthorized: false }) completes against an untrusted peer", async () => { + // The bare-TLSSocket path never reaches Socket.prototype.connect's + // rejectUnauthorized handling, so the constructor has to honor its own + // option or a self-signed peer would always be destroyed. + const server = tls.createServer({ ...COMMON_CERT_ }, socket => { + socket.on("error", () => {}); + socket.end(); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const secure: any = new TLSSocket(raw, { isServer: false, rejectUnauthorized: false } as tls.TLSSocketOptions); + try { + secure._start(); + await once(secure, "secureConnect"); + // Verification still ran and reported; it just did not tear the socket down. + expect(secure.authorized).toBe(false); + expect(secure.authorizationError).toBe("DEPTH_ZERO_SELF_SIGNED_CERT"); + } finally { + secure.destroy(); + server.close(); + await once(server, "close"); + } +}); + +it("an exception from a user checkServerIdentity escapes as an uncaught exception, not a socket 'error'", async () => { + // Node does not guard the callback (onConnectSecure in lib/_tls_wrap.js): + // a throw aborts the handshake and reaches the process as an uncaught + // exception. It must not be downgraded into the socket's 'error' routing, + // and 'secureConnect' must not fire. + using dir = tempDir("tls-csi-throw", { + "cert.pem": COMMON_CERT_.cert, + "key.pem": COMMON_CERT_.key, + "fixture.ts": ` + import tls from "node:tls"; + import { readFileSync } from "node:fs"; + const cert = readFileSync("cert.pem", "utf8"); + const key = readFileSync("key.pem", "utf8"); + process.on("uncaughtException", err => { + console.log("UNCAUGHT " + err.message); + process.exit(42); + }); + const server = tls.createServer({ key, cert }, s => s.end()); + server.listen(0, "127.0.0.1", () => { + const client = tls.connect({ + port: server.address().port, + host: "127.0.0.1", + ca: cert, + servername: "localhost", + checkServerIdentity() { + throw new Error("csi-boom"); + }, + }); + client.on("secureConnect", () => { + console.log("SECURE_CONNECT"); + process.exit(5); + }); + client.on("error", e => { + console.log("SOCKET_ERROR " + e.message); + process.exit(3); + }); + }); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "fixture.ts"], + env: bunEnv, + cwd: String(dir), + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "UNCAUGHT csi-boom", exitCode: 42 }); +}); From 1faed51e3bbd097d2c5ef3ee905a163ea73ba316 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 27 Jun 2026 05:40:18 +0000 Subject: [PATCH 2/6] node:tls: pin getEphemeralKeyInfo's exact key set and the fixture's stderr in tests Node's getEphemeralKeyInfo() always returns the same three own keys on a client, with undefined values when no ephemeral key applies; toEqual ignored undefined-valued keys and the comments wrongly described the result as a bare {}. Use toStrictEqual against the exact shape, and include the spawned fixture's stderr in the asserted object so an unexpected crash trace shows up in the failure diff. --- src/runtime/socket/tls_socket_functions.rs | 8 +++++--- test/js/node/tls/node-tls-connect.test.ts | 24 +++++++++++++++------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index d81656cc01b9..f2c4bc4326dd 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -1031,9 +1031,11 @@ pub(super) fn get_ephemeral_key_info( // BoringSSL has no `SSL_get_peer_tmp_key`, but the negotiated named group // carries the same information for a TLS <= 1.2 (EC)DHE key exchange. - // Node returns {} on TLS 1.3 (its `SSL_get_peer_tmp_key` only surfaces the - // ServerKeyExchange key) and on a non-forward-secret (RSA) key exchange, - // where `SSL_get_negotiated_group` returns `NID_undef`; match both. + // Node reports no ephemeral key on TLS 1.3 (its `SSL_get_peer_tmp_key` + // only surfaces the ServerKeyExchange key) and on a non-forward-secret + // (RSA) key exchange, where `SSL_get_negotiated_group` is `NID_undef`; + // match both. The tls.ts wrapper shapes the empty result into Node's + // fixed {type, name, size} key set. if ffi::SSL_version(ssl) >= i32::from(ffi::TLS1_3_VERSION) { return Ok(result); } diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index d189218bbd34..d736741f6de3 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -824,24 +824,30 @@ it("getCipher().version separates the pre-1.2 ECC suites (TLSv1.0) from the SSLv } }); +// Node's getEphemeralKeyInfo() always returns the same three own keys on a +// client, with undefined values when no ephemeral key applies (verified on +// node v26.3.0: Object.keys(...) === ["type","name","size"] for TLS 1.3 and +// for a static-RSA exchange). toStrictEqual pins that exact key set. +const NO_EPHEMERAL_KEY = { type: undefined, name: undefined, size: undefined }; + it("getEphemeralKeyInfo() reports the negotiated TLS 1.2 ECDHE group, matching Node", async () => { // Node reports OBJ_nid2sn + EVP_PKEY_bits of the peer's ephemeral key. // BoringSSL prefers X25519 on both sides of a TLS <= 1.2 key exchange. await withTlsPair({ maxVersion: "TLSv1.2" }, (client, serverSide) => { expect(client.getProtocol()).toBe("TLSv1.2"); - expect(client.getEphemeralKeyInfo()).toEqual({ type: "ECDH", name: "X25519", size: 253 }); + expect(client.getEphemeralKeyInfo()).toStrictEqual({ type: "ECDH", name: "X25519", size: 253 }); // Node reports null on the server side of the connection. expect(serverSide.getEphemeralKeyInfo()).toBeNull(); }); - // A static-RSA key exchange has no ephemeral key: {}. + // A static-RSA key exchange has no ephemeral key. await withTlsPair({ maxVersion: "TLSv1.2", ciphers: "AES128-SHA" }, client => { - expect(client.getEphemeralKeyInfo()).toEqual({}); + expect(client.getEphemeralKeyInfo()).toStrictEqual(NO_EPHEMERAL_KEY); }); - // Node reports {} on TLS 1.3 (its SSL_get_peer_tmp_key only surfaces the - // <= 1.2 ServerKeyExchange key); match it. + // Node reports no key on TLS 1.3: its SSL_get_peer_tmp_key only surfaces + // the <= 1.2 ServerKeyExchange key. await withTlsPair({ minVersion: "TLSv1.3" }, client => { expect(client.getProtocol()).toBe("TLSv1.3"); - expect(client.getEphemeralKeyInfo()).toEqual({}); + expect(client.getEphemeralKeyInfo()).toStrictEqual(NO_EPHEMERAL_KEY); }); }); @@ -957,5 +963,9 @@ it("an exception from a user checkServerIdentity escapes as an uncaught exceptio stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout: stdout.trim(), exitCode }).toEqual({ stdout: "UNCAUGHT csi-boom", exitCode: 42 }); + expect({ stdout: stdout.trim(), stderr, exitCode }).toEqual({ + stdout: "UNCAUGHT csi-boom", + stderr: "", + exitCode: 42, + }); }); From 302b9350c2d9291ebe80e6a13059a9dc270f9373 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:43:34 +0000 Subject: [PATCH 3/6] node:tls: getEphemeralKeyInfo reports no key on a resumed session A resumed TLS 1.2 abbreviated handshake has no ServerKeyExchange, so Node's SSL_get_peer_tmp_key is empty and getEphemeralKeyInfo returns the three undefined keys. BoringSSL serializes the original handshake's group_id into the session blob, so SSL_get_negotiated_group would surface it on the resumed connection; gate it on SSL_session_reused. --- src/runtime/socket/tls_socket_functions.rs | 15 ++++--- test/js/node/tls/node-tls-connect.test.ts | 50 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/runtime/socket/tls_socket_functions.rs b/src/runtime/socket/tls_socket_functions.rs index f2c4bc4326dd..2b83b825bc9c 100644 --- a/src/runtime/socket/tls_socket_functions.rs +++ b/src/runtime/socket/tls_socket_functions.rs @@ -1031,12 +1031,15 @@ pub(super) fn get_ephemeral_key_info( // BoringSSL has no `SSL_get_peer_tmp_key`, but the negotiated named group // carries the same information for a TLS <= 1.2 (EC)DHE key exchange. - // Node reports no ephemeral key on TLS 1.3 (its `SSL_get_peer_tmp_key` - // only surfaces the ServerKeyExchange key) and on a non-forward-secret - // (RSA) key exchange, where `SSL_get_negotiated_group` is `NID_undef`; - // match both. The tls.ts wrapper shapes the empty result into Node's - // fixed {type, name, size} key set. - if ffi::SSL_version(ssl) >= i32::from(ffi::TLS1_3_VERSION) { + // Node reports no ephemeral key on TLS 1.3 or a resumed session (its + // `SSL_get_peer_tmp_key` only surfaces the ServerKeyExchange key) and on + // a non-forward-secret (RSA) key exchange, where + // `SSL_get_negotiated_group` is `NID_undef`; match all three. The tls.ts + // wrapper shapes the empty result into Node's fixed {type, name, size} + // key set. BoringSSL serializes `group_id` into the session blob, so a + // resumed session must be checked separately. + if ffi::SSL_version(ssl) >= i32::from(ffi::TLS1_3_VERSION) || ffi::SSL_session_reused(ssl) != 0 + { return Ok(result); } let nid = ffi::SSL_get_negotiated_group(ssl); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index d736741f6de3..1413869385c3 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -851,6 +851,56 @@ it("getEphemeralKeyInfo() reports the negotiated TLS 1.2 ECDHE group, matching N }); }); +it("getEphemeralKeyInfo() reports no key on a resumed TLS 1.2 session, matching Node", async () => { + // A resumed abbreviated handshake has no ServerKeyExchange, so Node's + // SSL_get_peer_tmp_key is empty. BoringSSL serializes the original + // handshake's group_id into the session blob, so without a resumption + // check SSL_get_negotiated_group would surface it here. + const server = tls.createServer({ ...COMMON_CERT_, maxVersion: "TLSv1.2" }, socket => { + socket.write("x"); + socket.on("error", () => {}); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + const base = { + port, + host: "127.0.0.1", + ca: COMMON_CERT_.cert, + servername: "localhost", + maxVersion: "TLSv1.2", + } as const; + + async function connect(extra: tls.ConnectionOptions) { + const client = tlsConnect({ ...base, ...extra }); + await once(client, "secureConnect"); + await once(client, "data"); + return client; + } + + try { + const full = await connect({}); + try { + expect(full.isSessionReused()).toBe(false); + expect(full.getEphemeralKeyInfo()).toStrictEqual({ type: "ECDH", name: "X25519", size: 253 }); + var session = full.getSession(); + } finally { + full.destroy(); + } + + const resumed = await connect({ session }); + try { + expect(resumed.isSessionReused()).toBe(true); + expect(resumed.getEphemeralKeyInfo()).toStrictEqual(NO_EPHEMERAL_KEY); + } finally { + resumed.destroy(); + } + } finally { + server.close(); + await once(server, "close"); + } +}); + it("new tls.TLSSocket(socket, { isServer: false }) + _start() runs the handshake over the wrapped socket", async () => { // The client STARTTLS pattern: wrap an already-connected plaintext socket, // then start the handshake with the internal _start() entry point. This From c3fcd1ad4a60e6ee3092fb804f2f59cc1075dc40 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:58:38 +0000 Subject: [PATCH 4/6] node:tls: only an explicit false for rejectUnauthorized disables verification Node's CVE-2021-22939 fix checks `rejectUnauthorized !== false`: every other value, including null, keeps certificate verification on. Bun's client connect paths stored the raw option value and then checked it for truthiness, so rejectUnauthorized: null silently bypassed verification where Node rejects. Normalize to a real boolean at each store site (the TLSSocket constructor, Server setSecureContext, and the three Socket.prototype.connect TLS paths). The bindgen layer already rejects 0/""/1 with ERR_INVALID_ARG_TYPE, so null was the only non-boolean that reached these sites. Also: declare `session` with let before the try in the resumption test instead of hoisting with var. --- src/js/node/net.ts | 13 +++--- src/js/node/tls.ts | 6 ++- test/js/node/tls/node-tls-connect.test.ts | 52 ++++++++++++++++++++++- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 766f803e3efe..923cb1beb6dd 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -1666,8 +1666,9 @@ Socket.prototype.connect = function connect(...args) { this._requestCert = true; if (tls) { if (typeof rejectUnauthorized !== "undefined") { - this._rejectUnauthorized = rejectUnauthorized; - tls.rejectUnauthorized = rejectUnauthorized; + // Only an explicit `false` disables verification (CVE-2021-22939). + this._rejectUnauthorized = rejectUnauthorized !== false; + tls.rejectUnauthorized = this._rejectUnauthorized; } else { this._rejectUnauthorized = tls.rejectUnauthorized; } @@ -2718,8 +2719,8 @@ function internalConnect(self, options, address, port, addressType, localAddress if (tls) { const { rejectUnauthorized, session, checkServerIdentity } = options; if (typeof rejectUnauthorized !== "undefined") { - self._rejectUnauthorized = rejectUnauthorized; - tls.rejectUnauthorized = rejectUnauthorized; + self._rejectUnauthorized = rejectUnauthorized !== false; + tls.rejectUnauthorized = self._rejectUnauthorized; } else { self._rejectUnauthorized = tls.rejectUnauthorized; } @@ -2871,8 +2872,8 @@ function internalConnectMultiple(context, canceled?) { if (tls) { const { rejectUnauthorized, session, checkServerIdentity } = context.options; if (typeof rejectUnauthorized !== "undefined") { - self._rejectUnauthorized = rejectUnauthorized; - tls.rejectUnauthorized = rejectUnauthorized; + self._rejectUnauthorized = rejectUnauthorized !== false; + tls.rejectUnauthorized = self._rejectUnauthorized; } else { self._rejectUnauthorized = tls.rejectUnauthorized; } diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index bb8596731521..54597da30ab5 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -940,8 +940,10 @@ function TLSSocket(socket?, options?) { // Honor the constructor's own `rejectUnauthorized`. tls.connect() also // routes it through Socket.prototype.connect's options, but the bare // `new TLSSocket(socket, options)` + `_start()` path never gets there. + // Only an explicit `false` disables verification (Node's CVE-2021-22939 + // fix): every other value, including `null`, keeps it on. const rejectUnauthorized = options.rejectUnauthorized; - if (rejectUnauthorized !== undefined) this._rejectUnauthorized = rejectUnauthorized; + if (rejectUnauthorized !== undefined) this._rejectUnauthorized = rejectUnauthorized !== false; // `new tls.TLSSocket(socket, { isServer: true })`: drive the server-side TLS // handshake over the provided socket via net.ts's native upgrade path (reaches @@ -1402,7 +1404,7 @@ function Server(options, secureConnectionListener): void { const rejectUnauthorized = options.rejectUnauthorized; if (typeof rejectUnauthorized !== "undefined") { - this._rejectUnauthorized = rejectUnauthorized; + this._rejectUnauthorized = rejectUnauthorized !== false; } else this._rejectUnauthorized = rejectUnauthorizedDefault(); const ciphers = options.ciphers; diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 1413869385c3..2b97088852fb 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -878,12 +878,13 @@ it("getEphemeralKeyInfo() reports no key on a resumed TLS 1.2 session, matching return client; } + let session: Buffer | undefined; try { const full = await connect({}); try { expect(full.isSessionReused()).toBe(false); expect(full.getEphemeralKeyInfo()).toStrictEqual({ type: "ECDH", name: "X25519", size: 253 }); - var session = full.getSession(); + session = full.getSession(); } finally { full.destroy(); } @@ -967,6 +968,55 @@ it("new tls.TLSSocket(socket, { isServer: false, rejectUnauthorized: false }) co } }); +it("rejectUnauthorized: null keeps certificate verification on, matching Node (CVE-2021-22939)", async () => { + // Node only disables verification on an explicit `false`. Every other value + // (including `null`, which used to reach the handshake's truthiness check + // as-is and silently skip rejection) keeps it on. + const server = tls.createServer({ ...COMMON_CERT_ }, socket => { + socket.on("error", () => {}); + socket.end(); + }); + server.on("tlsClientError", () => {}); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + function outcome(client: TLSSocket) { + return new Promise(resolve => { + client.once("secureConnect", () => resolve("connected")); + client.once("error", e => resolve((e as NodeJS.ErrnoException).code ?? String(e))); + }).finally(() => client.destroy()); + } + + try { + // tls.connect path. + { + const client = tlsConnect({ port, host: "127.0.0.1", servername: "localhost", rejectUnauthorized: null as any }); + expect(await outcome(client)).toBe("DEPTH_ZERO_SELF_SIGNED_CERT"); + } + // new tls.TLSSocket(socket, { isServer: false }) + _start() path. + { + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const secure: any = new TLSSocket(raw, { + isServer: false, + servername: "localhost", + rejectUnauthorized: null as any, + } as tls.TLSSocketOptions); + secure._start(); + expect(await outcome(secure)).toBe("DEPTH_ZERO_SELF_SIGNED_CERT"); + } + // An explicit `false` still disables it. + { + const client = tlsConnect({ port, host: "127.0.0.1", servername: "localhost", rejectUnauthorized: false }); + expect(await outcome(client)).toBe("connected"); + } + } finally { + server.close(); + await once(server, "close"); + } +}); + it("an exception from a user checkServerIdentity escapes as an uncaught exception, not a socket 'error'", async () => { // Node does not guard the callback (onConnectSecure in lib/_tls_wrap.js): // a throw aborts the handshake and reaches the process as an uncaught From 74904474bc961d995394619c56ee0e8f4f6ad702 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:17:32 +0000 Subject: [PATCH 5/6] node:tls: preserve setServername() across _start() on a wrapped client socket Socket.prototype.connect unconditionally overwrites this.servername from its options object, so a setServername() call between `new TLSSocket(socket, { isServer: false })` and _start() was wiped and no SNI was sent. Node preserves the value and sends it. Forward the current servername in the connect() options so the handshake carries it. --- src/js/node/tls.ts | 4 ++- test/js/node/tls/node-tls-connect.test.ts | 30 +++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/js/node/tls.ts b/src/js/node/tls.ts index 54597da30ab5..438ac149e0e5 100644 --- a/src/js/node/tls.ts +++ b/src/js/node/tls.ts @@ -973,7 +973,9 @@ TLSSocket.prototype._start = function _start() { // port, path, or socket and throws ERR_MISSING_ARGS. const wrapped = this._handle; if (!this.isServer && wrapped instanceof Duplex) { - this.connect({ socket: wrapped }); + // Preserve any SNI set via setServername() before _start(): connect() + // would otherwise overwrite this.servername from its options object. + this.connect({ socket: wrapped, servername: this.servername }); return; } this.connect(); diff --git a/test/js/node/tls/node-tls-connect.test.ts b/test/js/node/tls/node-tls-connect.test.ts index 2b97088852fb..bb16e5b84d0f 100644 --- a/test/js/node/tls/node-tls-connect.test.ts +++ b/test/js/node/tls/node-tls-connect.test.ts @@ -968,6 +968,36 @@ it("new tls.TLSSocket(socket, { isServer: false, rejectUnauthorized: false }) co } }); +it("setServername() before _start() is sent as the client SNI, matching Node", async () => { + // Socket.prototype.connect overwrites this.servername from its options; + // _start() has to forward the value set between construction and start so + // the SNI reaches the handshake. + const observed = Promise.withResolvers(); + const server = tls.createServer({ ...COMMON_CERT_ }); + server.on("secureConnection", socket => { + observed.resolve(socket.servername); + socket.end(); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + const raw = net.connect(port, "127.0.0.1"); + await once(raw, "connect"); + const secure: any = new TLSSocket(raw, { isServer: false, rejectUnauthorized: false } as tls.TLSSocketOptions); + try { + secure.setServername("example.test"); + secure._start(); + await once(secure, "secureConnect"); + expect(secure.servername).toBe("example.test"); + expect(await observed.promise).toBe("example.test"); + } finally { + secure.destroy(); + server.close(); + await once(server, "close"); + } +}); + it("rejectUnauthorized: null keeps certificate verification on, matching Node (CVE-2021-22939)", async () => { // Node only disables verification on an explicit `false`. Every other value // (including `null`, which used to reach the handshake's truthiness check From 1c65194cdb3dac0ead48adaaffca1bb69a643710 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:44:26 +0000 Subject: [PATCH 6/6] node:tls: skip checkServerIdentity on a resumed session Node's onConnectSecure guards the user callback with `!this.isSessionReused()`: the identity was verified on the original full handshake, so a resumed abbreviated handshake skips it. Bun called it every time, so a side-effecting or throwing callback diverged (and the new nextTick rethrow made a throw on the resumed call fatal). Add the same guard to both handshake handlers. --- src/js/node/net.ts | 8 +++- test/js/node/tls/node-tls-connect.test.ts | 52 +++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 923cb1beb6dd..dadbbb99072b 100644 --- a/src/js/node/net.ts +++ b/src/js/node/net.ts @@ -442,7 +442,9 @@ const SocketHandlers: SocketHandler = { self.emit("secure", self); self.alpnProtocol = socket.alpnProtocol; const { checkServerIdentity } = self[bunTLSConnectOptions]; - if (!verifyError && typeof checkServerIdentity === "function") { + // Node skips the identity check on a resumed session: it was verified on + // the original full handshake (onConnectSecure in lib/_tls_wrap.js). + if (!verifyError && typeof checkServerIdentity === "function" && !self.isSessionReused()) { verifyError = runCheckServerIdentity(self, checkServerIdentity); if (verifyError === kCheckServerIdentityThrew) return; } @@ -1175,7 +1177,9 @@ const SocketHandlers2: SocketHandler { + // Node's onConnectSecure guards the callback with `!this.isSessionReused()`: + // the identity was verified on the original full handshake. + const server = tls.createServer({ ...COMMON_CERT_, maxVersion: "TLSv1.2" }, socket => { + socket.write("x"); + socket.on("error", () => {}); + }); + server.listen(0); + await once(server, "listening"); + const port = (server.address() as AddressInfo).port; + + let calls = 0; + const base = { + port, + host: "127.0.0.1", + ca: COMMON_CERT_.cert, + servername: "localhost", + maxVersion: "TLSv1.2", + checkServerIdentity: () => void calls++, + } as const; + + async function connect(extra: tls.ConnectionOptions) { + const client = tlsConnect({ ...base, ...extra }); + await once(client, "secureConnect"); + await once(client, "data"); + return client; + } + + let session: Buffer | undefined; + try { + const full = await connect({}); + try { + expect(full.isSessionReused()).toBe(false); + expect(calls).toBe(1); + session = full.getSession(); + } finally { + full.destroy(); + } + + const resumed = await connect({ session }); + try { + expect(resumed.isSessionReused()).toBe(true); + expect(calls).toBe(1); + } finally { + resumed.destroy(); + } + } finally { + server.close(); + await once(server, "close"); + } +}); + it("new tls.TLSSocket(socket, { isServer: false }) + _start() runs the handshake over the wrapped socket", async () => { // The client STARTTLS pattern: wrap an already-connected plaintext socket, // then start the handshake with the internal _start() entry point. This