diff --git a/src/cli-schema.mjs b/src/cli-schema.mjs index 33ef1d8..a46782e 100644 --- a/src/cli-schema.mjs +++ b/src/cli-schema.mjs @@ -406,6 +406,7 @@ export const DNS_VERBS = [ { name: "service", description: "install or remove the background service" }, { 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" }, ]; /** Sub-verb tables, by the name a command's `verbs` field refers to. */ diff --git a/src/dns.mjs b/src/dns.mjs index 7ae5e42..fb08f6c 100644 --- a/src/dns.mjs +++ b/src/dns.mjs @@ -1930,7 +1930,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, verifyStockTls } from "./trust.mjs"; +import { applyTrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs"; import { readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -2012,6 +2012,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { verify = verifyResolution, bridgeStatus = daemonStatus, startBridge = startDaemon, + autoTrustImpl = createAutoTrust, stopBridge = stopDaemon, dropins = readDropins, manifestFile = manifestPath(), @@ -2045,6 +2046,10 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { return 0; } + if (sub === "trust") { + return trustName(rest.find((a) => !a.startsWith("-")) || "", out, { registryBase, ...deps }); + } + if (sub === "resolve") { const name = resolveArgument(rest); if (!name) { @@ -2156,6 +2161,18 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { // so a busy port answered with a node:dgram stack trace. This one is fatal // where the parking server's is not, so it ends the command rather than // carrying on: the shape serve.mjs uses for a step it cannot complete. + // Trust every name as it resolves, rather than one command per name. Only + // useful as root — the trust store is not writable otherwise — so it says + // so once here instead of failing per name, forever, in the query log. + const wantsTrustAll = rest.includes("--trust-all"); + if (wantsTrustAll && uid !== 0) { + out("! --trust-all needs root to write to the trust store — certificates will not be installed"); + } + const autoTrust = wantsTrustAll && uid === 0 + ? autoTrustImpl({ registryBase, out, uid }) + : null; + if (autoTrust) out("trusting names as they resolve — only where the registry publishes a matching pin"); + let server; try { server = await createServer({ @@ -2164,7 +2181,13 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { parkingAddress: park, upstreams, tldSet, - onQuery: ({ name, address }) => out(` ${name} → ${address || "NXDOMAIN"}`), + onQuery: ({ name, address, forwarded }) => { + out(` ${name} → ${address || "NXDOMAIN"}`); + // Only a name that actually resolved to something of ours. A forwarded + // clearnet name is not ours to trust, and NXDOMAIN has no origin to + // fetch a certificate from. + if (autoTrust && address && !forwarded) autoTrust.consider(name); + }, onError: (err) => out(`! resolver socket error — ${err?.message || err}`), }); } catch (err) { @@ -2228,12 +2251,17 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { const wanted = requiredPort(platform, port); let tlds = []; + let tldError = null; try { tlds = await fetchTldsImpl({ registryBase }); - } catch { + } catch (err) { // disable does not need the list on Linux, and on macOS a stale list is // better than refusing to clean up because the registry is unreachable. + // enable does need it, and the reason it is empty is the whole difference + // between "nobody has claimed an ending" and "we could not ask" — see the + // refusal below, which used to report the second as the first. tlds = []; + tldError = err?.message || String(err); } // Phase 1, and it runs before every other question is asked — including the @@ -2296,7 +2324,18 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) { } if (sub === "enable" && !tlds.length) { - out("no TLDs claimed yet — nothing to route"); + // Two very different situations, and reporting the second as the first + // sends someone to claim an ending they already own. The registry holds + // thousands; a machine that sees none of them has almost certainly failed + // to ask rather than found an empty namespace. + if (tldError) { + out(`could not read the ending list from ${registryBase} — ${tldError}`); + out(" nothing has been changed. This is a failure to ask, not an empty registry:"); + out(` check with curl -s '${registryBase}/api/moshpit/tlds?limit=5&offset=0'`); + } else { + out("the registry reports no claimed endings — nothing to route"); + out(` that is the registry's answer, not a local failure: ${registryBase}/api/moshpit/tlds`); + } return 1; } diff --git a/src/trust.mjs b/src/trust.mjs index e973ab8..c4a88ee 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -404,6 +404,292 @@ export async function applyTrust(tlds, out, deps = {}) { return { ok: true, installed: plan.steps.length, skipped: (plan.skipped || []).length }; } +/* ------------------------------------------------ trusting one name directly */ + +/** + * SHA-256 over the SubjectPublicKeyInfo, base64 — RFC 7469's pin format. + * + * Over the key rather than the certificate, so re-issuing for the same key (a + * longer expiry, an added name) does not invalidate a pin anybody holds. + */ +export async function spkiPin(publicKeyPem) { + const crypto = await import("node:crypto"); + const der = crypto.createPublicKey(publicKeyPem).export({ type: "spki", format: "der" }); + return crypto.createHash("sha256").update(der).digest("base64"); +} + +/** + * The pin of the key inside a certificate. + * + * Read with node's own X509 parser rather than by shelling out to openssl a + * second time: the pin is the value the whole decision turns on, and piping a + * PEM back through a shell to extract it adds quoting and a second process to + * the one step that must not go wrong quietly. + */ +export async function pinFromCertificate(pem) { + const crypto = await import("node:crypto"); + const cert = new crypto.X509Certificate(pem); + const der = cert.publicKey.export({ type: "spki", format: "der" }); + return crypto.createHash("sha256").update(der).digest("base64"); +} + +/** The pins the registry publishes for a name. */ +export async function publishedPins(name, { registryBase = "https://pit.moshcode.sh", fetchImpl = fetch } = {}) { + const url = `${registryBase.replace(/\/+$/, "")}/api/moshpit/pins?name=${encodeURIComponent(name)}`; + const res = await fetchImpl(url); + if (!res.ok) throw new Error(`registry answered ${res.status}`); + const json = await res.json(); + return Array.isArray(json?.pins) ? json.pins : []; +} + +/** + * Is this certificate the one the registry vouches for? + * + * The entire security of installing a leaf rests on this comparison, so it is + * a gate rather than a report. Without it, `trust ` would install + * whatever answered the socket — which is the definition of trusting an + * attacker who can reach the port first. + * + * Any published pin matches, not just the first: the registry lists the old + * pin alongside the new one during a key rotation precisely so a key can change + * without a flag day. + */ +export function pinAccepted(pin, published) { + if (!pin || !Array.isArray(published) || !published.length) { + return { ok: false, why: "the registry publishes no pin for this name — nothing vouches for the certificate" }; + } + return published.includes(pin) + ? { ok: true, why: "the served key matches a pin the registry publishes" } + : { ok: false, why: `the served key (${pin}) is not among the ${published.length} pin(s) the registry publishes` }; +} + +/** + * Where a trusted leaf is written, per name. + * + * One file per name rather than a bundle: removing trust for a single name has + * to be removing a single file, and a name is not something to hand-edit out of + * a concatenation. + */ +export function leafPath(name, { platform = process.platform } = {}) { + // The name reaches this from a registry response, so it is not trusted input. + // Runs of dots are collapsed rather than merely stripped of slashes: `..` is + // the traversal, and leaving `....` behind produces a filename nobody can + // match back to a name even though it escapes nothing. + const safe = String(name).toLowerCase().replace(/[^a-z0-9.-]/g, "").replace(/\.{2,}/g, ".").replace(/^[.-]+|[.-]+$/g, ""); + if (!safe || !safe.includes(".")) return null; + return platform === "darwin" + ? `/Library/Keychains/moshpit-${safe}.crt` + : `/usr/local/share/ca-certificates/moshpit-${safe}.crt`; +} + +/** + * What `trust ` should do, given what the socket served and what the + * registry says about it. + * + * Pure, so the refusal path is testable without a network or a trust store. + */ +export function leafTrustPlan({ name, pin, published, platform = process.platform } = {}) { + const accepted = pinAccepted(pin, published); + if (!accepted.ok) return { ok: false, refused: true, why: accepted.why }; + + const file = leafPath(name, { platform }); + if (!file) return { ok: false, why: `${name} is not a name that can be written to a file` }; + + return { + ok: true, + why: accepted.why, + file, + // A self-signed leaf is its own trust anchor, and its SAN limits it to this + // one name — so trusting it vouches for `seo.rank` and nothing else. That + // is a far smaller grant than a CA, which is why this path needs no + // name constraints argument to be defensible. + refresh: platform === "darwin" + ? { command: "security", args: ["add-trusted-cert", "-d", "-r", "trustRoot", "-k", "/Library/Keychains/System.keychain", file] } + : { command: "update-ca-certificates", args: [] }, + }; +} + +/** The certificate a name is actually serving, as PEM. */ +export function fetchCertificateCommand(name, { port = 443 } = {}) { + return { + command: "sh", + args: ["-c", `openssl s_client -connect ${name}:${port} -servername ${name} /dev/null | openssl x509`], + }; +} + +/** + * `moshcode dns trust ` — trust one name, on the strength of its pin. + * + * The path that needs no proxy and no certificate authority. A Moshpit name + * serves a self-signed certificate whose SAN names only itself, so trusting it + * vouches for that one name and nothing else — a far smaller grant than a root, + * and the reason this needs no name-constraints argument to be defensible. + * + * The pin check is what makes it safe rather than reckless. Installing whatever + * answered the socket is the definition of trusting whoever reached the port + * first; installing it only when the registry already vouches for that exact + * key is registry-backed trust, which is a stronger claim than domain + * validation ever made. + */ +export async function trustName(name, out, deps = {}) { + const { + registryBase = "https://pit.moshcode.sh", + fetchImpl = fetch, + runner = run, + platform = process.platform, + uid = typeof process.getuid === "function" ? process.getuid() : 0, + writeFile = async (f, c) => (await import("node:fs/promises")).writeFile(f, c), + } = deps; + + if (!name) { + out("which name? e.g. moshcode dns trust seo.rank"); + return 1; + } + + const fetchCert = fetchCertificateCommand(name); + const served = await runner(fetchCert.command, fetchCert.args); + if (!served.ok || !served.stdout.includes("BEGIN CERTIFICATE")) { + out(`could not read a certificate from ${name}:443`); + out(" the name must resolve and be serving HTTPS before its certificate can be trusted"); + return 1; + } + + const pin = await pinFromCertificate(served.stdout).catch(() => null); + if (!pin) { + out(`could not read the public key out of ${name}'s certificate`); + return 1; + } + + let published = []; + try { + published = await publishedPins(name, { registryBase, fetchImpl }); + } catch (err) { + // An outage is not a failed pin check, and must not be reported as one — + // the answer to "the registry is down" is to wait, not to distrust a name. + out(`could not reach the registry to check ${name}'s pin — ${err?.message || err}`); + out(" nothing has been trusted."); + return 1; + } + + const plan = leafTrustPlan({ name, pin, published, platform }); + if (!plan.ok) { + out(`REFUSED — ${plan.why}`); + if (plan.refused) { + out(` served ${pin}`); + out(published.length ? ` pinned ${published.join("\n ")}` : " pinned (none)"); + out(" moshcode will not trust a certificate the registry does not vouch for."); + } + return 1; + } + + out(`${name} — ${plan.why}`); + out(` pin ${pin}`); + + if (uid !== 0) { + out(` writing ${plan.file} needs root.`); + return 1; + } + + try { + await writeFile(plan.file, served.stdout); + } catch (err) { + out(` FAIL could not write ${plan.file} — ${err?.message || err}`); + return 1; + } + const refreshed = await runner(plan.refresh.command, plan.refresh.args); + if (!refreshed.ok) { + out(` FAIL ${plan.refresh.command} — ${refreshed.stderr.split("\n")[0] || "failed"}`); + return 1; + } + out(` ok trusted — curl https://${name} now verifies without flags`); + return 0; +} + +/** + * Trust every name as it is resolved, instead of one command per name. + * + * `dns trust ` works and does not scale: a person browsing Moshpit hits a + * certificate error on every site they have not personally thought about, which + * is indistinguishable from the namespace being broken. + * + * The registry pin is what makes doing it automatically defensible. Nothing is + * trusted on sight — a name is trusted only when the key it serves is one the + * registry already published for it, which is a stronger claim than domain + * validation ever made. A name with no pin gets nothing, silently and forever. + * + * Three properties this has to have, and each one is a way it could go wrong: + * + * - it must never block a DNS answer. Resolution is on the critical path of + * every page load; certificate work is not. + * - it must ask about a name once, not once per query. A browser sends A and + * AAAA together and retries, so "on resolve" is a firehose. + * - a failure must be quiet and final for that name until restart. Retrying a + * name whose pin does not match, on every lookup, is a loop that writes a + * log line per query and never succeeds. + */ +export function createAutoTrust({ + trust = trustName, + out = () => {}, + registryBase, + uid = typeof process.getuid === "function" ? process.getuid() : 0, + ...deps +} = {}) { + // One entry per name for the life of the process: `true` while in flight or + // done, so neither a success nor a refusal is ever retried. + const seen = new Set(); + const pending = []; + // The in-flight drain, not a boolean. A flag can say "someone else is + // draining", but it cannot be awaited — so `idle()` returned the moment it + // saw one, reporting a queue as settled while it was still being worked. + let running = null; + + async function drain() { + if (running) return running; + running = (async () => { + try { + while (pending.length) { + const name = pending.shift(); + // Output is deliberately only the interesting half. A resolver that + // narrated a success per name would bury its own query log. + const lines = []; + const code = await trust(name, (l) => lines.push(l), { registryBase, uid, ...deps }) + .catch(() => 1); + if (code === 0) out(` trusted ${name}`); + else if (lines.some((l) => l.startsWith("REFUSED"))) out(` ! ${name} — ${lines[0]}`); + } + } finally { + running = null; + } + })(); + return running; + } + + return { + /** Consider a name for trust. Returns immediately; never throws. */ + consider(name) { + if (!name || seen.has(name)) return false; + seen.add(name); + pending.push(name); + // Detached on purpose: the caller is a UDP handler with a reply to send. + queueMicrotask(() => { drain().catch(() => {}); }); + return true; + }, + /** + * Settle whatever is queued, including work added while draining. + * + * Looped rather than awaited once: a name considered mid-drain joins the + * queue behind the current pass, so a single await can return with items + * still waiting. + */ + async idle() { + while (running || pending.length) await drain(); + }, + get size() { + return seen.size; + }, + }; +} + /** * A one-line proof that the whole chain works, or the reason it does not. * diff --git a/test/trust-all.test.mjs b/test/trust-all.test.mjs new file mode 100644 index 0000000..bef5a13 --- /dev/null +++ b/test/trust-all.test.mjs @@ -0,0 +1,147 @@ +/** + * Trusting names as they resolve, instead of one command per name. + * + * `dns trust ` works and does not scale: someone browsing Moshpit meets a + * certificate error on every site they have not personally thought about, which + * is indistinguishable from the namespace being broken. + * + * The registry pin is what makes doing it automatically defensible rather than + * reckless — nothing is trusted on sight, only a key the registry already + * published for that name. These tests are about the three ways the automation + * itself could go wrong, none of which are about cryptography: + * + * - blocking a DNS answer on certificate work + * - asking about a name once per query rather than once + * - retrying a name that will never succeed, forever, one line per lookup + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createAutoTrust } from "../src/trust.mjs"; + +/** An auto-truster over a fake `trustName`, recording what it was asked. */ +function harness({ refuse = [], fail = [] } = {}) { + const asked = []; + const out = []; + const auto = createAutoTrust({ + out: (l) => out.push(l), + trust: async (name, say) => { + asked.push(name); + if (refuse.includes(name)) { + say(`REFUSED — the served key is not among the pins the registry publishes`); + return 1; + } + if (fail.includes(name)) { + say("could not reach the registry to check the pin — ECONNREFUSED"); + return 1; + } + return 0; + }, + }); + return { auto, asked, out }; +} + +test("a name is asked about once, however many times it is looked up", async () => { + // A browser sends A and AAAA together and retries. "On resolve" is a firehose, + // and one certificate fetch per query would be a self-inflicted outage. + const h = harness(); + for (let i = 0; i < 5; i++) { + for (const name of ["seo.rank", "chovy.hacker"]) h.auto.consider(name); + } + await h.auto.idle(); + + assert.deepEqual(h.asked, ["seo.rank", "chovy.hacker"]); + assert.equal(h.asked.length, 2, "10 lookups, 2 certificate fetches"); +}); + +test("consider() returns before any of the work happens", async () => { + // It is called from a UDP handler that owes a client a reply. + const h = harness(); + const accepted = h.auto.consider("seo.rank"); + assert.equal(accepted, true); + assert.deepEqual(h.asked, [], "nothing has run yet — the caller is already free"); + await h.auto.idle(); + assert.deepEqual(h.asked, ["seo.rank"]); +}); + +test("a refused name is never asked about again", async () => { + // Otherwise every lookup of a name whose pin does not match writes a log line + // and fails, forever. + const h = harness({ refuse: ["evil.rank"] }); + h.auto.consider("evil.rank"); + await h.auto.idle(); + assert.deepEqual(h.asked, ["evil.rank"]); + + h.auto.consider("evil.rank"); + await h.auto.idle(); + assert.equal(h.asked.length, 1, "still one attempt"); +}); + +test("a refusal is reported, because it is the one outcome worth seeing", async () => { + const h = harness({ refuse: ["evil.rank"] }); + h.auto.consider("evil.rank"); + await h.auto.idle(); + assert.match(h.out.join("\n"), /evil\.rank/); + assert.match(h.out.join("\n"), /REFUSED/); +}); + +test("a registry outage is not narrated per name", async () => { + // If the registry is down, every name fails. Saying so once per name turns + // the query log into the outage. + const h = harness({ fail: ["a.rank", "b.rank", "c.rank"] }); + for (const n of ["a.rank", "b.rank", "c.rank"]) h.auto.consider(n); + await h.auto.idle(); + assert.equal(h.out.length, 0, "nothing printed for a transport failure"); +}); + +test("a success says so once", async () => { + const h = harness(); + h.auto.consider("seo.rank"); + await h.auto.idle(); + assert.deepEqual(h.out, [" trusted seo.rank"]); +}); + +test("an empty name is ignored rather than queued", async () => { + const h = harness(); + assert.equal(h.auto.consider(""), false); + assert.equal(h.auto.consider(null), false); + await h.auto.idle(); + assert.deepEqual(h.asked, []); +}); + +test("a thrown trust attempt does not take the resolver down", async () => { + // This runs detached from the query handler, so an unhandled rejection here + // is a process exit on a box whose whole job is to stay up. + const out = []; + const auto = createAutoTrust({ + out: (l) => out.push(l), + trust: async () => { throw new Error("boom"); }, + }); + auto.consider("seo.rank"); + await auto.idle(); + assert.equal(out.length, 0); +}); + +test("names queued while one is in flight are all still handled", async () => { + // The drain loop is single-flight; anything arriving mid-drain has to be + // picked up rather than dropped on the floor. + const asked = []; + let release; + const gate = new Promise((r) => { release = r; }); + const auto = createAutoTrust({ + trust: async (name) => { + asked.push(name); + if (name === "first.rank") await gate; + return 0; + }, + }); + + auto.consider("first.rank"); + await Promise.resolve(); + auto.consider("second.rank"); + auto.consider("third.rank"); + release(); + await auto.idle(); + + assert.deepEqual(asked.sort(), ["first.rank", "second.rank", "third.rank"]); +}); diff --git a/test/trust.test.mjs b/test/trust.test.mjs index 9275ee1..3a2facc 100644 --- a/test/trust.test.mjs +++ b/test/trust.test.mjs @@ -431,3 +431,141 @@ test("the refusal a session prints carries its remedy", async () => { assert.match(text, /STOP/); assert.match(text, /not overridable/, "the STOP line alone is not actionable"); }); + +/* ---------------------------------------- trusting one name on the strength of its pin */ + +import { fetchCertificateCommand, leafPath, leafTrustPlan, pinAccepted, pinFromCertificate, trustName } from "../src/trust.mjs"; + +/** A real self-signed leaf, the shape a Moshpit origin serves. */ +function leaf(cn = "seo.rank") { + try { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "leaf-")); + const key = path.join(dir, "k.pem"); + const crt = path.join(dir, "c.pem"); + execFileSync("openssl", [ + "req", "-x509", "-nodes", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", + "-keyout", key, "-out", crt, "-days", "1", "-subj", `/CN=${cn}`, "-addext", `subjectAltName=DNS:${cn}`, + ], { stdio: "ignore" }); + const pem = fs.readFileSync(crt, "utf8"); + fs.rmSync(dir, { recursive: true, force: true }); + return pem; + } catch { + return null; + } +} + +test("a pin is SHA-256 over the SPKI, so re-issuing for the same key keeps it valid", async () => { + const pem = leaf(); + if (!pem) return; + const pin = await pinFromCertificate(pem); + assert.equal(Buffer.from(pin, "base64").length, 32); + assert.equal(await pinFromCertificate(pem), pin, "the same certificate always gives the same pin"); +}); + +test("any published pin matches, because rotation lists both", () => { + // The registry keeps the old pin beside the new one during a key change + // precisely so there is no flag day; accepting only the first would undo that. + assert.equal(pinAccepted("B", ["A", "B"]).ok, true); + assert.equal(pinAccepted("A", ["A", "B"]).ok, true); +}); + +test("a key the registry does not vouch for is refused", () => { + const verdict = pinAccepted("SERVED=", ["OTHER="]); + assert.equal(verdict.ok, false); + assert.match(verdict.why, /SERVED=/, "the served key is named, so the mismatch is checkable"); +}); + +test("a name with no published pin is refused rather than trusted on sight", () => { + // Installing whatever answered the socket is the definition of trusting + // whoever reached the port first. + assert.equal(pinAccepted("SERVED=", []).ok, false); + assert.match(pinAccepted("SERVED=", []).why, /nothing vouches/); +}); + +test("the leaf is written per name, and a name cannot escape the directory", () => { + assert.equal(leafPath("seo.rank", { platform: "linux" }), "/usr/local/share/ca-certificates/moshpit-seo.rank.crt"); + assert.equal(leafPath("../../etc/passwd", { platform: "linux" }), null, "traversal is refused, not sanitised into nonsense"); + assert.equal(leafPath("localhost", { platform: "linux" }), null, "a single label is not a Moshpit name"); + assert.equal(leafPath("", { platform: "linux" }), null); +}); + +test("the certificate is read from the name itself, with SNI set", () => { + const { args } = fetchCertificateCommand("seo.rank"); + assert.match(args.join(" "), /-servername seo\.rank/, "without SNI a shared origin serves the wrong certificate"); +}); + +test("a mismatched pin installs nothing at all", async () => { + const pem = leaf(); + if (!pem) return; + const lines = []; + const ran = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => { + ran.push(c); + return a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }; + }, + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: ["NOTTHISONE="] }) }), + writeFile: async () => assert.fail("nothing may be written when the pin does not match"), + uid: 0, platform: "linux", + }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /REFUSED/); + assert.ok(!ran.includes("update-ca-certificates"), "and the store is never refreshed"); +}); + +test("a registry outage is not reported as a failed pin check", async () => { + // "the registry is down" is answered by waiting, not by distrusting a name. + const pem = leaf(); + if (!pem) return; + const lines = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => { throw new Error("ECONNREFUSED"); }, + writeFile: async () => assert.fail("nothing may be written"), + uid: 0, platform: "linux", + }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /could not reach the registry/); + // Anchored, because ECONNREFUSED contains the word — which is itself the + // confusion being guarded against, one layer down. + assert.doesNotMatch(lines.join("\n"), /^REFUSED/m, "an outage must not read as a rejected certificate"); +}); + +test("a matching pin is installed and reported as usable", async () => { + const pem = leaf(); + if (!pem) return; + const pin = await pinFromCertificate(pem); + const lines = []; + let written = null; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: [pin] }) }), + writeFile: async (f, c) => { written = { f, c }; }, + uid: 0, platform: "linux", + }); + assert.equal(code, 0); + assert.equal(written.f, "/usr/local/share/ca-certificates/moshpit-seo.rank.crt"); + assert.ok(written.c.includes("BEGIN CERTIFICATE")); + assert.match(lines.join("\n"), /verifies without flags/); +}); + +test("without root it says so instead of failing obscurely on the write", async () => { + const pem = leaf(); + if (!pem) return; + const pin = await pinFromCertificate(pem); + const lines = []; + const code = await trustName("seo.rank", (l) => lines.push(l), { + runner: async (c, a) => (a.join(" ").includes("s_client") ? { ok: true, stdout: pem, stderr: "" } : { ok: true, stdout: "", stderr: "" }), + fetchImpl: async () => ({ ok: true, json: async () => ({ pins: [pin] }) }), + writeFile: async () => assert.fail("must not attempt the write"), + uid: 1000, platform: "linux", + }); + assert.equal(code, 1); + assert.match(lines.join("\n"), /needs root/); +}); + +test("no name asks for one rather than guessing", async () => { + const lines = []; + assert.equal(await trustName("", (l) => lines.push(l), {}), 1); + assert.match(lines.join("\n"), /which name/); +});