From f05c3daebb7a53b010e6d9b865529c1298b886c4 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 16 Sep 2026 16:38:11 +0000 Subject: [PATCH] =?UTF-8?q?dns:=20trust=20the=20registry's=20root=20?= =?UTF-8?q?=E2=80=94=20every=20Moshpit=20name=20at=20once,=20in=20dns=20en?= =?UTF-8?q?able=20and=20a=20new=20dns=20ca=20verb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry now signs a certificate for every name it holds (#519), so a machine that trusts its root once trusts every Moshpit name over https: curl, Firefox, Chromium, git. dns enable fetches /api/moshpit/ca and /api/moshpit/ca.crt, checks the served root is a self-signed CA:TRUE certificate whose fingerprint matches what the registry reports, keeps it at ~/.moshpit/ca/registry-root.crt, and installs it into the NSS store (no root) and the system store (as root) under the nickname Moshpit Root CA, the same nickname TronBrowser's launcher uses so neither imports the other's work twice. dns disable takes it out. moshcode dns ca does only that half, for a machine whose resolution comes from elsewhere; --remove undoes it. trustStores/untrustPlan take the nickname and system file as parameters, defaults unchanged. The root carries no name constraints, since X.509 cannot say everything except the ICANN root across seventeen thousand endings; the signer is the bound, and installing it stays an explicit act. Co-Authored-By: Claude Fable 5.1 --- src/cli-schema.mjs | 8 ++ src/dns.mjs | 26 +++- src/trust.mjs | 253 ++++++++++++++++++++++++++++++++++- test/trust-registry.test.mjs | 178 ++++++++++++++++++++++++ 4 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 test/trust-registry.test.mjs diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index a8537350..52cff6c3 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -1144,6 +1144,14 @@ export const DNS_VERBS = [ { name: "tlds", description: "list the endings claimed in the Pit" }, { name: "resolve", description: "what a name resolves to, and why" }, { name: "trust", description: "trust one name's certificate, after checking it against the registry pin" }, + { + name: "ca", + description: "trust the registry's root here, so every Moshpit name is trusted at once", + synopsis: [ + ["moshcode dns ca", "fetch the registry's root, check it, install it (NSS without root, the system store with sudo)"], + ["moshcode dns ca --remove", "take it back out of every store"], + ], + }, { name: "filter", description: "block ads, trackers, malware and phishing at the resolver", diff --git a/src/dns.mjs b/src/dns.mjs index c2098a5c..1a87524a 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -2455,7 +2455,7 @@ import { createParkingServer, DEFAULT_PARKING_HTTP_PORT } from "./parking-http.m // use it without importing this one back. export { pitNameUrl } from "./pit-url.mjs"; import { pitNameUrl } from "./pit-url.mjs"; -import { applyTrust, applyUntrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs"; +import { applyRegistryTrust, applyTrust, applyUntrust, createAutoTrust, removeRegistryTrust, trustName, verifyStockTls } from "./trust.mjs"; import { readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -2619,6 +2619,20 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { return 0; } + // `moshcode dns ca` — trust the registry's root on this machine, and nothing + // else: no bridge, no resolver change. The half of `dns enable` a machine + // needs when its resolution comes from elsewhere (a router running the + // bridge, DoH in the browser, TronBrowser's own resolver) but its clients + // still refuse the certificates. `--remove` takes it back out. + if (sub === "ca") { + if (rest.includes("--remove")) { + const r = await removeRegistryTrust(out, deps); + return r.ok ? 0 : 1; + } + const r = await applyRegistryTrust(out, { ...deps, registryBase }); + return r.ok ? 0 : 1; + } + if (sub === "trust") { // resolveArgument, not a bare `find(!startsWith("-"))`: the latter grabs the // value after `--registry`/`--port` (a URL does not start with "-"), so @@ -3332,7 +3346,10 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // Removing it by default is what makes `disable` mean "as it was". // `--keep-trust` is for turning resolution off for an afternoon without // paying for a re-install of the root afterwards. - if (!rest.includes("--keep-trust")) await applyUntrust(out, deps); + if (!rest.includes("--keep-trust")) { + await applyUntrust(out, deps); + await removeRegistryTrust(out, deps); + } out(""); // The line the old implementation printed unconditionally, now only when @@ -3580,6 +3597,11 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // no CA will ever sign for a Moshpit name — so the local root that // moshpit-proxy generates is the only thing that closes it. if (!rest.includes("--no-trust")) { + // The registry's root first: one anchor that covers every name, and + // the one a stock client is meant to end up with. The local root that + // follows is what the pinned proxy needs and what a machine without a + // signing registry falls back to. + await applyRegistryTrust(out, { ...deps, registryBase }); const trusted = await applyTrust(tlds, out, deps); // The claim this whole feature makes is that an ordinary client now // works, so check it as an ordinary client would — a plain HTTPS GET diff --git a/src/trust.mjs b/src/trust.mjs index e9d9ae7f..685b54c9 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -20,6 +20,7 @@ import path from "node:path"; import os from "node:os"; import { execFileSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; import { IANA_TLDS } from "./iana-tlds.mjs"; /** Where moshpit-proxy generates its root on first run. */ @@ -255,8 +256,15 @@ export function summarise(items, show = 8) { * that says "installed" when curl still cannot verify is how someone concludes * the whole thing is broken again. */ -export function trustStores({ platform = process.platform, home = os.homedir(), caFile } = {}) { +export function trustStores({ + platform = process.platform, + home = os.homedir(), + caFile, + nickname = "Moshpit Local CA", + systemFile = "moshpit-local-ca.crt", +} = {}) { const file = caFile || caPath({ home }); + const systemCopy = `/usr/local/share/ca-certificates/${systemFile}`; const stores = []; if (platform === "darwin") { @@ -292,13 +300,13 @@ export function trustStores({ platform = process.platform, home = os.homedir(), // to update its own store. ownedDir: path.join(home, ".pki", "nssdb"), command: "certutil", - args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-A", "-t", "C,,", "-n", "Moshpit Local CA", "-i", file], + args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-A", "-t", "C,,", "-n", nickname, "-i", file], // By nickname, so the anchor can still be withdrawn after the root file // itself is gone — which is the ordinary case, since a person who wants // rid of this deletes the certificate first and asks questions after. remove: { command: "certutil", - args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-D", "-n", "Moshpit Local CA"], + args: ["-d", `sql:${path.join(home, ".pki", "nssdb")}`, "-D", "-n", nickname], }, }); stores.push({ @@ -308,14 +316,14 @@ export function trustStores({ platform = process.platform, home = os.homedir(), // Two steps rather than one: the copy is the install, and the refresh is // what makes it take effect. Reporting them together would hide which // one failed. - copyTo: "/usr/local/share/ca-certificates/moshpit-local-ca.crt", + copyTo: systemCopy, command: "update-ca-certificates", args: [], // Delete the copy, then rebuild. `--fresh` rather than a bare refresh: // the bare form adds what is new, and it is the rebuild that drops the // symlink for a source file that is no longer there. remove: { - removeFile: "/usr/local/share/ca-certificates/moshpit-local-ca.crt", + removeFile: systemCopy, command: "update-ca-certificates", args: ["--fresh"], }, @@ -398,12 +406,14 @@ export function untrustPlan({ isRoot = false, haveCertutil = true, haveFile = true, + nickname = undefined, + systemFile = undefined, } = {}) { const file = caFile || caPath({ home }); const steps = []; const skipped = []; - for (const store of trustStores({ platform, home, caFile: file })) { + for (const store of trustStores({ platform, home, caFile: file, nickname, systemFile })) { if (!store.remove) { skipped.push({ ...store, why: "this build knows how to install it but not how to remove it" }); continue; @@ -988,3 +998,234 @@ export async function verifyStockTls(name, { fetchImpl = fetch, timeoutMs = 8000 clearTimeout(timer); } } + +/* ------------------------------------------------------ the registry's root */ + +/* + * The registry now signs a certificate for every name it holds (moshcode + * apps/pwa/docs/moshpit-ca.md), so a machine that trusts its root once trusts + * every Moshpit name over https: curl, Firefox, Chromium, git, all of it. That + * makes the per-machine local CA above, and `dns trust `, the fallbacks + * rather than the way in. + * + * The root carries no name constraints: there are seventeen thousand endings + * and more every day, and X.509 cannot say "everything except the ICANN root". + * What bounds it instead is the signer: the registry refuses to sign any name + * whose ending is a real TLD, signs only for the account that controls a name, + * and issues thirty-day leaves. That is a policy promise rather than a + * certificate extension, which is why installing it is an explicit act here + * (`dns enable`, or `dns ca`) and never something a resolver does on its own. + */ + +export const REGISTRY_ROOT_NICKNAME = "Moshpit Root CA"; +export const REGISTRY_ROOT_SYSTEM_FILE = "moshpit-root-ca.crt"; +const DEFAULT_REGISTRY = "https://pit.moshcode.sh"; + +/** Where the fetched root is kept, beside the local one. */ +export function registryRootPath({ home = os.homedir(), dir = null } = {}) { + return path.join(dir || path.join(home, ".moshpit"), "ca", "registry-root.crt"); +} + +/** + * Is this PEM the root the registry says it is? Pure. + * + * Two fetches over HTTPS, `/api/moshpit/ca` for the fingerprint and `/ca.crt` + * for the bytes, and the bytes must hash to the fingerprint: a truncated or + * substituted download fails here rather than being installed as an anchor. + */ +export function checkRegistryRoot(pem, { fingerprint = null, now = Date.now() } = {}) { + let cert; + try { + cert = new X509Certificate(String(pem || "")); + } catch { + return { ok: false, why: "the registry served something that is not a certificate" }; + } + if (!cert.ca) return { ok: false, why: "the registry's root is not marked CA:TRUE" }; + if (!cert.verify(cert.publicKey)) return { ok: false, why: "the registry's root is not self-signed" }; + const bare = (s) => String(s || "").replace(/:/g, "").toLowerCase(); + if (fingerprint && bare(cert.fingerprint256) !== bare(fingerprint)) { + return { ok: false, why: "the root served does not match the fingerprint the registry reports for it" }; + } + if (new Date(cert.validTo).getTime() < now) return { ok: false, why: "the registry's root has expired" }; + // node prints one RDN per line; one line reads better in a report. + return { ok: true, subject: cert.subject.replace(/\n/g, ", "), fingerprint: cert.fingerprint256, notAfter: cert.validTo }; +} + +/** Ask the registry for its root. { enabled:false } when it publishes none. */ +export async function fetchRegistryRoot({ registryBase = DEFAULT_REGISTRY, fetchImpl = fetch, timeoutMs = 8000 } = {}) { + const base = registryBase.replace(/\/+$/, ""); + const opts = { signal: AbortSignal.timeout(timeoutMs), headers: { accept: "application/json, application/x-pem-file" } }; + const status = await fetchImpl(`${base}/api/moshpit/ca`, opts); + if (!status.ok) throw new Error(`${base}/api/moshpit/ca answered ${status.status}`); + const json = await status.json(); + if (!json?.enabled) return { enabled: false }; + const res = await fetchImpl(`${base}/api/moshpit/ca.crt`, opts); + if (!res.ok) throw new Error(`${base}/api/moshpit/ca.crt answered ${res.status}`); + return { + enabled: true, + pem: await res.text(), + fingerprint: json.root?.fingerprint_sha256 || null, + subject: json.root?.subject || null, + notAfter: json.root?.not_after || null, + }; +} + +/** The stores to install the registry root into. Pure; same shape as trustPlan. */ +export function registryTrustPlan({ platform = process.platform, home = os.homedir(), file, isRoot = false, haveCertutil = true } = {}) { + const steps = []; + const skipped = []; + for (const store of trustStores({ platform, home, caFile: file, nickname: REGISTRY_ROOT_NICKNAME, systemFile: REGISTRY_ROOT_SYSTEM_FILE })) { + if (store.id === "nss" && !haveCertutil) { + skipped.push({ ...store, why: "certutil is not installed (Debian/Ubuntu: libnss3-tools)" }); + continue; + } + if (store.needsRoot && !isRoot) { + skipped.push({ ...store, why: "needs root" }); + continue; + } + steps.push(store); + } + return { ok: true, steps, skipped, file }; +} + +/** + * Fetch, check and install the registry's root. Non-fatal throughout: names + * already resolve by the time this runs, and a trust store that could not be + * written is a line of output, not a reason to undo working DNS. + */ +export async function applyRegistryTrust(out, deps = {}) { + const { + runner = run, + env = process.env, + home = operatorHome({ env }), + platform = process.platform, + uid = typeof process.getuid === "function" ? process.getuid() : 0, + registryBase = DEFAULT_REGISTRY, + fetchImpl = fetch, + writeFile = async (f, body) => (await import("node:fs/promises")).writeFile(f, body, { mode: 0o644 }), + } = deps; + const owner = env.SUDO_USER || env.DOAS_USER || null; + const file = registryRootPath({ home }); + + out(""); + out("trust (the registry's root, so every Moshpit name is trusted at once)"); + + let got; + try { + got = await fetchRegistryRoot({ registryBase, fetchImpl }); + } catch (err) { + out(` -- could not reach the registry for its root: ${err?.message || err}`); + return { ok: false, why: "registry unreachable" }; + } + if (!got.enabled) { + out(" -- the registry publishes no root yet — names are trusted one at a time (dns trust )"); + return { ok: false, why: "no registry root" }; + } + const check = checkRegistryRoot(got.pem, { fingerprint: got.fingerprint }); + if (!check.ok) { + out(` STOP ${check.why}`); + return { ok: false, refused: true, why: check.why }; + } + + const dir = path.dirname(file); + const made = await runner("mkdir", ["-p", dir]); + if (!made.ok) { + out(` FAIL could not create ${dir} — ${made.stderr.split("\n")[0]}`); + return { ok: false, why: "could not write the root" }; + } + try { + await writeFile(file, got.pem); + } catch (err) { + out(` FAIL could not write ${file} — ${err?.message || err}`); + return { ok: false, why: "could not write the root" }; + } + if (owner && uid === 0) await runner("chown", ["-R", `${owner}:`, dir]); + out(` ok ${file} — ${check.subject}, ${check.fingerprint.slice(0, 23)}…, until ${String(check.notAfter).slice(0, 15)}`); + + const haveCertutil = (await runner("which", ["certutil"])).ok; + const plan = registryTrustPlan({ platform, home, file, isRoot: uid === 0, haveCertutil }); + let installed = 0; + for (const step of plan.steps) { + if (step.copyTo) { + const copied = await runner("cp", [file, step.copyTo]); + if (!copied.ok) { + out(` FAIL ${step.label} — ${copied.stderr.split("\n")[0] || "could not copy the root"}`); + continue; + } + } + const done = await runner(step.command, step.args); + if (!done.ok) { + out(` FAIL ${step.label} — ${done.stderr.split("\n")[0] || `${step.command} failed`}`); + continue; + } + if (step.ownedDir && owner && uid === 0) { + const owned = await runner("chown", ["-R", `${owner}:`, step.ownedDir]); + if (!owned.ok) out(` -- ${step.ownedDir} is left owned by root — chown -R ${owner}: ${step.ownedDir}`); + } + installed++; + out(` ok installed into ${step.label}`); + } + for (const step of plan.skipped) { + out(` -- ${step.label} — ${step.why}`); + if (step.needsRoot) out(" re-run with root to cover it: sudo moshcode dns ca"); + } + return { ok: true, installed, skipped: plan.skipped.length, file }; +} + +/** Take the registry's root back out of every store `applyRegistryTrust` writes. */ +export async function removeRegistryTrust(out, deps = {}) { + const { + runner = run, + env = process.env, + home = operatorHome({ env }), + platform = process.platform, + uid = typeof process.getuid === "function" ? process.getuid() : 0, + readFile = async (f) => (await import("node:fs/promises")).readFile(f, "utf8"), + } = deps; + const owner = env.SUDO_USER || env.DOAS_USER || null; + const file = registryRootPath({ home }); + const haveFile = await readFile(file).then(() => true, () => false); + const plan = untrustPlan({ + platform, home, caFile: file, isRoot: uid === 0, + haveCertutil: (await runner("which", ["certutil"])).ok, + haveFile, + nickname: REGISTRY_ROOT_NICKNAME, systemFile: REGISTRY_ROOT_SYSTEM_FILE, + }); + if (!plan.steps.length && !plan.skipped.length) return { ok: true, removed: 0, skipped: 0 }; + + out(""); + out("trust (taking the registry's root back out)"); + let removed = 0; + for (const step of plan.steps) { + const undo = step.remove; + if (undo.removeFile) { + const gone = await runner("rm", ["-f", undo.removeFile]); + if (!gone.ok) { + out(` FAIL ${step.label} — ${gone.stderr.split("\n")[0] || `could not remove ${undo.removeFile}`}`); + continue; + } + } + const done = await runner(undo.command, undo.args); + if (!done.ok) { + const first = done.stderr.split("\n")[0] || ""; + if (/SEC_ERROR_BAD_DATA|not found|PR_FILE_NOT_FOUND/i.test(first)) { + out(` ok ${step.label} — was not there`); + continue; + } + out(` FAIL ${step.label} — ${first || `${undo.command} failed`}`); + continue; + } + if (step.ownedDir && owner && uid === 0) { + const owned = await runner("chown", ["-R", `${owner}:`, step.ownedDir]); + if (!owned.ok) out(` -- ${step.ownedDir} is left owned by root — chown -R ${owner}: ${step.ownedDir}`); + } + removed++; + out(` ok removed from ${step.label}`); + } + if (haveFile) await runner("rm", ["-f", file]); + for (const step of plan.skipped) { + out(` -- ${step.label} — ${step.why}`); + if (step.needsRoot && uid !== 0) out(" re-run with root to cover it: sudo moshcode dns ca --remove"); + } + return { ok: true, removed, skipped: plan.skipped.length }; +} diff --git a/test/trust-registry.test.mjs b/test/trust-registry.test.mjs new file mode 100644 index 00000000..c4e109be --- /dev/null +++ b/test/trust-registry.test.mjs @@ -0,0 +1,178 @@ +/** + * Trusting the registry's root: the check that decides whether what the + * registry served is the root it says it is, the plan of stores it goes into, + * and the apply/remove paths with every side effect injected. + * + * The root is made with openssl here, the same way trust.test.mjs makes its + * roots; the tests skip when openssl is missing. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { X509Certificate } from "node:crypto"; + +import { + REGISTRY_ROOT_NICKNAME, REGISTRY_ROOT_SYSTEM_FILE, applyRegistryTrust, checkRegistryRoot, + fetchRegistryRoot, registryRootPath, registryTrustPlan, removeRegistryTrust, trustStores, untrustPlan, +} from "../src/trust.mjs"; + +function haveOpenssl() { + try { execFileSync("openssl", ["version"], { stdio: "ignore" }); return true; } catch { return false; } +} + +/** A self-signed certificate: a CA root, or a plain leaf, as asked. */ +function makeCert({ ca = true, cn = "Test Root" } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "moshcode-registry-root-")); + const key = path.join(dir, "k.pem"); + const crt = path.join(dir, "c.pem"); + const args = ["req", "-x509", "-new", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-sha256", "-days", "30", "-subj", `/CN=${cn}`, "-keyout", key, "-out", crt, + "-addext", `basicConstraints=critical,CA:${ca ? "TRUE" : "FALSE"}`]; + execFileSync("openssl", args, { stdio: "ignore" }); + const pem = fs.readFileSync(crt, "utf8"); + fs.rmSync(dir, { recursive: true, force: true }); + return pem; +} + +const skip = haveOpenssl() ? false : "openssl not installed"; + +test("a self-signed CA:TRUE root whose fingerprint matches is accepted", { skip }, () => { + const pem = makeCert(); + const fp = new X509Certificate(pem).fingerprint256; + const r = checkRegistryRoot(pem, { fingerprint: fp }); + assert.equal(r.ok, true, r.why); + assert.match(r.subject, /Test Root/); + // Fingerprint compared without regard to colons or case. + assert.equal(checkRegistryRoot(pem, { fingerprint: fp.replace(/:/g, "").toLowerCase() }).ok, true); +}); + +test("a fingerprint that does not match is refused", { skip }, () => { + const pem = makeCert(); + const r = checkRegistryRoot(pem, { fingerprint: "00".repeat(32) }); + assert.equal(r.ok, false); + assert.match(r.why, /does not match/); +}); + +test("a leaf is refused as a root, and so is garbage", { skip }, () => { + assert.match(checkRegistryRoot(makeCert({ ca: false })).why, /CA:TRUE/); + assert.match(checkRegistryRoot("not a certificate").why, /not a certificate/); +}); + +test("trustStores takes a nickname and a system file, and defaults to the local CA's", () => { + const local = trustStores({ platform: "linux", home: "/home/x", caFile: "/tmp/a.crt" }); + assert.ok(local.find((s) => s.id === "nss").args.includes("Moshpit Local CA")); + assert.equal(local.find((s) => s.id === "system").copyTo, "/usr/local/share/ca-certificates/moshpit-local-ca.crt"); + + const registry = trustStores({ platform: "linux", home: "/home/x", caFile: "/tmp/r.crt", + nickname: REGISTRY_ROOT_NICKNAME, systemFile: REGISTRY_ROOT_SYSTEM_FILE }); + assert.ok(registry.find((s) => s.id === "nss").args.includes("Moshpit Root CA")); + assert.equal(registry.find((s) => s.id === "system").copyTo, "/usr/local/share/ca-certificates/moshpit-root-ca.crt"); + assert.deepEqual(registry.find((s) => s.id === "nss").remove.args.slice(-2), ["-n", "Moshpit Root CA"]); +}); + +test("registryTrustPlan: NSS without root, the system store only as root, neither without certutil", () => { + const user = registryTrustPlan({ platform: "linux", home: "/home/x", file: "/f", isRoot: false, haveCertutil: true }); + assert.deepEqual(user.steps.map((s) => s.id), ["nss"]); + assert.deepEqual(user.skipped.map((s) => s.id), ["system"]); + const root = registryTrustPlan({ platform: "linux", home: "/home/x", file: "/f", isRoot: true, haveCertutil: true }); + assert.deepEqual(root.steps.map((s) => s.id), ["nss", "system"]); + const bare = registryTrustPlan({ platform: "linux", home: "/home/x", file: "/f", isRoot: false, haveCertutil: false }); + assert.deepEqual(bare.steps, []); + assert.deepEqual(bare.skipped.map((s) => s.id), ["nss", "system"]); +}); + +test("untrustPlan honours the registry nickname and system file", () => { + const p = untrustPlan({ platform: "linux", home: "/home/x", caFile: "/f", isRoot: true, + nickname: REGISTRY_ROOT_NICKNAME, systemFile: REGISTRY_ROOT_SYSTEM_FILE }); + assert.equal(p.steps.find((s) => s.id === "system").remove.removeFile, "/usr/local/share/ca-certificates/moshpit-root-ca.crt"); + assert.ok(p.steps.find((s) => s.id === "nss").remove.args.includes("Moshpit Root CA")); +}); + +/** A fetch that answers the two registry endpoints from memory. */ +function fakeRegistry({ enabled = true, pem = "", fingerprint = null }) { + return async (url) => { + if (url.endsWith("/api/moshpit/ca")) { + return { ok: true, status: 200, json: async () => (enabled ? { enabled: true, root: { fingerprint_sha256: fingerprint, subject: "CN=Test Root" } } : { enabled: false }) }; + } + if (url.endsWith("/api/moshpit/ca.crt")) return { ok: true, status: 200, text: async () => pem }; + return { ok: false, status: 404 }; + }; +} + +test("fetchRegistryRoot reports a registry without a CA, and the root of one with", { skip }, async () => { + const pem = makeCert(); + const fp = new X509Certificate(pem).fingerprint256; + assert.deepEqual(await fetchRegistryRoot({ fetchImpl: fakeRegistry({ enabled: false }) }), { enabled: false }); + const got = await fetchRegistryRoot({ fetchImpl: fakeRegistry({ pem, fingerprint: fp }) }); + assert.equal(got.enabled, true); + assert.equal(got.pem, pem); + assert.equal(got.fingerprint, fp); +}); + +test("applyRegistryTrust writes the root and installs it, with every command visible", { skip }, async () => { + const pem = makeCert(); + const fp = new X509Certificate(pem).fingerprint256; + const calls = []; + const written = {}; + const lines = []; + const runner = async (cmd, args) => { calls.push([cmd, ...args]); return { ok: true, stdout: "", stderr: "" }; }; + const r = await applyRegistryTrust((l) => lines.push(l), { + runner, env: { SUDO_USER: "alice" }, home: "/home/alice", platform: "linux", uid: 0, + fetchImpl: fakeRegistry({ pem, fingerprint: fp }), + writeFile: async (f, body) => { written[f] = body; }, + }); + assert.equal(r.ok, true); + assert.equal(r.installed, 2); + const file = registryRootPath({ home: "/home/alice" }); + assert.equal(written[file], pem, "the root is written where dns disable will look for it"); + assert.ok(calls.some((c) => c[0] === "certutil" && c.includes("-A") && c.includes("Moshpit Root CA")), "NSS import"); + assert.ok(calls.some((c) => c[0] === "cp" && c[2] === "/usr/local/share/ca-certificates/moshpit-root-ca.crt"), "system copy"); + assert.ok(calls.some((c) => c[0] === "update-ca-certificates"), "system refresh"); + assert.ok(calls.some((c) => c[0] === "chown" && c.at(-1) === "/home/alice/.pki/nssdb"), "the operator gets their database back"); + assert.ok(lines.some((l) => /installed into the NSS store/.test(l))); +}); + +test("applyRegistryTrust refuses a root that does not match the registry's fingerprint, and installs nothing", { skip }, async () => { + const pem = makeCert(); + const calls = []; + const lines = []; + const r = await applyRegistryTrust((l) => lines.push(l), { + runner: async (cmd, args) => { calls.push([cmd, ...args]); return { ok: true, stdout: "", stderr: "" }; }, + env: {}, home: "/home/alice", platform: "linux", uid: 1000, + fetchImpl: fakeRegistry({ pem, fingerprint: "00".repeat(32) }), + writeFile: async () => { throw new Error("must not be called"); }, + }); + assert.equal(r.ok, false); + assert.equal(r.refused, true); + assert.ok(lines.some((l) => /STOP/.test(l))); + assert.equal(calls.filter((c) => c[0] === "certutil").length, 0); +}); + +test("applyRegistryTrust says so when the registry has no CA, or cannot be reached", { skip }, async () => { + const lines = []; + const quiet = async () => ({ ok: true, stdout: "", stderr: "" }); + const none = await applyRegistryTrust((l) => lines.push(l), { runner: quiet, env: {}, home: "/h", platform: "linux", uid: 1000, fetchImpl: fakeRegistry({ enabled: false }) }); + assert.equal(none.ok, false); + assert.ok(lines.some((l) => /publishes no root yet/.test(l))); + const down = await applyRegistryTrust((l) => lines.push(l), { runner: quiet, env: {}, home: "/h", platform: "linux", uid: 1000, fetchImpl: async () => { throw new Error("ECONNREFUSED"); } }); + assert.equal(down.ok, false); + assert.ok(lines.some((l) => /could not reach the registry/.test(l))); +}); + +test("removeRegistryTrust takes the root out of both stores and deletes the file", async () => { + const calls = []; + const lines = []; + const r = await removeRegistryTrust((l) => lines.push(l), { + runner: async (cmd, args) => { calls.push([cmd, ...args]); return { ok: true, stdout: "", stderr: "" }; }, + env: {}, home: "/home/alice", platform: "linux", uid: 0, + readFile: async () => "PEM", + }); + assert.equal(r.ok, true); + assert.equal(r.removed, 2); + assert.ok(calls.some((c) => c[0] === "certutil" && c.includes("-D") && c.includes("Moshpit Root CA"))); + assert.ok(calls.some((c) => c[0] === "rm" && c[2] === "/usr/local/share/ca-certificates/moshpit-root-ca.crt")); + assert.ok(calls.some((c) => c[0] === "rm" && c[2] === registryRootPath({ home: "/home/alice" }))); +});