Skip to content
Closed
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
22 changes: 21 additions & 1 deletion src/js/thirdparty/ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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];
Comment thread
robobun marked this conversation as resolved.
tls._pfxExtraCACerts = undefined;
}
return tls;
}

/**
* Extracts TLS and proxy options from an agent object.
* @param {Object} agent The agent object to extract options from
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -219,6 +238,7 @@ class BunWebSocket extends EventEmitter {
tlsOptions = agentTls;
}
}
tlsOptions = unsealPfx(tlsOptions);
}

const finishRequest = options?.finishRequest;
Expand Down
Binary file not shown.
92 changes: 91 additions & 1 deletion test/js/first_party/ws/ws.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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<string> {
const { promise, resolve, reject } = Promise.withResolvers<string>();
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) {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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/,
);
});
});
Loading