329 lines
10 KiB
JavaScript
329 lines
10 KiB
JavaScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, readFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { test } from "node:test";
|
|
import tls from "node:tls";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { resolveSystemCaEnv } from "./runtime.mjs";
|
|
|
|
const fixtureDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "testdata", "chain-repair");
|
|
const rootPem = await readFixtureText("root.pem");
|
|
const intermediatePem = await readFixtureText("intermediate.pem");
|
|
const intermediateDer = await readFixtureBuffer("intermediate.der");
|
|
const leafPem = await readFixtureText("leaf.pem");
|
|
const leafKey = await readFixtureText("leaf.key.pem");
|
|
const systemCert = "-----BEGIN CERTIFICATE-----\nsystem\n-----END CERTIFICATE-----";
|
|
|
|
function fixturePath(name) {
|
|
return path.join(fixtureDir, name);
|
|
}
|
|
|
|
function readFixtureText(name) {
|
|
return readFile(fixturePath(name), "utf8");
|
|
}
|
|
|
|
function readFixtureBuffer(name) {
|
|
return readFile(fixturePath(name));
|
|
}
|
|
|
|
function arrayBufferFromBuffer(buffer) {
|
|
return Uint8Array.from(buffer).buffer;
|
|
}
|
|
|
|
function fetchResponse(buffer) {
|
|
return { ok: true, arrayBuffer: async () => arrayBufferFromBuffer(buffer) };
|
|
}
|
|
|
|
async function withTlsServer(options, fn) {
|
|
const server = tls.createServer(options, (socket) => socket.end());
|
|
const port = await new Promise((resolve, reject) => {
|
|
const onError = (error) => reject(error);
|
|
server.once("error", onError);
|
|
server.listen(0, "127.0.0.1", () => {
|
|
server.off("error", onError);
|
|
const address = server.address();
|
|
if (!address || typeof address === "string") {
|
|
reject(new Error("TLS server did not bind to a TCP port"));
|
|
return;
|
|
}
|
|
resolve(address.port);
|
|
});
|
|
});
|
|
try {
|
|
return await fn(port);
|
|
} finally {
|
|
await new Promise((resolve) => server.close(resolve));
|
|
}
|
|
}
|
|
|
|
function connectWithCa(port, ca) {
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const socket = tls.connect({ host: "127.0.0.1", port, servername: "localhost", ca, rejectUnauthorized: true });
|
|
const timer = setTimeout(() => finish(new Error("TLS client timed out")), 4000);
|
|
function finish(error) {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
socket.destroy();
|
|
if (error) reject(error);
|
|
else resolve();
|
|
}
|
|
socket.once("secureConnect", () => finish(null));
|
|
socket.once("error", finish);
|
|
});
|
|
}
|
|
|
|
function authorizedSocket() {
|
|
const socket = {
|
|
authorized: true,
|
|
authorizationError: null,
|
|
destroy() {},
|
|
setTimeout() {
|
|
return socket;
|
|
},
|
|
once(eventName, listener) {
|
|
if (eventName === "secureConnect") process.nextTick(() => listener());
|
|
return socket;
|
|
},
|
|
off() {
|
|
return socket;
|
|
},
|
|
};
|
|
return socket;
|
|
}
|
|
|
|
function hangingSocket() {
|
|
const socket = {
|
|
destroy() {},
|
|
setTimeout() {
|
|
return socket;
|
|
},
|
|
once() {
|
|
return socket;
|
|
},
|
|
off() {
|
|
return socket;
|
|
},
|
|
};
|
|
return socket;
|
|
}
|
|
|
|
test("repairs a leaf-only TLS chain from an AIA intermediate", async () => {
|
|
await withTlsServer({ cert: leafPem, key: leafKey }, async (port) => {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
|
|
const logs = [];
|
|
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [] },
|
|
userDataDir,
|
|
parentEnv: {},
|
|
logInfo(message) {
|
|
logs.push(String(message));
|
|
},
|
|
loadPlatformCertificates: async () => [],
|
|
chainRepair: {
|
|
origins: [`https://localhost:${port}`],
|
|
fetchImpl: async () => fetchResponse(intermediateDer),
|
|
rootsProvider: () => [rootPem],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, { NODE_EXTRA_CA_CERTS: bundlePath });
|
|
const bundleContent = await readFile(bundlePath, "utf8");
|
|
assert.equal(bundleContent, `${intermediatePem.trim()}\n`);
|
|
assert.ok(logs.some((line) => /chain repaired/.test(line)));
|
|
|
|
await assert.rejects(connectWithCa(port, [rootPem]));
|
|
await connectWithCa(port, [rootPem, bundleContent]);
|
|
});
|
|
});
|
|
|
|
test("refuses repair when the failure is not leaf-only", async () => {
|
|
await withTlsServer({ cert: `${leafPem}\n${intermediatePem}`, key: leafKey }, async (port) => {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
const logs = [];
|
|
let fetchCalled = false;
|
|
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [] },
|
|
userDataDir,
|
|
parentEnv: {},
|
|
logInfo(message) {
|
|
logs.push(String(message));
|
|
},
|
|
loadPlatformCertificates: async () => [],
|
|
chainRepair: {
|
|
origins: [`https://localhost:${port}`],
|
|
fetchImpl: async () => {
|
|
fetchCalled = true;
|
|
return fetchResponse(intermediateDer);
|
|
},
|
|
rootsProvider: () => [rootPem],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, {});
|
|
assert.equal(fetchCalled, false);
|
|
assert.ok(logs.some((line) => /chain repair skipped/.test(line)));
|
|
});
|
|
});
|
|
|
|
test("refuses repair when the fetched certificate did not issue the leaf", async () => {
|
|
await withTlsServer({ cert: leafPem, key: leafKey }, async (port) => {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
const logs = [];
|
|
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [] },
|
|
userDataDir,
|
|
parentEnv: {},
|
|
logInfo(message) {
|
|
logs.push(String(message));
|
|
},
|
|
loadPlatformCertificates: async () => [],
|
|
chainRepair: {
|
|
origins: [`https://localhost:${port}`],
|
|
fetchImpl: async () => fetchResponse(Buffer.from(rootPem, "utf8")),
|
|
rootsProvider: () => [rootPem],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, {});
|
|
assert.ok(logs.some((line) => /chain repair refused/.test(line)));
|
|
});
|
|
});
|
|
|
|
test("chain repair kill switch preserves system CA export", async () => {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
const bundlePath = path.join(userDataDir, "system-ca-bundle.pem");
|
|
let fetchCalled = false;
|
|
let probeCalled = false;
|
|
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [systemCert] },
|
|
userDataDir,
|
|
parentEnv: { OPENWORK_DISABLE_CHAIN_REPAIR: " 1 " },
|
|
logInfo: () => {},
|
|
loadPlatformCertificates: async () => [],
|
|
chainRepair: {
|
|
origins: ["https://localhost:443"],
|
|
fetchImpl: async () => {
|
|
fetchCalled = true;
|
|
return fetchResponse(intermediateDer);
|
|
},
|
|
tlsConnectImpl() {
|
|
probeCalled = true;
|
|
return authorizedSocket();
|
|
},
|
|
rootsProvider: () => [rootPem],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, { NODE_EXTRA_CA_CERTS: bundlePath });
|
|
assert.equal(await readFile(bundlePath, "utf8"), `${systemCert}\n`);
|
|
assert.equal(fetchCalled, false);
|
|
assert.equal(probeCalled, false);
|
|
});
|
|
|
|
test("user NODE_EXTRA_CA_CERTS disables repair", async () => {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
let fetchCalled = false;
|
|
let probeCalled = false;
|
|
let logged = false;
|
|
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [systemCert] },
|
|
userDataDir,
|
|
parentEnv: { NODE_EXTRA_CA_CERTS: "/custom/ca.pem" },
|
|
logInfo(message) {
|
|
logged = String(message).includes("NODE_EXTRA_CA_CERTS is already set");
|
|
},
|
|
loadPlatformCertificates: async () => [systemCert],
|
|
chainRepair: {
|
|
origins: ["https://localhost:443"],
|
|
fetchImpl: async () => {
|
|
fetchCalled = true;
|
|
return fetchResponse(intermediateDer);
|
|
},
|
|
tlsConnectImpl() {
|
|
probeCalled = true;
|
|
return authorizedSocket();
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, {});
|
|
assert.equal(fetchCalled, false);
|
|
assert.equal(probeCalled, false);
|
|
assert.equal(logged, true);
|
|
});
|
|
|
|
test("chain repair total timeout can be shortened by env", async () => {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
const logs = [];
|
|
const startTime = Date.now();
|
|
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [] },
|
|
userDataDir,
|
|
parentEnv: { OPENWORK_CHAIN_REPAIR_TIMEOUT_MS: "1500" },
|
|
logInfo(message) {
|
|
logs.push(String(message));
|
|
},
|
|
loadPlatformCertificates: async () => [],
|
|
chainRepair: {
|
|
origins: ["https://localhost:443"],
|
|
fetchImpl: async () => {
|
|
throw new Error("timed-out chain repair should not fetch");
|
|
},
|
|
tlsConnectImpl: hangingSocket,
|
|
rootsProvider: () => [rootPem],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, {});
|
|
assert.ok(Date.now() - startTime < 5000);
|
|
assert.ok(logs.some((line) => line.includes("chain repair skipped: timed out")));
|
|
});
|
|
|
|
test("activation bootstrap origin parsing is strict", async () => {
|
|
const cases = [
|
|
{ fileName: "bootstrap-valid.json", expected: "localhost:4443" },
|
|
{ fileName: "bootstrap-http.json" },
|
|
{ fileName: "bootstrap-credentials.json" },
|
|
{ fileName: "bootstrap-query.json" },
|
|
{ fileName: "bootstrap-hash.json" },
|
|
{ fileName: "bootstrap-missing-activated-at.json" },
|
|
];
|
|
|
|
for (const testCase of cases) {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), "openwork-chain-repair-"));
|
|
const calls = [];
|
|
const env = await resolveSystemCaEnv({
|
|
tlsModule: { getCACertificates: () => [] },
|
|
userDataDir,
|
|
parentEnv: {},
|
|
logInfo: () => {},
|
|
loadPlatformCertificates: async () => [],
|
|
chainRepair: {
|
|
bootstrapPath: fixturePath(testCase.fileName),
|
|
fetchImpl: async () => {
|
|
throw new Error("bootstrap origin parsing should not fetch");
|
|
},
|
|
tlsConnectImpl(options) {
|
|
calls.push(`${options.host}:${options.port}`);
|
|
return authorizedSocket();
|
|
},
|
|
rootsProvider: () => [rootPem],
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(env, {});
|
|
assert.deepEqual(calls, testCase.expected ? [testCase.expected] : []);
|
|
}
|
|
});
|