Files

372 lines
14 KiB
JavaScript

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { X509Certificate } from "node:crypto";
import { mkdtemp, readFile, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { test } from "node:test";
import { createSystemCaCertificateVerifyProc, mergeSystemCaChildEnv, resolveSystemCaEnv } from "./runtime.mjs";
import {
dedupeCertificates,
parseDarwinSecurityCertificates,
parseWindowsPowerShellCertificates,
resolveSystemCaBundle,
summarizeSystemCaSources,
} from "./system-ca.mjs";
const CERT_ONE = "-----BEGIN CERTIFICATE-----\none\n-----END CERTIFICATE-----";
const CERT_TWO = "-----BEGIN CERTIFICATE-----\ntwo\n-----END CERTIFICATE-----";
const CERT_THREE = "-----BEGIN CERTIFICATE-----\nthree\n-----END CERTIFICATE-----";
function windowsPowerShellCertBlock(base64) {
return `-----OPENWORK-CERTIFICATE-----\n${base64}\n-----END-OPENWORK-CERTIFICATE-----`;
}
function pemForBase64(base64) {
const lines = [];
for (let index = 0; index < base64.length; index += 64) {
lines.push(base64.slice(index, index + 64));
}
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
}
let certificateFixturePromise;
async function certificateFixture() {
certificateFixturePromise ??= (async () => {
const directory = await mkdtemp(path.join(tmpdir(), "openwork-runtime-cert-chain-"));
const run = (...args) => execFileSync("openssl", args, { cwd: directory, stdio: "ignore" });
run("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "root.key", "-out", "root.pem", "-subj", "/CN=OpenWork Test Root", "-days", "2", "-sha256");
await writeFile(path.join(directory, "server.ext"), "extendedKeyUsage = serverAuth\nsubjectAltName = DNS:enterprise.test\n");
run("req", "-newkey", "rsa:2048", "-nodes", "-keyout", "leaf.key", "-out", "leaf.csr", "-subj", "/CN=enterprise.test", "-sha256");
run("x509", "-req", "-in", "leaf.csr", "-CA", "root.pem", "-CAkey", "root.key", "-set_serial", "2", "-out", "leaf.pem", "-days", "1", "-sha256", "-extfile", "server.ext");
await writeFile(path.join(directory, "client.ext"), "extendedKeyUsage = clientAuth\nsubjectAltName = DNS:enterprise.test\n");
run("req", "-newkey", "rsa:2048", "-nodes", "-keyout", "client.key", "-out", "client.csr", "-subj", "/CN=enterprise.test", "-sha256");
run("x509", "-req", "-in", "client.csr", "-CA", "root.pem", "-CAkey", "root.key", "-set_serial", "3", "-out", "client.pem", "-days", "1", "-sha256", "-extfile", "client.ext");
run("req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "other.key", "-out", "other.pem", "-subj", "/CN=Unrelated Test Root", "-days", "2", "-sha256");
return {
clientLeaf: await readFile(path.join(directory, "client.pem"), "utf8"),
leaf: await readFile(path.join(directory, "leaf.pem"), "utf8"),
root: await readFile(path.join(directory, "root.pem"), "utf8"),
otherRoot: await readFile(path.join(directory, "other.pem"), "utf8"),
};
})();
return certificateFixturePromise;
}
function verifyCertificate(proc, request) {
return new Promise((resolve) => proc(request, resolve));
}
test("Chromium certificate verification success delegates", async () => {
const proc = createSystemCaCertificateVerifyProc([]);
assert.equal(await verifyCertificate(proc, { verificationResult: "net::OK", errorCode: 0 }), -3);
});
test("authority-invalid chain anchored by configured CA is accepted", async () => {
const fixture = await certificateFixture();
const keyUsage = new X509Certificate(fixture.leaf).keyUsage;
assert.ok(keyUsage?.some((usage) => usage === "1.3.6.1.5.5.7.3.1" || /server ?auth/i.test(usage)));
const proc = createSystemCaCertificateVerifyProc([fixture.root]);
assert.equal(await verifyCertificate(proc, {
verificationResult: "net::ERR_CERT_AUTHORITY_INVALID",
errorCode: -202,
hostname: "enterprise.test",
certificate: { data: fixture.leaf },
}), 0);
});
test("authority-invalid chain with clientAuth-only leaf delegates", async () => {
const fixture = await certificateFixture();
const keyUsage = new X509Certificate(fixture.clientLeaf).keyUsage;
assert.ok(keyUsage?.some((usage) => usage === "1.3.6.1.5.5.7.3.2" || /client ?auth/i.test(usage)));
assert.ok(!keyUsage.some((usage) => usage === "1.3.6.1.5.5.7.3.1" || /server ?auth/i.test(usage)));
const proc = createSystemCaCertificateVerifyProc([fixture.root]);
assert.equal(await verifyCertificate(proc, {
verificationResult: "net::ERR_CERT_AUTHORITY_INVALID",
errorCode: -202,
hostname: "enterprise.test",
certificate: { data: fixture.clientLeaf },
}), -3);
});
test("authority-invalid chain with an unrelated root delegates", async () => {
const fixture = await certificateFixture();
const proc = createSystemCaCertificateVerifyProc([fixture.otherRoot]);
assert.equal(await verifyCertificate(proc, {
verificationResult: "net::ERR_CERT_AUTHORITY_INVALID",
errorCode: -202,
hostname: "enterprise.test",
certificate: { data: fixture.leaf },
}), -3);
});
test("trusted authority-invalid chain with mismatched or missing hostname delegates", async () => {
const fixture = await certificateFixture();
const proc = createSystemCaCertificateVerifyProc([fixture.root]);
const authorityError = { verificationResult: "net::ERR_CERT_AUTHORITY_INVALID", errorCode: -202 };
assert.equal(await verifyCertificate(proc, {
...authorityError,
hostname: "different.test",
certificate: { data: fixture.leaf },
}), -3);
assert.equal(await verifyCertificate(proc, {
...authorityError,
certificate: { data: fixture.leaf },
}), -3);
});
test("non-authority certificate errors delegate to Chromium", async () => {
const fixture = await certificateFixture();
const proc = createSystemCaCertificateVerifyProc([fixture.root]);
assert.equal(await verifyCertificate(proc, {
verificationResult: "net::ERR_CERT_DATE_INVALID",
errorCode: -201,
certificate: { data: fixture.leaf },
}), -3);
assert.equal(await verifyCertificate(proc, {
verificationResult: "net::ERR_CERT_AUTHORITY_INVALID",
errorCode: -201,
certificate: { data: fixture.leaf },
}), -3);
});
test("malformed and cyclic certificate chains never accept", async () => {
const fixture = await certificateFixture();
const proc = createSystemCaCertificateVerifyProc([fixture.root]);
const cyclic = { data: fixture.leaf };
cyclic.issuerCert = cyclic;
const authorityError = { verificationResult: "net::ERR_CERT_AUTHORITY_INVALID", errorCode: -202, hostname: "enterprise.test" };
assert.equal(await verifyCertificate(proc, { ...authorityError, certificate: { data: "not a certificate" } }), -3);
assert.equal(await verifyCertificate(proc, { ...authorityError, certificate: cyclic }), -3);
});
test("writes system CA bundle when certificates are available", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
const env = await resolveSystemCaEnv({
tlsModule: {
getCACertificates(scope) {
assert.equal(scope, "system");
return [CERT_ONE, CERT_TWO];
},
},
userDataDir,
parentEnv: {},
logInfo: () => {},
loadPlatformCertificates: async () => [],
});
assert.deepEqual(env, { NODE_EXTRA_CA_CERTS: bundlePath });
assert.equal(await readFile(bundlePath, "utf8"), `${CERT_ONE}\n${CERT_TWO}\n`);
});
test("sets NODE_EXTRA_CA_CERTS for a child env merge", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
const caEnv = await resolveSystemCaEnv({
tlsModule: { getCACertificates: () => [CERT_ONE] },
userDataDir,
parentEnv: {},
logInfo: () => {},
loadPlatformCertificates: async () => [],
});
/** @type {NodeJS.ProcessEnv} */
const childEnv = { PATH: "/bin", ...caEnv };
assert.equal(childEnv.NODE_EXTRA_CA_CERTS, path.join(userDataDir, "system-ca-bundle.pem"));
});
test("keeps NODE_EXTRA_CA_CERTS from user env file over generated bundle", () => {
const userEnvFile = { NODE_EXTRA_CA_CERTS: "/user/file-ca.pem" };
const processEnv = {};
const baseEnv = {
...userEnvFile,
...processEnv,
BUN_CONFIG_DNS_RESULT_ORDER: "verbatim",
};
const childEnv = mergeSystemCaChildEnv(baseEnv, { NODE_EXTRA_CA_CERTS: "/generated/system-ca-bundle.pem" });
assert.equal(childEnv.NODE_EXTRA_CA_CERTS, "/user/file-ca.pem");
});
test("respects user-set NODE_EXTRA_CA_CERTS", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
let called = false;
let logged = false;
const env = await resolveSystemCaEnv({
tlsModule: {
getCACertificates() {
called = true;
return [CERT_ONE];
},
},
userDataDir,
parentEnv: { NODE_EXTRA_CA_CERTS: "/custom/ca.pem" },
logInfo(message) {
logged = String(message).includes("NODE_EXTRA_CA_CERTS is already set");
},
loadPlatformCertificates: async () => {
called = true;
return [CERT_TWO];
},
});
assert.deepEqual(env, {});
assert.equal(called, false);
assert.equal(logged, true);
});
test("no-ops when tls.getCACertificates is unavailable", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
const env = await resolveSystemCaEnv({
tlsModule: {},
userDataDir,
parentEnv: {},
logInfo: () => {},
loadPlatformCertificates: async () => [],
});
assert.deepEqual(env, {});
await assert.rejects(readFile(path.join(userDataDir, "system-ca-bundle.pem"), "utf8"));
});
test("incident case: macOS runtime returns no certs but platform keychains produce a bundle", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
const logs = [];
const setDefaultCalls = [];
const env = await resolveSystemCaEnv({
tlsModule: {
getCACertificates(scope) {
if (scope === "default") return ["default-root"];
assert.equal(scope, "system");
return [];
},
setDefaultCACertificates(certs) {
setDefaultCalls.push(certs);
},
},
userDataDir,
parentEnv: {},
logInfo(message) {
logs.push(String(message));
},
loadPlatformCertificates: async () => [CERT_ONE, CERT_TWO],
platformSourceName: "macos-keychains",
});
assert.deepEqual(env, { NODE_EXTRA_CA_CERTS: bundlePath });
assert.equal(await readFile(bundlePath, "utf8"), `${CERT_ONE}\n${CERT_TWO}\n`);
assert.deepEqual(setDefaultCalls, [["default-root", CERT_ONE, CERT_TWO]]);
assert.ok(logs.some((line) => line.includes("runtime=0 macos-keychains=2")));
});
test("dedupes certificates across runtime and platform sources", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
const env = await resolveSystemCaEnv({
tlsModule: { getCACertificates: () => [CERT_ONE, CERT_TWO] },
userDataDir,
parentEnv: {},
logInfo: () => {},
loadPlatformCertificates: async () => [CERT_TWO, CERT_THREE, CERT_ONE],
platformSourceName: "windows-cert-stores",
});
assert.deepEqual(env, { NODE_EXTRA_CA_CERTS: bundlePath });
assert.equal(await readFile(bundlePath, "utf8"), `${CERT_ONE}\n${CERT_TWO}\n${CERT_THREE}\n`);
});
test("does not set main-process defaults when no additions are available", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
let setDefaultCalled = false;
const env = await resolveSystemCaEnv({
tlsModule: {
getCACertificates: () => [],
setDefaultCACertificates() {
setDefaultCalled = true;
},
},
userDataDir,
parentEnv: {},
logInfo: () => {},
loadPlatformCertificates: async () => [],
});
assert.deepEqual(env, {});
assert.equal(setDefaultCalled, false);
});
test("main-process default extension is optional", async () => {
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-runtime-ca-"));
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
const env = await resolveSystemCaEnv({
tlsModule: { getCACertificates: () => [CERT_ONE] },
userDataDir,
parentEnv: {},
logInfo: () => {},
loadPlatformCertificates: async () => [],
});
assert.deepEqual(env, { NODE_EXTRA_CA_CERTS: bundlePath });
});
test("system CA bundle resolver keeps platform sources additive and reports counts", async () => {
const bundle = await resolveSystemCaBundle({
runtime: () => [CERT_ONE],
platform: { name: "macos-keychains", load: async () => [CERT_ONE, CERT_TWO] },
});
assert.deepEqual(bundle.certificates, [CERT_ONE, CERT_TWO]);
assert.equal(summarizeSystemCaSources(bundle.sources), "runtime=1 macos-keychains=2");
});
test("system CA bundle resolver keeps other sources when platform enumeration fails", async () => {
const bundle = await resolveSystemCaBundle({
runtime: () => [CERT_ONE],
platform: {
name: "windows-cert-stores",
load: async () => {
throw new Error("powershell blocked by policy");
},
},
});
assert.deepEqual(bundle.certificates, [CERT_ONE]);
assert.equal(summarizeSystemCaSources(bundle.sources), "runtime=1 windows-cert-stores=0");
});
test("parses and dedupes windows PowerShell certificate output", () => {
const first = Buffer.from("first certificate with enough bytes to require PEM wrapping across more than one output line").toString("base64");
const second = Buffer.from("second certificate").toString("base64");
const output = [
windowsPowerShellCertBlock(first),
"noise",
windowsPowerShellCertBlock(second),
windowsPowerShellCertBlock("not-valid-base64"),
windowsPowerShellCertBlock(first),
].join("\n");
assert.deepEqual(parseWindowsPowerShellCertificates(output), [pemForBase64(first), pemForBase64(second)]);
});
test("parses darwin security PEM output", () => {
const output = `noise\n${CERT_ONE}\nmore noise\n${CERT_TWO}\n${CERT_ONE}\n`;
assert.deepEqual(parseDarwinSecurityCertificates(output), [CERT_ONE, CERT_TWO]);
});
test("ignores garbage certificate command output", () => {
assert.deepEqual(parseDarwinSecurityCertificates("not certificate output"), []);
assert.deepEqual(parseWindowsPowerShellCertificates("not certificate output"), []);
assert.deepEqual(dedupeCertificates(["", " ", CERT_ONE, CERT_ONE]), [CERT_ONE]);
});