Skip to content

Commit e84d695

Browse files
ralyodioclaude
andauthored
fix(site): one key per name, not one per ending (#253)
`keyPaths` keyed certificates off the TLD, justified as "that is the granularity the registry stores". It is the opposite of what the registry stores. Migration 009 says so, and says why: Per name rather than per TLD, and that is forced by 008: names under a TLD are sold, so `blue.eggs` can belong to someone who does not own `.eggs`. Hanging keys off the TLD would let its operator publish a key for a name they already sold. A shared per-ending key is that hole in private-key form. The ending's operator holds the key for every name they sold, and every buyer holds a key that signs for every other buyer. 009 closed it at the pin layer; this reintroduced it one layer down, where it is worse — a pin can be withdrawn, a distributed private key cannot. The certificate carried `DNS:*.<tld>` for the same reason, so each buyer's certificate asserted authority over every other name in a namespace they merely bought into. Now `DNS:<name>` alone. Found because it broke nginx on a live box: `moshcode site` wrote conf.d blocks pointing at /etc/ssl/moshpit/hacker.crt while the certificates on disk were per-name, so `nginx -t` failed with three missing files and the box could not reload. Per-name paths agree with what setup-origin.sh has written all along. Path sanitising now keeps dots, so `alt.2600` stays `alt.2600.crt`. Runs of dots collapse to one, so no `..` survives to mean "parent": `../../etc/passwd` becomes `etcpasswd`, a harmless filename inside the key directory. 841 tests pass. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 11e8586 commit e84d695

3 files changed

Lines changed: 64 additions & 24 deletions

File tree

src/pins.mjs

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,36 @@ export function pinFromCertificate(certPem) {
4949
}
5050

5151
/**
52-
* Where a TLD's key lives.
52+
* Where a name's key lives.
5353
*
54-
* One key per ending, not per name, because that is the granularity the
55-
* registry stores. Giving each name its own key would mean a new pin published
56-
* per site, and every name under the ending would then accept all of them —
57-
* strictly more keys able to impersonate each other, for no isolation gained.
54+
* One key per name, not per ending. This used to key off the TLD, on the
55+
* belief that "that is the granularity the registry stores" — which is the
56+
* opposite of true. Migration 009 is explicit about it, and about why:
57+
*
58+
* Per name rather than per TLD, and that is forced by 008: names under a TLD
59+
* are sold, so `blue.eggs` can belong to someone who does not own `.eggs`.
60+
* Hanging keys off the TLD would let its operator publish a key for a name
61+
* they already sold — impersonating a buyer inside the namespace they bought
62+
* into.
63+
*
64+
* A shared per-ending key is that hole in private-key form: the ending's
65+
* operator holds the key for every name they have sold, and every buyer holds
66+
* a key that signs for every other buyer. Per-name keys make a compromise stop
67+
* at one site.
68+
*
69+
* The dot is kept — `chovy.hacker.crt`, not `chovyhacker.crt` — which also
70+
* matches the certificates setup-origin.sh has been writing all along.
71+
* Separators that could climb out of `dir` are dropped rather than escaped:
72+
* `../../etc/passwd` collapses to `etcpasswd`, which is a harmless filename
73+
* inside the directory rather than a path anywhere else.
5874
*/
59-
export function keyPaths(tld, dir = "/etc/ssl/moshpit") {
60-
const safe = String(tld ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
75+
export function keyPaths(name, dir = "/etc/ssl/moshpit") {
76+
const safe = String(name ?? "")
77+
.toLowerCase()
78+
.replace(/[^a-z0-9.-]/g, "")
79+
// Any run of dots becomes one, so no `..` survives to mean "parent".
80+
.replace(/\.{2,}/g, ".")
81+
.replace(/^[.-]+|[.-]+$/g, "");
6182
if (!safe) return null;
6283
return { key: `${dir}/${safe}.key`, cert: `${dir}/${safe}.crt`, dir };
6384
}
@@ -80,9 +101,12 @@ export function certificateCommand({ name, tld, paths, days = 3650 }) {
80101
"-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1",
81102
"-keyout", paths.key, "-out", paths.cert,
82103
"-days", String(days), "-subj", subject,
83-
// Every name under the ending, since they share this key. Browsers and
104+
// This name and nothing else. It used to carry `DNS:*.${tld}` as well,
105+
// on the assumption that every name under the ending shared one key —
106+
// which would have each buyer's certificate assert authority over every
107+
// other name in a namespace they merely bought into. Browsers and
84108
// pin-checking clients both read SAN, not CN.
85-
"-addext", `subjectAltName=DNS:${name},DNS:*.${tld}`,
109+
"-addext", `subjectAltName=DNS:${name}`,
86110
],
87111
};
88112
}

src/serve.mjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ export function caddySite({ name, root, proxy }) {
253253
export async function tlsFor(name, { dir = "/etc/ssl/moshpit", readFile = fs.readFile } = {}) {
254254
const tld = tldOf(name);
255255
if (!tld) return null;
256-
const paths = keyPaths(tld, dir);
256+
const paths = keyPaths(name, dir);
257257
if (!paths) return null;
258258

259259
try {

test/pins.test.mjs

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,32 +41,48 @@ test("pins hang off the ending, not the name", () => {
4141
assert.equal(tldOf("SEO.RANK"), "rank", "case is not part of the identity");
4242
});
4343

44-
test("one key per ending, because that is the granularity the registry stores", () => {
45-
const paths = keyPaths("hacker");
46-
assert.match(paths.key, /hacker\.key$/);
47-
assert.match(paths.cert, /hacker\.crt$/);
48-
49-
// Per-name keys would mean a pin published per site, and every name under
50-
// the ending would then accept all of them — more keys able to impersonate
51-
// each other, for no isolation gained.
52-
assert.deepEqual(keyPaths("hacker"), keyPaths("hacker"));
44+
test("one key per name, because a name can belong to someone who does not own the ending", () => {
45+
const paths = keyPaths("chovy.hacker");
46+
assert.match(paths.key, /\/chovy\.hacker\.key$/);
47+
assert.match(paths.cert, /\/chovy\.hacker\.crt$/);
48+
assert.deepEqual(keyPaths("chovy.hacker"), keyPaths("chovy.hacker"));
5349
});
5450

55-
test("a path traversal in the ending cannot escape the key directory", () => {
56-
// The ending reaches this from a registry response, so it is not trusted
51+
test("two names under one ending do not share a key", () => {
52+
// This is the whole point. Names under a TLD are sold, so a shared key means
53+
// the ending's operator holds the private key for every name they sold, and
54+
// every buyer holds a key that signs for every other buyer. Migration 009
55+
// exists to stop exactly that at the pin layer; keying certificates off the
56+
// TLD reintroduced it one layer down.
57+
assert.notEqual(keyPaths("chovy.hacker").key, keyPaths("auto.hacker").key);
58+
assert.notEqual(keyPaths("chovy.hacker").cert, keyPaths("auto.hacker").cert);
59+
});
60+
61+
test("a numeric ending keeps its digits", () => {
62+
// `.2600` is a registered ending. Stripping to alphanumerics used to be
63+
// harmless here; now that the label is included it must not eat the dot.
64+
assert.match(keyPaths("alt.2600").cert, /\/alt\.2600\.crt$/);
65+
});
66+
67+
test("a path traversal in the name cannot escape the key directory", () => {
68+
// The name reaches this from a registry response, so it is not trusted
5769
// input. Writing a key through `../../` would be a very bad day.
5870
assert.match(keyPaths("../../etc/passwd").key, /\/etcpasswd\.key$/);
71+
assert.match(keyPaths("a/../../b.hacker").key, /\/a\.b\.hacker\.key$/);
5972
assert.equal(keyPaths("..."), null, "nothing usable left after stripping");
6073
assert.equal(keyPaths(""), null);
6174
});
6275

63-
test("the certificate covers every name under the ending, since they share the key", () => {
64-
const paths = keyPaths("hacker");
76+
test("the certificate covers this name and nothing else", () => {
77+
const paths = keyPaths("chovy.hacker");
6578
const { args } = certificateCommand({ name: "chovy.hacker", tld: "hacker", paths });
6679
const san = args[args.indexOf("-addext") + 1];
6780

6881
assert.match(san, /DNS:chovy\.hacker/);
69-
assert.match(san, /DNS:\*\.hacker/, "the other names under this ending present the same key");
82+
// A wildcard here would have each buyer's certificate assert authority over
83+
// every other name under an ending they merely bought into — the same hole
84+
// migration 009 closed at the pin layer.
85+
assert.doesNotMatch(san, /\*/, "no name may vouch for its neighbours");
7086
assert.match(args.join(" "), /prime256v1/, "P-256, matching what is already deployed");
7187
});
7288

0 commit comments

Comments
 (0)