Skip to content

Commit b7694bc

Browse files
ralyodioclaude
andauthored
feat(dns): --trust-all, so every name works without a command per name (#281)
`dns trust <name>` 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 <noreply@anthropic.com>
1 parent 63c42ea commit b7694bc

3 files changed

Lines changed: 253 additions & 2 deletions

File tree

src/dns.mjs

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1930,7 +1930,7 @@ import { createParkingServer, DEFAULT_PARKING_HTTP_PORT } from "./parking-http.m
19301930
// use it without importing this one back.
19311931
export { pitNameUrl } from "./pit-url.mjs";
19321932
import { pitNameUrl } from "./pit-url.mjs";
1933-
import { applyTrust, trustName, verifyStockTls } from "./trust.mjs";
1933+
import { applyTrust, createAutoTrust, trustName, verifyStockTls } from "./trust.mjs";
19341934
import { readFile, writeFile } from "node:fs/promises";
19351935
import { existsSync } from "node:fs";
19361936
import { fileURLToPath } from "node:url";
@@ -2012,6 +2012,7 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
20122012
verify = verifyResolution,
20132013
bridgeStatus = daemonStatus,
20142014
startBridge = startDaemon,
2015+
autoTrustImpl = createAutoTrust,
20152016
stopBridge = stopDaemon,
20162017
dropins = readDropins,
20172018
manifestFile = manifestPath(),
@@ -2160,6 +2161,18 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
21602161
// so a busy port answered with a node:dgram stack trace. This one is fatal
21612162
// where the parking server's is not, so it ends the command rather than
21622163
// carrying on: the shape serve.mjs uses for a step it cannot complete.
2164+
// Trust every name as it resolves, rather than one command per name. Only
2165+
// useful as root — the trust store is not writable otherwise — so it says
2166+
// so once here instead of failing per name, forever, in the query log.
2167+
const wantsTrustAll = rest.includes("--trust-all");
2168+
if (wantsTrustAll && uid !== 0) {
2169+
out("! --trust-all needs root to write to the trust store — certificates will not be installed");
2170+
}
2171+
const autoTrust = wantsTrustAll && uid === 0
2172+
? autoTrustImpl({ registryBase, out, uid })
2173+
: null;
2174+
if (autoTrust) out("trusting names as they resolve — only where the registry publishes a matching pin");
2175+
21632176
let server;
21642177
try {
21652178
server = await createServer({
@@ -2168,7 +2181,13 @@ export async function dnsCommand(args = [], out = console.log, deps = {}) {
21682181
parkingAddress: park,
21692182
upstreams,
21702183
tldSet,
2171-
onQuery: ({ name, address }) => out(` ${name}${address || "NXDOMAIN"}`),
2184+
onQuery: ({ name, address, forwarded }) => {
2185+
out(` ${name}${address || "NXDOMAIN"}`);
2186+
// Only a name that actually resolved to something of ours. A forwarded
2187+
// clearnet name is not ours to trust, and NXDOMAIN has no origin to
2188+
// fetch a certificate from.
2189+
if (autoTrust && address && !forwarded) autoTrust.consider(name);
2190+
},
21722191
onError: (err) => out(`! resolver socket error — ${err?.message || err}`),
21732192
});
21742193
} catch (err) {

src/trust.mjs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -605,6 +605,91 @@ export async function trustName(name, out, deps = {}) {
605605
return 0;
606606
}
607607

608+
/**
609+
* Trust every name as it is resolved, instead of one command per name.
610+
*
611+
* `dns trust <name>` works and does not scale: a person browsing Moshpit hits a
612+
* certificate error on every site they have not personally thought about, which
613+
* is indistinguishable from the namespace being broken.
614+
*
615+
* The registry pin is what makes doing it automatically defensible. Nothing is
616+
* trusted on sight — a name is trusted only when the key it serves is one the
617+
* registry already published for it, which is a stronger claim than domain
618+
* validation ever made. A name with no pin gets nothing, silently and forever.
619+
*
620+
* Three properties this has to have, and each one is a way it could go wrong:
621+
*
622+
* - it must never block a DNS answer. Resolution is on the critical path of
623+
* every page load; certificate work is not.
624+
* - it must ask about a name once, not once per query. A browser sends A and
625+
* AAAA together and retries, so "on resolve" is a firehose.
626+
* - a failure must be quiet and final for that name until restart. Retrying a
627+
* name whose pin does not match, on every lookup, is a loop that writes a
628+
* log line per query and never succeeds.
629+
*/
630+
export function createAutoTrust({
631+
trust = trustName,
632+
out = () => {},
633+
registryBase,
634+
uid = typeof process.getuid === "function" ? process.getuid() : 0,
635+
...deps
636+
} = {}) {
637+
// One entry per name for the life of the process: `true` while in flight or
638+
// done, so neither a success nor a refusal is ever retried.
639+
const seen = new Set();
640+
const pending = [];
641+
// The in-flight drain, not a boolean. A flag can say "someone else is
642+
// draining", but it cannot be awaited — so `idle()` returned the moment it
643+
// saw one, reporting a queue as settled while it was still being worked.
644+
let running = null;
645+
646+
async function drain() {
647+
if (running) return running;
648+
running = (async () => {
649+
try {
650+
while (pending.length) {
651+
const name = pending.shift();
652+
// Output is deliberately only the interesting half. A resolver that
653+
// narrated a success per name would bury its own query log.
654+
const lines = [];
655+
const code = await trust(name, (l) => lines.push(l), { registryBase, uid, ...deps })
656+
.catch(() => 1);
657+
if (code === 0) out(` trusted ${name}`);
658+
else if (lines.some((l) => l.startsWith("REFUSED"))) out(` ! ${name}${lines[0]}`);
659+
}
660+
} finally {
661+
running = null;
662+
}
663+
})();
664+
return running;
665+
}
666+
667+
return {
668+
/** Consider a name for trust. Returns immediately; never throws. */
669+
consider(name) {
670+
if (!name || seen.has(name)) return false;
671+
seen.add(name);
672+
pending.push(name);
673+
// Detached on purpose: the caller is a UDP handler with a reply to send.
674+
queueMicrotask(() => { drain().catch(() => {}); });
675+
return true;
676+
},
677+
/**
678+
* Settle whatever is queued, including work added while draining.
679+
*
680+
* Looped rather than awaited once: a name considered mid-drain joins the
681+
* queue behind the current pass, so a single await can return with items
682+
* still waiting.
683+
*/
684+
async idle() {
685+
while (running || pending.length) await drain();
686+
},
687+
get size() {
688+
return seen.size;
689+
},
690+
};
691+
}
692+
608693
/**
609694
* A one-line proof that the whole chain works, or the reason it does not.
610695
*

test/trust-all.test.mjs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
/**
2+
* Trusting names as they resolve, instead of one command per name.
3+
*
4+
* `dns trust <name>` works and does not scale: someone browsing Moshpit meets a
5+
* certificate error on every site they have not personally thought about, which
6+
* is indistinguishable from the namespace being broken.
7+
*
8+
* The registry pin is what makes doing it automatically defensible rather than
9+
* reckless — nothing is trusted on sight, only a key the registry already
10+
* published for that name. These tests are about the three ways the automation
11+
* itself could go wrong, none of which are about cryptography:
12+
*
13+
* - blocking a DNS answer on certificate work
14+
* - asking about a name once per query rather than once
15+
* - retrying a name that will never succeed, forever, one line per lookup
16+
*/
17+
import test from "node:test";
18+
import assert from "node:assert/strict";
19+
20+
import { createAutoTrust } from "../src/trust.mjs";
21+
22+
/** An auto-truster over a fake `trustName`, recording what it was asked. */
23+
function harness({ refuse = [], fail = [] } = {}) {
24+
const asked = [];
25+
const out = [];
26+
const auto = createAutoTrust({
27+
out: (l) => out.push(l),
28+
trust: async (name, say) => {
29+
asked.push(name);
30+
if (refuse.includes(name)) {
31+
say(`REFUSED — the served key is not among the pins the registry publishes`);
32+
return 1;
33+
}
34+
if (fail.includes(name)) {
35+
say("could not reach the registry to check the pin — ECONNREFUSED");
36+
return 1;
37+
}
38+
return 0;
39+
},
40+
});
41+
return { auto, asked, out };
42+
}
43+
44+
test("a name is asked about once, however many times it is looked up", async () => {
45+
// A browser sends A and AAAA together and retries. "On resolve" is a firehose,
46+
// and one certificate fetch per query would be a self-inflicted outage.
47+
const h = harness();
48+
for (let i = 0; i < 5; i++) {
49+
for (const name of ["seo.rank", "chovy.hacker"]) h.auto.consider(name);
50+
}
51+
await h.auto.idle();
52+
53+
assert.deepEqual(h.asked, ["seo.rank", "chovy.hacker"]);
54+
assert.equal(h.asked.length, 2, "10 lookups, 2 certificate fetches");
55+
});
56+
57+
test("consider() returns before any of the work happens", async () => {
58+
// It is called from a UDP handler that owes a client a reply.
59+
const h = harness();
60+
const accepted = h.auto.consider("seo.rank");
61+
assert.equal(accepted, true);
62+
assert.deepEqual(h.asked, [], "nothing has run yet — the caller is already free");
63+
await h.auto.idle();
64+
assert.deepEqual(h.asked, ["seo.rank"]);
65+
});
66+
67+
test("a refused name is never asked about again", async () => {
68+
// Otherwise every lookup of a name whose pin does not match writes a log line
69+
// and fails, forever.
70+
const h = harness({ refuse: ["evil.rank"] });
71+
h.auto.consider("evil.rank");
72+
await h.auto.idle();
73+
assert.deepEqual(h.asked, ["evil.rank"]);
74+
75+
h.auto.consider("evil.rank");
76+
await h.auto.idle();
77+
assert.equal(h.asked.length, 1, "still one attempt");
78+
});
79+
80+
test("a refusal is reported, because it is the one outcome worth seeing", async () => {
81+
const h = harness({ refuse: ["evil.rank"] });
82+
h.auto.consider("evil.rank");
83+
await h.auto.idle();
84+
assert.match(h.out.join("\n"), /evil\.rank/);
85+
assert.match(h.out.join("\n"), /REFUSED/);
86+
});
87+
88+
test("a registry outage is not narrated per name", async () => {
89+
// If the registry is down, every name fails. Saying so once per name turns
90+
// the query log into the outage.
91+
const h = harness({ fail: ["a.rank", "b.rank", "c.rank"] });
92+
for (const n of ["a.rank", "b.rank", "c.rank"]) h.auto.consider(n);
93+
await h.auto.idle();
94+
assert.equal(h.out.length, 0, "nothing printed for a transport failure");
95+
});
96+
97+
test("a success says so once", async () => {
98+
const h = harness();
99+
h.auto.consider("seo.rank");
100+
await h.auto.idle();
101+
assert.deepEqual(h.out, [" trusted seo.rank"]);
102+
});
103+
104+
test("an empty name is ignored rather than queued", async () => {
105+
const h = harness();
106+
assert.equal(h.auto.consider(""), false);
107+
assert.equal(h.auto.consider(null), false);
108+
await h.auto.idle();
109+
assert.deepEqual(h.asked, []);
110+
});
111+
112+
test("a thrown trust attempt does not take the resolver down", async () => {
113+
// This runs detached from the query handler, so an unhandled rejection here
114+
// is a process exit on a box whose whole job is to stay up.
115+
const out = [];
116+
const auto = createAutoTrust({
117+
out: (l) => out.push(l),
118+
trust: async () => { throw new Error("boom"); },
119+
});
120+
auto.consider("seo.rank");
121+
await auto.idle();
122+
assert.equal(out.length, 0);
123+
});
124+
125+
test("names queued while one is in flight are all still handled", async () => {
126+
// The drain loop is single-flight; anything arriving mid-drain has to be
127+
// picked up rather than dropped on the floor.
128+
const asked = [];
129+
let release;
130+
const gate = new Promise((r) => { release = r; });
131+
const auto = createAutoTrust({
132+
trust: async (name) => {
133+
asked.push(name);
134+
if (name === "first.rank") await gate;
135+
return 0;
136+
},
137+
});
138+
139+
auto.consider("first.rank");
140+
await Promise.resolve();
141+
auto.consider("second.rank");
142+
auto.consider("third.rank");
143+
release();
144+
await auto.idle();
145+
146+
assert.deepEqual(asked.sort(), ["first.rank", "second.rank", "third.rank"]);
147+
});

0 commit comments

Comments
 (0)