Repository: denoland/deno
Pull request: fix(ext/node): mTLS support for `node:tls`

Fixes https://github.com/denoland/deno/issues/28341

- handle `secureContext.key` and `secureContext.cert` in `new TLSSocket()`
- Fix serialize when `secureContext.ca` is an array

Fixes MQTT connect support:
```
New connection from 127.0.0.1:58119 on port 8883.
1755367370: New client connected from 127.0.0.1:58119 as 637013c2-53c7-4779-9e84-50dcf2f2f81b (p5, c0, k60, u'test-client').
1755367370: No will message specified.
1755367370: Sending CONNACK to 637013c2-53c7-4779-9e84-50dcf2f2f81b (0, 0)
```

Changed files:
filename: ext/net/02_tls.js
patch: @@ -203,6 +203,7 @@ function startTlsInternal(
     hostname = "127.0.0.1",
     caCerts = [],
     alpnProtocols = undefined,
+    keyPair = null,
     rejectUnauthorized,
   },
 ) {
@@ -212,7 +213,7 @@ function startTlsInternal(
     caCerts,
     alpnProtocols,
     rejectUnauthorized,
-  }, null);
+  }, keyPair);
   return new TlsConn(rid, remoteAddr, localAddr);
 }
 

filename: ext/node/polyfills/_tls_wrap.js
patch: @@ -36,7 +36,11 @@ import {
 } from "ext:deno_node/internal/util/types.ts";
 import { startTlsInternal } from "ext:deno_net/02_tls.js";
 import { internals } from "ext:core/mod.js";
-import { op_tls_canonicalize_ipv4_address } from "ext:core/ops";
+import {
+  op_tls_canonicalize_ipv4_address,
+  op_tls_key_null,
+  op_tls_key_static,
+} from "ext:core/ops";
 
 const kConnectOptions = Symbol("connect-options");
 const kIsVerified = Symbol("verified");
@@ -81,14 +85,30 @@ export class TLSSocket extends net.Socket {
       "localhost";
     tlsOptions.hostname = hostname;
 
-    const _cert = tlsOptions?.secureContext?.cert;
-    const _key = tlsOptions?.secureContext?.key;
-
+    const cert = tlsOptions?.secureContext?.cert;
+    const key = tlsOptions?.secureContext?.key;
+    const hasTlsKey = key !== undefined &&
+      cert !== undefined;
+    const keyPair = hasTlsKey
+      ? op_tls_key_static(cert, key)
+      : op_tls_key_null();
     let caCerts = tlsOptions?.secureContext?.ca;
-    if (typeof caCerts === "string") caCerts = [caCerts];
-    else if (isArrayBufferView(caCerts) || isAnyArrayBuffer(caCerts)) {
+    if (typeof caCerts === "string") {
+      caCerts = [caCerts];
+    } else if (isArrayBufferView(caCerts) || isAnyArrayBuffer(caCerts)) {
       caCerts = [new TextDecoder().decode(caCerts)];
+    } else if (Array.isArray(caCerts)) {
+      caCerts = caCerts.map((cert) => {
+        if (typeof cert === "string") {
+          return cert;
+        } else if (isArrayBufferView(cert) || isAnyArrayBuffer(cert)) {
+          return new TextDecoder().decode(cert);
+        }
+        return cert;
+      });
     }
+
+    tlsOptions.keyPair = keyPair;
     tlsOptions.caCerts = caCerts;
     tlsOptions.alpnProtocols = opts.ALPNProtocols;
     tlsOptions.rejectUnauthorized = opts.rejectUnauthorized !== false;

filename: tests/unit_node/tls_test.ts
patch: @@ -349,3 +349,52 @@ Deno.test({
   }
   assertEquals(new TextDecoder().decode(stdout), "");
 });
+
+Deno.test("mTLS client certificate authentication", async () => {
+  const clientKey = key;
+  const clientCert = cert;
+
+  const server = tls.createServer({
+    key,
+    cert,
+    requestCert: true,
+    rejectUnauthorized: true,
+    ca: [rootCaCert],
+  }, (socket) => {
+    socket.write("mTLS success!");
+    socket.end();
+  });
+
+  const { promise, resolve, reject } = Promise.withResolvers<string>();
+
+  server.listen(0, () => {
+    // deno-lint-ignore no-explicit-any
+    const port = (server.address() as any)?.port;
+
+    const client = tls.connect({
+      host: "localhost",
+      port,
+      key: clientKey,
+      cert: clientCert,
+      ca: rootCaCert,
+    });
+
+    client.setEncoding("utf8");
+    let data = "";
+    client.on("data", (chunk) => {
+      data += chunk;
+    });
+
+    client.on("end", () => {
+      resolve(data);
+    });
+
+    client.on("error", (err) => {
+      reject(err);
+    });
+  });
+
+  const result = await promise;
+  assertEquals(result, "mTLS success!");
+  server.close();
+});