diff --git a/src/js/thirdparty/ws.js b/src/js/thirdparty/ws.js index 301d18f6abb7..ed302f922b7b 100644 --- a/src/js/thirdparty/ws.js +++ b/src/js/thirdparty/ws.js @@ -30,6 +30,21 @@ function sendAfterClose(state, cb) { } } +// The native SSLConfig has no `pfx` field; turn a PKCS#12 archive into PEM key/cert. +function unsealPfx(tls) { + if (tls?.pfx == null) return tls; + const { processPfxOptions } = require("internal/tls"); + tls = processPfxOptions(tls); + const pfxExtraCAs = tls._pfxExtraCACerts; + if (pfxExtraCAs?.length) { + // A native `ca` replaces the default roots, so extend them here like Node's addCACert. + const ca = tls.ca ?? require("node:tls").getCACertificates("default"); + tls.ca = $isArray(ca) ? [...ca, ...pfxExtraCAs] : [ca, ...pfxExtraCAs]; + tls._pfxExtraCACerts = undefined; + } + return tls; +} + /** * Extracts TLS and proxy options from an agent object. * @param {Object} agent The agent object to extract options from @@ -45,7 +60,7 @@ function extractAgentOptions(agent) { const newTlsOptions = {}; let hasTlsOptions = false; - const { rejectUnauthorized, ca, cert, key, passphrase } = connectOpts; + const { rejectUnauthorized, ca, cert, key, pfx, passphrase } = connectOpts; if (rejectUnauthorized !== undefined) { newTlsOptions.rejectUnauthorized = rejectUnauthorized; hasTlsOptions = true; @@ -62,6 +77,10 @@ function extractAgentOptions(agent) { newTlsOptions.key = key; hasTlsOptions = true; } + if (pfx) { + newTlsOptions.pfx = pfx; + hasTlsOptions = true; + } if (passphrase) { newTlsOptions.passphrase = passphrase; hasTlsOptions = true; @@ -219,6 +238,7 @@ class BunWebSocket extends EventEmitter { tlsOptions = agentTls; } } + tlsOptions = unsealPfx(tlsOptions); } const finishRequest = options?.finishRequest; diff --git a/test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx b/test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx new file mode 100644 index 000000000000..7a62abeb2264 Binary files /dev/null and b/test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx differ diff --git a/test/js/first_party/ws/ws.test.ts b/test/js/first_party/ws/ws.test.ts index d855eeb1c6da..187136b05f23 100644 --- a/test/js/first_party/ws/ws.test.ts +++ b/test/js/first_party/ws/ws.test.ts @@ -3,9 +3,11 @@ import { spawn } from "bun"; import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import crypto from "crypto"; import { EventEmitter, once } from "events"; -import { bunEnv, bunExe, isDebug } from "harness"; +import { bunEnv, bunExe, isDebug, tls as tlsCerts } from "harness"; import { createServer } from "http"; +import { Agent as HttpsAgent } from "https"; import { AddressInfo, connect } from "net"; +import fs from "node:fs"; import path from "node:path"; import { Server, WebSocket, WebSocketServer } from "ws"; @@ -1462,3 +1464,91 @@ describe("module loading", () => { expect(exitCode).toBe(0); }); }); + +describe("client TLS options with a PKCS#12 archive", () => { + // agent1.pfx bundles agent1's key, its certificate, and the ca1 root that + // signed it. The server requires a client certificate chained to ca1, so a + // handshake only succeeds when the archive's identity is presented. + const fixtures = path.join(import.meta.dir, "../../node/test/fixtures/keys"); + const pfx = fs.readFileSync(path.join(fixtures, "agent1.pfx")); + const clientCa = fs.readFileSync(path.join(fixtures, "ca1-cert.pem"), "utf8"); + + function startMtlsServer() { + const clients: string[] = []; + const server = Bun.serve({ + port: 0, + tls: { ...tlsCerts, ca: clientCa, requestCert: true, rejectUnauthorized: true }, + fetch(req, server) { + if (server.upgrade(req)) return; + return new Response("not a websocket", { status: 400 }); + }, + websocket: { + open(ws) { + clients.push("open"); + ws.send("hello"); + }, + message() {}, + }, + }); + return { server, clients }; + } + + async function firstMessage(ws: WebSocket): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + ws.on("message", data => resolve(String(data))); + ws.on("error", reject); + ws.on("close", (code, reason) => reject(new Error(`closed before a message: ${code} ${reason}`))); + try { + return await promise; + } finally { + ws.close(); + } + } + + const cases: [string, (url: string) => WebSocket][] = [ + ["tls: { pfx, passphrase }", url => new WebSocket(url, { tls: { pfx, passphrase: "sample", ca: tlsCerts.cert } })], + [ + "tls: { pfx: [{ buf, passphrase }] }", + url => new WebSocket(url, { tls: { pfx: [{ buf: pfx, passphrase: "sample" }], ca: tlsCerts.cert } }), + ], + [ + "agent: new https.Agent({ pfx, passphrase })", + url => new WebSocket(url, { agent: new HttpsAgent({ pfx, passphrase: "sample", ca: tlsCerts.cert }) }), + ], + ]; + + for (const [label, open] of cases) { + it(`presents the client certificate from ${label}`, async () => { + const { server, clients } = startMtlsServer(); + using _server = server; + const ws = open(`wss://localhost:${server.port}/`); + expect(await firstMessage(ws)).toBe("hello"); + expect(clients).toEqual(["open"]); + }); + } + + it("trusts the server through a CA bundled in the archive when no ca is given", async () => { + // agent1-with-server-ca.pfx carries agent1's key and certificate plus the + // self-signed certificate the server presents. Node adds archive CAs on top + // of the default roots, so a client with only `pfx` verifies this server. + // Rebuild it from the repository root when the harness `tls.cert` or + // agent1's key pair changes: + // bun -e 'await Bun.write("/tmp/server.pem", (await import("./test/harness.ts")).tls.cert)' + // openssl pkcs12 -export -passout pass:sample -certfile /tmp/server.pem \ + // -inkey test/js/node/test/fixtures/keys/agent1-key.pem \ + // -in test/js/node/test/fixtures/keys/agent1-cert.pem \ + // -out test/js/first_party/ws/fixtures/agent1-with-server-ca.pfx + const bundled = fs.readFileSync(path.join(import.meta.dir, "fixtures/agent1-with-server-ca.pfx")); + const { server, clients } = startMtlsServer(); + using _server = server; + const ws = new WebSocket(`wss://localhost:${server.port}/`, { tls: { pfx: bundled, passphrase: "sample" } }); + expect(await firstMessage(ws)).toBe("hello"); + expect(clients).toEqual(["open"]); + }); + + it("rejects a wrong passphrase before connecting", () => { + expect(() => new WebSocket("wss://localhost:1/", { tls: { pfx, passphrase: "wrong" } })).toThrow( + /MAC verification failed/, + ); + }); +});