Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/dns-service.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`,
Expand Down
51 changes: 45 additions & 6 deletions src/trust.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 } = {}) {
Expand Down Expand Up @@ -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));
Expand Down
18 changes: 13 additions & 5 deletions test/dns-service.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
49 changes: 49 additions & 0 deletions test/trust.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
});
Loading