diff --git a/src/js/node/net.ts b/src/js/node/net.ts index 8d2305b5ef50..dadbbb99072b 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; @@ -415,12 +442,11 @@ const SocketHandlers: SocketHandler = { self.emit("secure", self); 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); - } + // 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; } let rejectUnauthorized; if (self._requestCert || (rejectUnauthorized = self._rejectUnauthorized)) { @@ -1151,12 +1177,11 @@ 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,43 @@ 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 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 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..fd3462c5f093 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,407 @@ 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 }, + ); + } +}); + +// 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()).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. + await withTlsPair({ maxVersion: "TLSv1.2", ciphers: "AES128-SHA" }, client => { + expect(client.getEphemeralKeyInfo()).toStrictEqual(NO_EPHEMERAL_KEY); + }); + // 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()).toStrictEqual(NO_EPHEMERAL_KEY); + }); +}); + +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; + } + + 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 }); + 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("checkServerIdentity is not called on a resumed session, matching Node", async () => { + // 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 + // 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("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 + // 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 + // 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(), stderr, exitCode }).toEqual({ + stdout: "UNCAUGHT csi-boom", + stderr: "", + exitCode: 42, + }); +});