From a502e4e49d79b5762ee85f4ee9c3e3634fbcfdc0 Mon Sep 17 00:00:00 2001 From: Reinier Criel Date: Mon, 10 Nov 2025 14:31:43 -0800 Subject: [PATCH] Some more cleanup --- .../src/registryProxy/certBundle.js | 100 ++++++++++++++++++ .../src/registryProxy/certBundle.spec.js | 71 +++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 packages/safe-chain/src/registryProxy/certBundle.js create mode 100644 packages/safe-chain/src/registryProxy/certBundle.spec.js diff --git a/packages/safe-chain/src/registryProxy/certBundle.js b/packages/safe-chain/src/registryProxy/certBundle.js new file mode 100644 index 0000000..c6582a4 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certBundle.js @@ -0,0 +1,100 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +// @ts-ignore - certifi has no type definitions +import certifi from "certifi"; +import tls from "node:tls"; +import { X509Certificate } from "node:crypto"; +import { getCaCertPath } from "./certUtils.js"; + +/** + * Check if a PEM string contains only parsable cert blocks. + * @param {string} pem - PEM-encoded certificate string + * @returns {boolean} + */ +function isParsable(pem) { + if (!pem || typeof pem !== "string") return false; + const begin = "-----BEGIN CERTIFICATE-----"; + const end = "-----END CERTIFICATE-----"; + const blocks = []; + + let idx = 0; + while (idx < pem.length) { + const start = pem.indexOf(begin, idx); + if (start === -1) break; + const stop = pem.indexOf(end, start + begin.length); + if (stop === -1) break; + const blockEnd = stop + end.length; + blocks.push(pem.slice(start, blockEnd)); + idx = blockEnd; + } + + if (blocks.length === 0) return false; + try { + for (const b of blocks) { + // throw if invalid + new X509Certificate(b); + } + return true; + } catch { + return false; + } +} + +/** @type {string | null} */ +let cachedPath = null; + +/** + * Build a combined CA bundle for Python and Node HTTPS flows. + * - Includes Safe Chain CA (for MITM of known registries) + * - Includes Mozilla roots via npm `certifi` (public HTTPS) + * - Includes Node's built-in root certificates as a portable fallback + * @returns {string} Path to the combined CA bundle PEM file + */ +export function getCombinedCaBundlePath() { + if (cachedPath && fs.existsSync(cachedPath)) return cachedPath; + + // Concatenate PEM files + const parts = []; + + // 1) Safe Chain CA (for MITM'd registries) + const safeChainPath = getCaCertPath(); + try { + const safeChainPem = fs.readFileSync(safeChainPath, "utf8"); + if (isParsable(safeChainPem)) + { + parts.push(safeChainPem.trim()); + console.log("[Safe Chain] Loaded Safe Chain CA from:", safeChainPath); + } + } catch { + // Ignore if Safe Chain CA is not available + } + + // 2) certifi (Mozilla CA bundle for all public HTTPS) + try { + const certifiPem = fs.readFileSync(certifi, "utf8"); + if (isParsable(certifiPem)) parts.push(certifiPem.trim()); + } catch { + // Ignore if certifi bundle is not available + } + + // 3) Node's built-in root certificates + try { + const nodeRoots = tls.rootCertificates; + if (Array.isArray(nodeRoots) && nodeRoots.length) { + for (const rootPem of nodeRoots) { + if (typeof rootPem !== "string") continue; + if (isParsable(rootPem)) parts.push(rootPem.trim()); + } + } + } catch { + // Ignore if unavailable + } + + const combined = parts.filter(Boolean).join("\n"); + const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem"); + fs.writeFileSync(target, combined, { encoding: "utf8" }); + cachedPath = target; + console.log(`Created combined CA bundle at: ${cachedPath}`); + return cachedPath; +} diff --git a/packages/safe-chain/src/registryProxy/certBundle.spec.js b/packages/safe-chain/src/registryProxy/certBundle.spec.js new file mode 100644 index 0000000..2f26d51 --- /dev/null +++ b/packages/safe-chain/src/registryProxy/certBundle.spec.js @@ -0,0 +1,71 @@ +import { describe, it, beforeEach, mock } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import tls from "node:tls"; + +// Utility to remove the generated bundle so the module rebuilds it on demand +function removeBundleIfExists() { + const target = path.join(os.tmpdir(), "safe-chain-ca-bundle.pem"); + try { + if (fs.existsSync(target)) fs.unlinkSync(target); + } catch { + // ignore + } +} + +describe("certBundle.getCombinedCaBundlePath", () => { + beforeEach(() => { + mock.restoreAll(); + removeBundleIfExists(); + }); + + it("includes Safe Chain CA when parsable and produces a PEM bundle", async () => { + // Prepare a temporary Safe Chain CA file with a recognizable marker and a valid cert block + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); + const safeChainPath = path.join(tmpDir, "safechain-ca.pem"); + const marker = "# SAFE_CHAIN_TEST_MARKER"; + const rootPem = typeof tls.rootCertificates?.[0] === "string" ? tls.rootCertificates[0] : ""; + assert.ok(rootPem.includes("BEGIN CERTIFICATE"), "Environment lacks Node root certificates for test"); + fs.writeFileSync(safeChainPath, `${marker}\n${rootPem}`, "utf8"); + + // Mock the certUtils.getCaCertPath to return our temp file + mock.module("./certUtils.js", { + namedExports: { + getCaCertPath: () => safeChainPath, + }, + }); + + const { getCombinedCaBundlePath } = await import("./certBundle.js"); + const bundlePath = getCombinedCaBundlePath(); + assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); + const contents = fs.readFileSync(bundlePath, "utf8"); + assert.match(contents, /-----BEGIN CERTIFICATE-----/); + assert.ok(contents.includes(marker), "Bundle should include Safe Chain CA content when parsable"); + }); + + it("ignores invalid Safe Chain CA but still builds from other sources", async () => { + // Write an invalid file (no cert blocks) + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pipcabundle-")); + const safeChainPath = path.join(tmpDir, "safechain-invalid.pem"); + const invalidMarker = "INVALID_SAFE_CHAIN_CONTENT"; + fs.writeFileSync(safeChainPath, invalidMarker, "utf8"); + + // Mock the certUtils.getCaCertPath to return our invalid file + mock.module("./certUtils.js", { + namedExports: { + getCaCertPath: () => safeChainPath, + }, + }); + + // Ensure fresh build + removeBundleIfExists(); + const { getCombinedCaBundlePath } = await import("./certBundle.js"); + const bundlePath = getCombinedCaBundlePath(); + assert.ok(fs.existsSync(bundlePath), "Bundle path should exist"); + const contents = fs.readFileSync(bundlePath, "utf8"); + assert.match(contents, /-----BEGIN CERTIFICATE-----/, "Bundle should contain certificate blocks from certifi/Node roots"); + assert.ok(!contents.includes(invalidMarker), "Bundle should not include invalid Safe Chain content"); + }); +});