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" }))); +});