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