From 63c42eaee44212c92bbd7f913612b02d2d230c62 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 18:19:13 +0000 Subject: [PATCH 1/2] feat(dns): trust a name on the strength of its pin, and stop blaming the registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two failures from the same first run, neither of which the machine could explain. 1. `dns enable` printed "no TLDs claimed yet — nothing to route" on a machine where the registry holds 5674 endings. The fetch had failed and the failure was swallowed to an empty array, so a network problem was reported as a fact about the registry — sending someone to claim an ending they already own. Same bug as the one fixed in `dns start`; this is the other half of it. The two cases now read differently, and the failure prints the curl that checks it. 2. The certificate error survived everything. moshpit-proxy verifies the registry pin and re-signs with a local root, but nothing routes to it: the resolver answers the origin, the proxy sits on loopback, and the two were never connected. So the root installed by #275 signs nothing, and `curl https://seo.rank` still meets the origin's self-signed certificate. `dns trust ` closes that without the proxy at all. A Moshpit name serves a self-signed leaf whose SAN names only itself, so trusting it vouches for that one name — a far smaller grant than a root, and why this path needs no name-constraints argument to be defensible. The pin check is what separates it from recklessness. Installing whatever answered the socket is 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. Every published pin is accepted, not just the first, because the registry lists the old one beside the new during a rotation precisely so a key can change without a flag day. A registry outage is reported as an outage, never as a rejected certificate: the answer to "the registry is down" is to wait, not to distrust a name. Verified against the live registry — the key seo.rank serves matches the pin the Pit publishes, and trusting that leaf takes curl from error 60 to 200. Co-Authored-By: Claude Opus 5 --- src/cli-schema.mjs | 1 + src/dns.mjs | 26 +++++- src/trust.mjs | 201 ++++++++++++++++++++++++++++++++++++++++++++ test/trust.test.mjs | 138 ++++++++++++++++++++++++++++++ 4 files changed, 363 insertions(+), 3 deletions(-) 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..c903a5a 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, trustName, verifyStockTls } from "./trust.mjs"; import { readFile, writeFile } from "node:fs/promises"; import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -2045,6 +2045,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) { @@ -2228,12 +2232,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 +2305,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..ee6b9e9 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -404,6 +404,207 @@ 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; +} + /** * A one-line proof that the whole chain works, or the reason it does not. * 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/); +}); From b7694bc1807e2f22f9e24c8ca34359a32f572512 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 17:59:08 -0700 Subject: [PATCH 2/2] feat(dns): --trust-all, so every name works without a command per name (#281) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. `dns start --trust-all` trusts a name as it resolves: fetch the certificate it serves, check the key against the pin the registry published for that name, install it only on a match. Nothing is trusted on sight — a name with no published pin gets nothing, silently and forever — so this is registry-backed trust rather than trust-on-first-use. Three ways the automation could go wrong, none about cryptography: - blocking a DNS answer on certificate work. consider() queues and returns; the drain runs detached from the query handler. - asking once per query rather than once per name. A browser sends A and AAAA together and retries, so "on resolve" is a firehose: ten lookups of two names is two certificate fetches. - retrying a name that will never succeed. A refusal is final for that name until restart, or every lookup writes a log line and fails. Only a name that actually resolved to one of ours is considered: a forwarded clearnet name is not ours to trust, and NXDOMAIN has no origin to fetch from. Without root it says so once, rather than failing per name forever in the query log. A registry outage is not narrated per name — if the registry is down every name fails, and saying so each time turns the query log into the outage. Only refusals and successes are reported. Fixed while testing: idle() awaited a boolean rather than the in-flight drain, so it reported a queue as settled while it was still being worked. A flag can say someone else is draining; it cannot be awaited. Stacked on #279, which added the per-name command this automates. Co-authored-by: Claude Opus 5 --- src/dns.mjs | 23 ++++++- src/trust.mjs | 85 +++++++++++++++++++++++ test/trust-all.test.mjs | 147 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+), 2 deletions(-) create mode 100644 test/trust-all.test.mjs diff --git a/src/dns.mjs b/src/dns.mjs index c903a5a..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, trustName, 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(), @@ -2160,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({ @@ -2168,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) { diff --git a/src/trust.mjs b/src/trust.mjs index ee6b9e9..c4a88ee 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -605,6 +605,91 @@ export async function trustName(name, out, deps = {}) { 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"]); +});