From 7c6d0b34e9e4a713fcf0161eb95e1dab555b5459 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 3 Aug 2026 18:33:21 +0000 Subject: [PATCH] feat(dns): --trust-all, so every name works without a command per name 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"]); +});