Skip to content

Commit 7c1ae94

Browse files
ralyodioclaude
andcommitted
moshpit: publish the keys a name may present
The registry knew who held a name and where it pointed, but not what key it was allowed to present — so a client had nothing to check a peer against, and "is this really scrambled.eggs" had no answer. Per name, not per TLD, and 008 is what forces it. 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 had already sold — impersonating a buyer inside the namespace they bought into. The pin therefore lives beside the name and is authorised by the name's holder. `kind` keeps the transports apart. A `tls` pin covers a certificate's SubjectPublicKeyInfo; an `mtp` pin covers an ML-DSA-65 identity. Both are SHA-256 over an SPKI, so as strings they are indistinguishable, and nothing but that column stops a client being handed the wrong one and failing with no diagnosable reason. The same pin cannot be published under two kinds. Several rows per (tld, label, kind) on purpose: a key cannot rotate without a window where the old and the new are both published. Withdrawing the last key of a kind is allowed — that is how a compromised key is taken out of service. releaseName now deletes the name's pins explicitly. SQLite only honours ON DELETE CASCADE with `PRAGMA foreign_keys = ON`, which this app never sets, so a declared cascade would not have fired and whoever registered the name next would have inherited the previous holder's published keys. GET /api/moshpit/pins?name=&kind= public; 400 not a name, 404 no key GET /api/moshpit/tlds/:tld/pins?label= public POST /api/moshpit/tlds/:tld/pins the name's holder DELETE /api/moshpit/tlds/:tld/pins the name's holder Aliases are followed before pins are read: `foo.agentic` under an alias to `.agent` connects to whatever serves `foo.agent`, so those are the keys that will actually be presented. 16 tests against a real throwaway libSQL database, since the behaviour worth checking is in the SQL and the ownership rules — including that a TLD operator cannot pin a name they sold, and that a released name carries no keys forward. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 6486d84 commit 7c1ae94

4 files changed

Lines changed: 497 additions & 6 deletions

File tree

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
-- The keys a name is allowed to present.
2+
--
3+
-- Per name rather than per TLD, and that is forced by 008: names under a TLD
4+
-- are sold, so `blue.eggs` can belong to someone who does not own `.eggs`.
5+
-- Hanging keys off the TLD would let its operator publish a key for a name they
6+
-- already sold — impersonating a buyer inside the namespace they bought into.
7+
-- The pin therefore lives beside the name and is authorised by the name's owner.
8+
--
9+
-- `kind` keeps the transports apart. A `tls` pin covers a certificate's
10+
-- SubjectPublicKeyInfo; an `mtp` pin covers an ML-DSA-65 identity. Both are
11+
-- SHA-256 over an SPKI, so as strings they are indistinguishable, and nothing
12+
-- but this column stops a client being handed the wrong one and failing with no
13+
-- diagnosable reason.
14+
--
15+
-- Several rows per (tld, label, kind) on purpose: a key cannot rotate without a
16+
-- window in which the old and the new one are both published.
17+
CREATE TABLE IF NOT EXISTS moshpit_name_pins (
18+
tld TEXT NOT NULL,
19+
label TEXT NOT NULL,
20+
pin TEXT NOT NULL,
21+
kind TEXT NOT NULL CHECK (kind IN ('tls','mtp')),
22+
note TEXT,
23+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
24+
created_at INTEGER NOT NULL,
25+
PRIMARY KEY (tld, label, pin)
26+
);
27+
CREATE INDEX IF NOT EXISTS idx_moshpit_name_pins ON moshpit_name_pins(tld, label, kind);
28+
CREATE INDEX IF NOT EXISTS idx_moshpit_name_pins_user ON moshpit_name_pins(user_id);

