224 lines
6.5 KiB
JavaScript
224 lines
6.5 KiB
JavaScript
import { spawn } from "node:child_process";
|
|
|
|
const COMMAND_TIMEOUT_MS = 10_000;
|
|
const OUTPUT_LIMIT_CHARS = 8 * 1024 * 1024;
|
|
const WINDOWS_CERT_BEGIN = "-----OPENWORK-CERTIFICATE-----";
|
|
const WINDOWS_CERT_END = "-----END-OPENWORK-CERTIFICATE-----";
|
|
const PEM_CERT_PATTERN = /-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g;
|
|
|
|
/**
|
|
* @typedef {(command: string, args: string[], windowsHide: boolean) => Promise<string | null>} SystemCaCommandRunner
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SystemCaSource
|
|
* @property {string} name
|
|
* @property {number} count
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SystemCaBundle
|
|
* @property {string[]} certificates
|
|
* @property {SystemCaSource[]} sources
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SystemCaPlatformLoader
|
|
* @property {string} name
|
|
* @property {() => Promise<string[]>} load
|
|
*/
|
|
|
|
/**
|
|
* @typedef {Object} SystemCaLoaders
|
|
* @property {() => string[]} runtime
|
|
* @property {SystemCaPlatformLoader} platform
|
|
*/
|
|
|
|
/**
|
|
* @param {Iterable<string>} certs
|
|
* @returns {string[]}
|
|
*/
|
|
export function dedupeCertificates(certs) {
|
|
const seen = new Set();
|
|
const out = [];
|
|
for (const cert of certs) {
|
|
const trimmed = String(cert ?? "").trim();
|
|
if (!trimmed || seen.has(trimmed)) continue;
|
|
seen.add(trimmed);
|
|
out.push(trimmed);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* @param {string} value
|
|
* @returns {string | null}
|
|
*/
|
|
export function pemFromBase64(value) {
|
|
const base64 = value.replace(/\s+/g, "");
|
|
if (!base64 || base64.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(base64)) {
|
|
return null;
|
|
}
|
|
const lines = base64.match(/.{1,64}/g);
|
|
if (!lines) return null;
|
|
return `-----BEGIN CERTIFICATE-----\n${lines.join("\n")}\n-----END CERTIFICATE-----`;
|
|
}
|
|
|
|
/**
|
|
* @param {string} output
|
|
* @returns {string[]}
|
|
*/
|
|
export function parseWindowsPowerShellCertificates(output) {
|
|
const certs = [];
|
|
const pattern = new RegExp(`${WINDOWS_CERT_BEGIN}\\s*([A-Za-z0-9+/=\\r\\n]+?)\\s*${WINDOWS_CERT_END}`, "g");
|
|
for (const match of output.matchAll(pattern)) {
|
|
const pem = pemFromBase64(match[1] ?? "");
|
|
if (pem) certs.push(pem);
|
|
}
|
|
return dedupeCertificates(certs);
|
|
}
|
|
|
|
/**
|
|
* @param {string} output
|
|
* @returns {string[]}
|
|
*/
|
|
export function parseDarwinSecurityCertificates(output) {
|
|
const certs = [];
|
|
for (const match of output.matchAll(PEM_CERT_PATTERN)) {
|
|
certs.push(match[0]);
|
|
}
|
|
return dedupeCertificates(certs);
|
|
}
|
|
|
|
/**
|
|
* @param {string} command
|
|
* @param {string[]} args
|
|
* @param {boolean} windowsHide
|
|
* @returns {Promise<string | null>}
|
|
*/
|
|
export function runCommand(command, args, windowsHide) {
|
|
return new Promise((resolve) => {
|
|
let child;
|
|
try {
|
|
child = spawn(command, args, { stdio: ["ignore", "pipe", "ignore"], windowsHide });
|
|
} catch {
|
|
resolve(null);
|
|
return;
|
|
}
|
|
|
|
let output = "";
|
|
let settled = false;
|
|
const timeout = setTimeout(() => {
|
|
child.kill();
|
|
finish(null);
|
|
}, COMMAND_TIMEOUT_MS);
|
|
|
|
/** @param {string | null} value */
|
|
function finish(value) {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
resolve(value);
|
|
}
|
|
|
|
child.stdout?.setEncoding("utf8");
|
|
child.stdout?.on("data", (chunk) => {
|
|
if (settled) return;
|
|
const next = `${output}${String(chunk)}`;
|
|
if (next.length > OUTPUT_LIMIT_CHARS) {
|
|
child.kill();
|
|
finish(null);
|
|
return;
|
|
}
|
|
output = next;
|
|
});
|
|
child.on("error", () => finish(null));
|
|
child.on("exit", (code) => finish(code === 0 ? output : null));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param {SystemCaCommandRunner} [commandRunner]
|
|
* @returns {Promise<string[]>}
|
|
*/
|
|
export async function loadWindowsSystemCertificates(commandRunner = runCommand) {
|
|
const script = `
|
|
$ErrorActionPreference = 'SilentlyContinue'
|
|
$stores = @('Cert:\\LocalMachine\\Root', 'Cert:\\LocalMachine\\CA', 'Cert:\\CurrentUser\\Root', 'Cert:\\CurrentUser\\CA')
|
|
foreach ($store in $stores) {
|
|
Get-ChildItem -Path $store -ErrorAction SilentlyContinue | ForEach-Object {
|
|
if ($_.RawData) {
|
|
'${WINDOWS_CERT_BEGIN}'
|
|
[Convert]::ToBase64String($_.RawData)
|
|
'${WINDOWS_CERT_END}'
|
|
}
|
|
}
|
|
}
|
|
`;
|
|
const output = await commandRunner("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], true);
|
|
return output ? parseWindowsPowerShellCertificates(output) : [];
|
|
}
|
|
|
|
// Admin-controlled keychains only. `find-certificate` ignores trust settings, so
|
|
// including the user-writable login keychain would let any local process widen
|
|
// what the desktop trusts; user-domain roots go through NODE_EXTRA_CA_CERTS.
|
|
export const DARWIN_KEYCHAINS = [
|
|
"/Library/Keychains/System.keychain",
|
|
"/System/Library/Keychains/SystemRootCertificates.keychain",
|
|
];
|
|
|
|
/**
|
|
* @param {SystemCaCommandRunner} [commandRunner]
|
|
* @returns {Promise<string[]>}
|
|
*/
|
|
export async function loadDarwinSystemCertificates(commandRunner = runCommand) {
|
|
const output = await commandRunner("security", ["find-certificate", "-a", "-p", ...DARWIN_KEYCHAINS], false);
|
|
return output ? parseDarwinSecurityCertificates(output) : [];
|
|
}
|
|
|
|
/**
|
|
* @param {NodeJS.Platform} platform
|
|
* @param {SystemCaCommandRunner} [commandRunner]
|
|
* @returns {SystemCaPlatformLoader}
|
|
*/
|
|
export function systemPlatformCertificateLoader(platform = process.platform, commandRunner = runCommand) {
|
|
if (platform === "win32") return { name: "windows-cert-stores", load: () => loadWindowsSystemCertificates(commandRunner) };
|
|
if (platform === "darwin") return { name: "macos-keychains", load: () => loadDarwinSystemCertificates(commandRunner) };
|
|
return { name: "platform-stores", load: async () => [] };
|
|
}
|
|
|
|
/**
|
|
* Every source is additive. Returning the runtime list as soon as it is
|
|
* non-empty skips the thorough platform enumeration, which is where corporate
|
|
* roots often live on managed Windows and macOS machines.
|
|
*
|
|
* @param {SystemCaLoaders} loaders
|
|
* @returns {Promise<SystemCaBundle>}
|
|
*/
|
|
export async function resolveSystemCaBundle(loaders) {
|
|
let runtimeCerts = [];
|
|
try {
|
|
runtimeCerts = loaders.runtime();
|
|
} catch {
|
|
runtimeCerts = [];
|
|
}
|
|
const platformCerts = await loaders.platform.load().catch(() => []);
|
|
|
|
return {
|
|
certificates: dedupeCertificates([...runtimeCerts, ...platformCerts]),
|
|
sources: [
|
|
{ name: "runtime", count: runtimeCerts.length },
|
|
{ name: loaders.platform.name, count: platformCerts.length },
|
|
],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {SystemCaSource[]} sources
|
|
* @returns {string}
|
|
*/
|
|
export function summarizeSystemCaSources(sources) {
|
|
if (sources.length === 0) return "no OS trust sources returned certificates";
|
|
return sources.map((source) => `${source.name}=${source.count}`).join(" ");
|
|
}
|