diff --git a/src/dns-service.mjs b/src/dns-service.mjs index f928722..d450585 100644 --- a/src/dns-service.mjs +++ b/src/dns-service.mjs @@ -250,10 +250,14 @@ export function proxyServiceUnit({ `Environment=MOSHPIT_PROXY_PORT=${port}`, `Environment=MOSHPIT_PROXY_DIR=${dir}`, ]; - // Only the endings it is asked to serve. Left unset it defaults to `.moshpit` - // alone, which is why a proxy can be running, healthy, and unable to present - // a certificate for the name someone is actually trying to reach. - if (tlds.length) lines.push(`Environment=MOSHPIT_PROXY_TLDS=${tlds.join(",")}`); + // Deliberately not written any more. + // + // It used to be set from the endings the registry had sold — 18224 of them, a + // ~150 KB environment variable in a unit file, for a list stale the next time + // one is sold. moshpit-proxy now reads an unset value as "every Moshpit + // ending", defining the namespace by excluding the real internet rather than + // by enumerating what Moshpit owns, so there is nothing left to pass. Setting + // it there still narrows, which is a deployment's choice and not this unit's. lines.push( `ExecStart=${wrapper}`, diff --git a/src/trust.mjs b/src/trust.mjs index 843aee5..e9d9ae7 100644 --- a/src/trust.mjs +++ b/src/trust.mjs @@ -20,6 +20,7 @@ import path from "node:path"; import os from "node:os"; import { execFileSync } from "node:child_process"; +import { IANA_TLDS } from "./iana-tlds.mjs"; /** Where moshpit-proxy generates its root on first run. */ export function caPath({ home = os.homedir(), dir = null } = {}) { @@ -139,15 +140,53 @@ export function requireNameConstraints(text, { tlds = [] } = {}) { // `excluded;DNS:.hacker` alone is an unconstrained root wearing the word // "constraints". Requiring a permitted DNS subtree is what makes the rest of // this check mean anything. + const bare = (entry) => entry.replace(/^\./, ""); + + // Two shapes are acceptable, and they make the same promise a different way. + // + // The old one permits a list of Moshpit endings and is bounded by what it + // names. It cannot scale: there are 18224 endings, a permitted subtree each + // is roughly 214 KB of constraints on every handshake, and the list is stale + // the next time the registry sells one — which is why a machine could reach + // `.2600` over HTTPS and not `.hacker`. + // + // The new one names nothing and excludes the real internet instead: all 1438 + // delegated top-level domains, about 15 KB, covering every Moshpit ending + // that exists or ever will. RFC 5280 4.2.1.10 leaves a name type unrestricted + // when no permitted subtree names it, which is what makes that work — and is + // also exactly what an unconstrained root looks like, so the difference has + // to be established rather than assumed. + // + // It is established against the same IANA list this tool refuses to sell + // endings from. A root that excludes the internet cannot forge your bank, + // which is the whole property the old shape bought by enumeration. if (!constraints.permitted.length) { - return { - ok: false, - kind: "unconstrained", - why: "the root permits no DNS subtree, so every name it does not exclude is allowed — it could vouch for any name", - }; + const excluded = new Set(constraints.excluded.map(bare)); + const covered = [...IANA_TLDS].filter((tld) => excluded.has(tld)); + const uncovered = [...IANA_TLDS].filter((tld) => !excluded.has(tld)); + + // A handful of exclusions is not this shape; it is an unconstrained root + // with a few names crossed out, which is the thing this gate exists to + // refuse. The threshold sits far below the real count on purpose: the + // question is "is the internet excluded", not "is this list current". + if (covered.length < 1000) { + return { + ok: false, + kind: "unconstrained", + why: covered.length + ? `the root excludes only ${covered.length} real top-level domains — it could still vouch for the rest of the internet` + : "the root permits no DNS subtree and excludes no real domains, so it could vouch for any name", + }; + } + + // A root minted before a TLD was delegated does not exclude it. A real gap, + // and a small one, so it is reported rather than made fatal: refusing here + // would mean refusing every root the day IANA adds a name. + return uncovered.length + ? { ok: true, why: `excludes ${covered.length} real top-level domains`, uncovered } + : { ok: true, why: `excludes all ${covered.length} real top-level domains` }; } - const bare = (entry) => entry.replace(/^\./, ""); const permits = (tld) => constraints.permitted.some((entry) => bare(entry) === String(tld).toLowerCase()); const missing = tlds.filter((tld) => !permits(tld)); diff --git a/test/dns-service.test.mjs b/test/dns-service.test.mjs index 9489137..1e53d7c 100644 --- a/test/dns-service.test.mjs +++ b/test/dns-service.test.mjs @@ -205,12 +205,20 @@ test("the proxy dir is the operator's, never root's", () => { assert.match(px(), /^Environment=MOSHPIT_PROXY_DIR=\/home\/x\/\.moshpit$/m); }); -test("it serves the endings it is given, and says nothing when given none", () => { - assert.match(px({ tlds: ["moshpit", "eggs", "2600"] }), /^Environment=MOSHPIT_PROXY_TLDS=moshpit,eggs,2600$/m); - // Unset means the proxy's own default of `.moshpit` alone — which is why a - // healthy proxy can still fail to present a certificate for the name someone - // is actually trying to reach. +test("the unit never names endings — the proxy serves all of them", () => { + // This used to write MOSHPIT_PROXY_TLDS from the registry's ending list, + // which is 18224 names: a ~150 KB environment variable in a unit file, for a + // list stale the next time one is sold. + // + // moshpit-proxy now reads an unset value as "every Moshpit ending" — its root + // excludes the real internet rather than enumerating what Moshpit owns — so + // there is nothing to pass and no size to worry about. + assert.doesNotMatch(px({ tlds: ["moshpit", "eggs", "2600"] }), /MOSHPIT_PROXY_TLDS/); assert.doesNotMatch(px({ tlds: [] }), /MOSHPIT_PROXY_TLDS/); + assert.ok( + px({ tlds: Array.from({ length: 18224 }, (_, i) => `e${i}`) }).length < 2000, + "a unit must not grow with the registry", + ); }); test("a unit it cannot pin is refused rather than written half-formed", () => { diff --git a/test/trust.test.mjs b/test/trust.test.mjs index 1ab6919..d82ebd1 100644 --- a/test/trust.test.mjs +++ b/test/trust.test.mjs @@ -59,6 +59,13 @@ function makeRoot({ constraints }) { const MOSHPIT_ONLY = "critical,permitted;DNS:.hacker,permitted;DNS:.rank"; +/** + * The shape moshpit-proxy mints now: nothing permitted, the whole of IANA + * excluded. Built from the same list the registry refuses to sell from, so a + * drift between the two would fail here rather than in someone's browser. + */ +const EXCLUDES_THE_INTERNET = ["critical", ...[...IANA_TLDS].map((t) => `excluded;DNS:.${t}`)].join(","); + /** * Skip loudly, never silently. * @@ -472,6 +479,7 @@ test("the refusal a session prints carries its remedy", async () => { /* ---------------------------------------- trusting one name on the strength of its pin */ import { fetchCertificateCommand, leafPath, leafTrustPlan, pinAccepted, pinFromCertificate, trustName } from "../src/trust.mjs"; +import { IANA_TLDS } from "../src/iana-tlds.mjs"; /** * A real self-signed leaf, the shape a Moshpit origin serves. @@ -674,3 +682,44 @@ test("no name asks for one rather than guessing", async () => { assert.equal(await trustName("", (l) => lines.push(l), {}), 1); assert.match(lines.join("\n"), /which name/); }); + +/* ------------------------------ the root that excludes the internet instead */ + +// Permitting every Moshpit ending does not scale — 18224 of them is roughly +// 214 KB of constraints on every handshake, and stale the next time one is +// sold, which is why a machine could reach `.2600` over HTTPS and not +// `.hacker`. Excluding the 1438 real TLDs costs ~15 KB and covers the whole +// namespace forever. The catch is that it looks exactly like an unconstrained +// root to anything that only asks "is there a permitted subtree", so the +// difference has to be established rather than assumed. + +test("a root that excludes the whole real internet is accepted", (t) => { + const text = needRoot(t, EXCLUDES_THE_INTERNET); + if (!text) return; + // No `tlds` need be passed: that is the entire point of this shape. + const verdict = requireNameConstraints(text, { tlds: ["hacker", "2600", "eggs", "soldtomorrow"] }); + assert.equal(verdict.ok, true, verdict.why); + assert.match(verdict.why, /excludes all 1438 real top-level domains/); +}); + +test("a root with a few exclusions is not that shape — it is unconstrained", (t) => { + // The attack this refuses: cross out `.com` and `.net`, permit nothing, and + // an "excluded" root can still forge every other real domain. + const text = needRoot(t, "critical,excluded;DNS:.com,excluded;DNS:.net"); + if (!text) return; + const verdict = requireNameConstraints(text, { tlds: ["hacker"] }); + assert.equal(verdict.ok, false); + assert.match(verdict.why, /excludes only 2/); +}); + +test("a root missing a newly delegated TLD is reported, not refused", (t) => { + // IANA adds names. A root minted last month does not exclude one added this + // month, which is a real gap and a small one — refusing would mean refusing + // every root on the day the list moves. + const shortOne = [...IANA_TLDS].slice(0, -3); + const text = needRoot(t, ["critical", ...shortOne.map((x) => `excluded;DNS:.${x}`)].join(",")); + if (!text) return; + const verdict = requireNameConstraints(text, { tlds: ["hacker"] }); + assert.equal(verdict.ok, true, verdict.why); + assert.equal(verdict.uncovered.length, 3); +});