apps/pwa/src/moshpit.mjs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,12 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId,
248248
export async function releaseName({ tld: tldInput, label: labelInput, userId }) {
249249
const owned = await ownedName(tldInput, labelInput, userId);
250250
if (!owned.ok) return owned;
251+
// Keys go with the name. Deleted explicitly rather than left to the foreign
252+
// key, because SQLite only enforces those with `PRAGMA foreign_keys = ON`
253+
// and nothing here sets it — so a cascade that looks declared would not fire,
254+
// and whoever registered the name next would inherit the previous holder's
255+
// published keys.
256+
await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
251257
await run(`DELETE FROM moshpit_names WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
252258
await logAction(owned.tld, userId, `unname:${owned.label}`);
253259
return { ok: true };
@@ -431,3 +437,120 @@ export async function resolveMoshpitName(input) {
431437
target: entry?.target ?? null,
432438
};
433439
}
440+
441+
/* ---- the keys a name may present ---- */
442+
443+
const PIN_COLS = `tld, label, pin, kind, note, user_id, created_at`;
444+
445+
export const PIN_KINDS = ["tls", "mtp"];
446+
447+
/**
448+
* A pin is SHA-256 over a SubjectPublicKeyInfo, base64 — always 32 bytes, so
449+
* always 44 characters ending in one '='. Checked rather than trusted, because
450+
* a malformed pin is indistinguishable in effect from a key that simply never
451+
* matches: the connection fails, and nothing anywhere says why.
452+
*/
453+
export function isPin(value) {
454+
if (typeof value !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(value)) return false;
455+
return Buffer.from(value, "base64").length === 32;
456+
}
457+
458+
export function normalizePinKind(value) {
459+
const kind = String(value ?? "").trim().toLowerCase();
460+
return PIN_KINDS.includes(kind) ? kind : null;
461+
}
462+
463+
export async function listPins(tldInput, labelInput, kind = null) {
464+
const tld = normalizeTld(tldInput);
465+
const label = normalizeLabel(labelInput);
466+
if (!tld || !label) return [];
467+
return kind
468+
? all(`SELECT ${PIN_COLS} FROM moshpit_name_pins WHERE tld = ? AND label = ? AND kind = ?
469+
ORDER BY created_at DESC`, [tld, label, kind])
470+
: all(`SELECT ${PIN_COLS} FROM moshpit_name_pins WHERE tld = ? AND label = ?
471+
ORDER BY kind, created_at DESC`, [tld, label]);
472+
}
473+
474+
/**
475+
* The keys a client should accept for `scrambled.eggs`.
476+
*
477+
* Aliases are followed first. When `.agentic` points at `.agent`, a client
478+
* asking about `foo.agentic` connects to whatever serves `foo.agent`, so the
479+
* keys that matter are the ones published there. Answering with the typed
480+
* name's own pins would refuse every working connection.
481+
*
482+
* Returns null when the pit has no authority over the name at all — an
483+
* unclaimed TLD is not a Moshpit name, and saying "no key published" about
484+
* `example.com` would invite a client to treat clearnet as merely unpinned.
485+
*/
486+
export async function pinsForName(input, kind = null) {
487+
const resolution = await resolveMoshpitName(input);
488+
if (!resolution || !resolution.registered) return null;
489+
490+
const parsed = parseMoshpitName(resolution.resolved);
491+
if (!parsed) return null;
492+
493+
return {
494+
name: resolution.name,
495+
resolved: resolution.resolved,
496+
tld: parsed.tld,
497+
label: parsed.label,
498+
name_registered: resolution.name_registered,
499+
target: resolution.target,
500+
pins: await listPins(parsed.tld, parsed.label, kind),
501+
};
502+
}
503+
504+
/** Publish a key for a name you hold. */
505+
export async function addPin({ tld: tldInput, label: labelInput, pin, kind: kindInput, note = null, userId }) {
506+
const owned = await ownedName(tldInput, labelInput, userId);
507+
if (!owned.ok) return owned;
508+
509+
if (!isPin(pin)) {
510+
return { ok: false, error: "pin must be base64 SHA-256 over a SubjectPublicKeyInfo (44 characters)" };
511+
}
512+
const kind = normalizePinKind(kindInput);
513+
if (!kind) return { ok: false, error: `kind must be one of ${PIN_KINDS.join(", ")}` };
514+
515+
// The same pin under a second kind is a mistake worth naming. Ignoring it
516+
// would leave the operator sure they published an `mtp` key while every
517+
// client is still told it is `tls`.
518+
const existing = await get(
519+
`SELECT kind FROM moshpit_name_pins WHERE tld = ? AND label = ? AND pin = ?`,
520+
[owned.tld, owned.label, pin],
521+
);
522+
if (existing && existing.kind !== kind) {
523+
return { ok: false, error: `that pin is already published for ${owned.label}.${owned.tld} as ${existing.kind}`, taken: true };
524+
}
525+
if (existing) return { ok: true };
526+
527+
const trimmed = typeof note === "string" && note.trim() ? note.trim().slice(0, 200) : null;
528+
await run(
529+
`INSERT INTO moshpit_name_pins (${PIN_COLS}) VALUES (?,?,?,?,?,?,?)`,
530+
[owned.tld, owned.label, pin, kind, trimmed, userId, Date.now()],
531+
);
532+
await logAction(owned.tld, userId, `pin:add:${owned.label}:${kind}`);
533+
return { ok: true };
534+
}
535+
536+
/**
537+
* Withdraw a key.
538+
*
539+
* Removing the last pin of a kind is allowed. It leaves the name with no key
540+
* published, which clients treat as a refusal rather than as permission — so
541+
* this is how a compromised key is taken out of service, and refusing it on the
542+
* grounds that it breaks connections would be refusing the point.
543+
*/
544+
export async function removePin({ tld: tldInput, label: labelInput, pin, userId }) {
545+
const owned = await ownedName(tldInput, labelInput, userId);
546+
if (!owned.ok) return owned;
547+
548+
const result = await run(
549+
`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ? AND pin = ?`,
550+
[owned.tld, owned.label, pin],
551+
);
552+
if (!result.rowsAffected) return { ok: false, error: "that pin is not published for this name" };
553+
554+
await logAction(owned.tld, userId, `pin:remove:${owned.label}`);
555+
return { ok: true };
556+
}

apps/pwa/src/routes/moshpit.mjs

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,38 @@ import { balance } from "../lib/credits.mjs";
1919
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
2020
import { landingFor } from "../lib/moshpit-landing.mjs";
2121
import {
22-
getTld, listTlds, listTldsForUser, registerTld, normalizeLabel,
23-
setAlias, clearAlias, listExempt, setExempt, clearExempt,
24-
listNames, getName, getTldWithPrice, registerName, setNameTarget, releaseName,
25-
setTldPrice, listTldsNotOwnedBy, quoteName, openNamePurchase,
26-
resolveMoshpitName, normalizeTld, tldRejection, parseMoshpitName,
27-
normalizeMode, resolutionPreference,
22+
addPin,
23+
clearAlias,
24+
clearExempt,
25+
getName,
26+
getTld,
27+
getTldWithPrice,
28+
listExempt,
29+
listNames,
30+
listPins,
31+
listTlds,
32+
listTldsForUser,
33+
listTldsNotOwnedBy,
34+
normalizeLabel,
35+
normalizeMode,
36+
normalizePinKind,
37+
normalizeTld,
38+
openNamePurchase,
39+
parseMoshpitName,
40+
PIN_KINDS,
41+
pinsForName,
42+
quoteName,
43+
registerName,
44+
registerTld,
45+
releaseName,
46+
removePin,
47+
resolutionPreference,
48+
resolveMoshpitName,
49+
setAlias,
50+
setExempt,
51+
setNameTarget,
52+
setTldPrice,
53+
tldRejection,
2854
} from "../moshpit.mjs";
2955
import { config } from "../config.mjs";
3056

@@ -150,6 +176,94 @@ moshpitRouter.delete("/api/moshpit/tlds/:tld/names", async (req, res) => {
150176
res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), released: true });
151177
});
152178

179+
/* ---- the keys a name may present ---- */
180+
181+
/**
182+
* GET /api/moshpit/pins?name=scrambled.eggs[&kind=tls] — public.
183+
*
184+
* The lookup every Moshpit client makes before it will talk to anything. The
185+
* status codes carry meaning the body does not, because clients cache on them:
186+
*
187+
* 400 not a Moshpit name a definite no, cacheable as long as a real answer
188+
* 404 no key published also definite — nobody has vouched for a key here
189+
* 200 { pins: [...] } the keys a peer may present
190+
*
191+
* What matters is that both differ from a 5xx or a timeout. A definite no means
192+
* refuse the connection; an outage means try again later. A client that treats
193+
* them alike either fails closed forever or fails open once, and the second is
194+
* how pinning gets quietly defeated.
195+
*/
196+
moshpitRouter.get("/api/moshpit/pins", async (req, res) => {
197+
const name = String(req.query.name ?? "").trim();
198+
if (!name) return bad(res, "name is required");
199+
200+
const requested = req.query.kind ? String(req.query.kind) : null;
201+
const kind = requested ? normalizePinKind(requested) : null;
202+
if (requested && !kind) return bad(res, `kind must be one of ${PIN_KINDS.join(", ")}`);
203+
204+
const found = await pinsForName(name, kind);
205+
if (!found) return bad(res, "not a Moshpit name");
206+
207+
const body = {
208+
name: found.name,
209+
resolved: found.resolved,
210+
tld: found.tld,
211+
label: found.label,
212+
target: found.target,
213+
// A flat array of strings first: that is all a client needs in order to
214+
// compare against what a peer actually presented.
215+
pins: found.pins.map((p) => p.pin),
216+
entries: found.pins.map((p) => ({ pin: p.pin, kind: p.kind, note: p.note })),
217+
};
218+
return found.pins.length ? res.json(body) : res.status(404).json(body);
219+
});
220+
221+
/** GET /api/moshpit/tlds/:tld/pins?label=blue — public; pins are public by nature. */
222+
moshpitRouter.get("/api/moshpit/tlds/:tld/pins", async (req, res) => {
223+
const tld = normalizeTld(req.params.tld);
224+
const label = normalizeLabel(req.query.label);
225+
if (!tld || !label) return bad(res, "tld and label are required");
226+
227+
const requested = req.query.kind ? String(req.query.kind) : null;
228+
const kind = requested ? normalizePinKind(requested) : null;
229+
if (requested && !kind) return bad(res, `kind must be one of ${PIN_KINDS.join(", ")}`);
230+
231+
res.json({ tld, label, pins: await listPins(tld, label, kind) });
232+
});
233+
234+
/**
235+
* POST /api/moshpit/tlds/:tld/pins { label, pin, kind, note? } — publish a key.
236+
*
237+
* Adds rather than replaces, so rotation has a window: publish the new key
238+
* alongside the old, deploy it, then withdraw the old. Replacing outright would
239+
* break every client between the write and the deploy.
240+
*/
241+
moshpitRouter.post("/api/moshpit/tlds/:tld/pins", async (req, res) => {
242+
if (!req.user) return unauthorized(res);
243+
const result = await addPin({
244+
tld: req.params.tld,
245+
label: req.body?.label,
246+
pin: req.body?.pin,
247+
kind: req.body?.kind,
248+
note: req.body?.note,
249+
userId: req.user.id,
250+
});
251+
// 409 when the pin is already published under another kind: the request was
252+
// well formed, it just contradicts what is already there.
253+
if (!result.ok) return bad(res, result.error || "could not publish that pin", result.taken ? 409 : 400);
254+
res.status(201).json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), kind: req.body?.kind });
255+
});
256+
257+
/** DELETE /api/moshpit/tlds/:tld/pins { label, pin } — withdraw a key. */
258+
moshpitRouter.delete("/api/moshpit/tlds/:tld/pins", async (req, res) => {
259+
if (!req.user) return unauthorized(res);
260+
const result = await removePin({
261+
tld: req.params.tld, label: req.body?.label, pin: req.body?.pin, userId: req.user.id,
262+
});
263+
if (!result.ok) return bad(res, result.error || "could not withdraw that pin", 404);
264+
res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), withdrawn: true });
265+
});
266+
153267
/* ---- the market ---- */
154268

155269
/** TLDs other people hold. `?for_sale=1` narrows to the buyable ones. */

0 commit comments

Comments
 (0)