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
28 changes: 28 additions & 0 deletions apps/pwa/src/migrations/009_moshpit_pins.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
-- The keys a name is allowed to present.
--
-- 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 — impersonating a buyer inside the namespace they bought into.
-- The pin therefore lives beside the name and is authorised by the name's owner.
--
-- `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 this column stops a client being handed the wrong one and failing with no
-- diagnosable reason.
--
-- Several rows per (tld, label, kind) on purpose: a key cannot rotate without a
-- window in which the old and the new one are both published.
CREATE TABLE IF NOT EXISTS moshpit_name_pins (
tld TEXT NOT NULL,
label TEXT NOT NULL,
pin TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('tls','mtp')),
note TEXT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL,
PRIMARY KEY (tld, label, pin)
);
CREATE INDEX IF NOT EXISTS idx_moshpit_name_pins ON moshpit_name_pins(tld, label, kind);
CREATE INDEX IF NOT EXISTS idx_moshpit_name_pins_user ON moshpit_name_pins(user_id);
123 changes: 123 additions & 0 deletions apps/pwa/src/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,12 @@ export async function setNameTarget({ tld: tldInput, label: labelInput, userId,
export async function releaseName({ tld: tldInput, label: labelInput, userId }) {
const owned = await ownedName(tldInput, labelInput, userId);
if (!owned.ok) return owned;
// Keys go with the name. Deleted explicitly rather than left to the foreign
// key, because SQLite only enforces those with `PRAGMA foreign_keys = ON`
// and nothing here sets it — so a cascade that looks declared would not fire,
// and whoever registered the name next would inherit the previous holder's
// published keys.
await run(`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
await run(`DELETE FROM moshpit_names WHERE tld = ? AND label = ?`, [owned.tld, owned.label]);
await logAction(owned.tld, userId, `unname:${owned.label}`);
return { ok: true };
Expand Down Expand Up @@ -431,3 +437,120 @@ export async function resolveMoshpitName(input) {
target: entry?.target ?? null,
};
}

/* ---- the keys a name may present ---- */

const PIN_COLS = `tld, label, pin, kind, note, user_id, created_at`;

export const PIN_KINDS = ["tls", "mtp"];

/**
* A pin is SHA-256 over a SubjectPublicKeyInfo, base64 — always 32 bytes, so
* always 44 characters ending in one '='. Checked rather than trusted, because
* a malformed pin is indistinguishable in effect from a key that simply never
* matches: the connection fails, and nothing anywhere says why.
*/
export function isPin(value) {
if (typeof value !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(value)) return false;
return Buffer.from(value, "base64").length === 32;
}

export function normalizePinKind(value) {
const kind = String(value ?? "").trim().toLowerCase();
return PIN_KINDS.includes(kind) ? kind : null;
}

export async function listPins(tldInput, labelInput, kind = null) {
const tld = normalizeTld(tldInput);
const label = normalizeLabel(labelInput);
if (!tld || !label) return [];
return kind
? all(`SELECT ${PIN_COLS} FROM moshpit_name_pins WHERE tld = ? AND label = ? AND kind = ?
ORDER BY created_at DESC`, [tld, label, kind])
: all(`SELECT ${PIN_COLS} FROM moshpit_name_pins WHERE tld = ? AND label = ?
ORDER BY kind, created_at DESC`, [tld, label]);
}

/**
* The keys a client should accept for `scrambled.eggs`.
*
* Aliases are followed first. When `.agentic` points at `.agent`, a client
* asking about `foo.agentic` connects to whatever serves `foo.agent`, so the
* keys that matter are the ones published there. Answering with the typed
* name's own pins would refuse every working connection.
*
* Returns null when the pit has no authority over the name at all — an
* unclaimed TLD is not a Moshpit name, and saying "no key published" about
* `example.com` would invite a client to treat clearnet as merely unpinned.
*/
export async function pinsForName(input, kind = null) {
const resolution = await resolveMoshpitName(input);
if (!resolution || !resolution.registered) return null;

const parsed = parseMoshpitName(resolution.resolved);
if (!parsed) return null;

return {
name: resolution.name,
resolved: resolution.resolved,
tld: parsed.tld,
label: parsed.label,
name_registered: resolution.name_registered,
target: resolution.target,
pins: await listPins(parsed.tld, parsed.label, kind),
};
}

/** Publish a key for a name you hold. */
export async function addPin({ tld: tldInput, label: labelInput, pin, kind: kindInput, note = null, userId }) {
const owned = await ownedName(tldInput, labelInput, userId);
if (!owned.ok) return owned;

if (!isPin(pin)) {
return { ok: false, error: "pin must be base64 SHA-256 over a SubjectPublicKeyInfo (44 characters)" };
}
const kind = normalizePinKind(kindInput);
if (!kind) return { ok: false, error: `kind must be one of ${PIN_KINDS.join(", ")}` };

// The same pin under a second kind is a mistake worth naming. Ignoring it
// would leave the operator sure they published an `mtp` key while every
// client is still told it is `tls`.
const existing = await get(
`SELECT kind FROM moshpit_name_pins WHERE tld = ? AND label = ? AND pin = ?`,
[owned.tld, owned.label, pin],
);
if (existing && existing.kind !== kind) {
return { ok: false, error: `that pin is already published for ${owned.label}.${owned.tld} as ${existing.kind}`, taken: true };
}
if (existing) return { ok: true };

const trimmed = typeof note === "string" && note.trim() ? note.trim().slice(0, 200) : null;
await run(
`INSERT INTO moshpit_name_pins (${PIN_COLS}) VALUES (?,?,?,?,?,?,?)`,
[owned.tld, owned.label, pin, kind, trimmed, userId, Date.now()],
);
await logAction(owned.tld, userId, `pin:add:${owned.label}:${kind}`);
return { ok: true };
}

/**
* Withdraw a key.
*
* Removing the last pin of a kind is allowed. It leaves the name with no key
* published, which clients treat as a refusal rather than as permission — so
* this is how a compromised key is taken out of service, and refusing it on the
* grounds that it breaks connections would be refusing the point.
*/
export async function removePin({ tld: tldInput, label: labelInput, pin, userId }) {
const owned = await ownedName(tldInput, labelInput, userId);
if (!owned.ok) return owned;

const result = await run(
`DELETE FROM moshpit_name_pins WHERE tld = ? AND label = ? AND pin = ?`,
[owned.tld, owned.label, pin],
);
if (!result.rowsAffected) return { ok: false, error: "that pin is not published for this name" };

await logAction(owned.tld, userId, `pin:remove:${owned.label}`);
return { ok: true };
}
126 changes: 120 additions & 6 deletions apps/pwa/src/routes/moshpit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,38 @@ import { balance } from "../lib/credits.mjs";
import { resolverConfig } from "../lib/moshpit-resolvers.mjs";
import { landingFor } from "../lib/moshpit-landing.mjs";
import {
getTld, listTlds, listTldsForUser, registerTld, normalizeLabel,
setAlias, clearAlias, listExempt, setExempt, clearExempt,
listNames, getName, getTldWithPrice, registerName, setNameTarget, releaseName,
setTldPrice, listTldsNotOwnedBy, quoteName, openNamePurchase,
resolveMoshpitName, normalizeTld, tldRejection, parseMoshpitName,
normalizeMode, resolutionPreference,
addPin,
clearAlias,
clearExempt,
getName,
getTld,
getTldWithPrice,
listExempt,
listNames,
listPins,
listTlds,
listTldsForUser,
listTldsNotOwnedBy,
normalizeLabel,
normalizeMode,
normalizePinKind,
normalizeTld,
openNamePurchase,
parseMoshpitName,
PIN_KINDS,
pinsForName,
quoteName,
registerName,
registerTld,
releaseName,
removePin,
resolutionPreference,
resolveMoshpitName,
setAlias,
setExempt,
setNameTarget,
setTldPrice,
tldRejection,
} from "../moshpit.mjs";
import { config } from "../config.mjs";

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

/* ---- the keys a name may present ---- */

/**
* GET /api/moshpit/pins?name=scrambled.eggs[&kind=tls] — public.
*
* The lookup every Moshpit client makes before it will talk to anything. The
* status codes carry meaning the body does not, because clients cache on them:
*
* 400 not a Moshpit name a definite no, cacheable as long as a real answer
* 404 no key published also definite — nobody has vouched for a key here
* 200 { pins: [...] } the keys a peer may present
*
* What matters is that both differ from a 5xx or a timeout. A definite no means
* refuse the connection; an outage means try again later. A client that treats
* them alike either fails closed forever or fails open once, and the second is
* how pinning gets quietly defeated.
*/
moshpitRouter.get("/api/moshpit/pins", async (req, res) => {
const name = String(req.query.name ?? "").trim();
if (!name) return bad(res, "name is required");

const requested = req.query.kind ? String(req.query.kind) : null;
const kind = requested ? normalizePinKind(requested) : null;
if (requested && !kind) return bad(res, `kind must be one of ${PIN_KINDS.join(", ")}`);

const found = await pinsForName(name, kind);
if (!found) return bad(res, "not a Moshpit name");

const body = {
name: found.name,
resolved: found.resolved,
tld: found.tld,
label: found.label,
target: found.target,
// A flat array of strings first: that is all a client needs in order to
// compare against what a peer actually presented.
pins: found.pins.map((p) => p.pin),
entries: found.pins.map((p) => ({ pin: p.pin, kind: p.kind, note: p.note })),
};
return found.pins.length ? res.json(body) : res.status(404).json(body);
});

/** GET /api/moshpit/tlds/:tld/pins?label=blue — public; pins are public by nature. */
moshpitRouter.get("/api/moshpit/tlds/:tld/pins", async (req, res) => {
const tld = normalizeTld(req.params.tld);
const label = normalizeLabel(req.query.label);
if (!tld || !label) return bad(res, "tld and label are required");

const requested = req.query.kind ? String(req.query.kind) : null;
const kind = requested ? normalizePinKind(requested) : null;
if (requested && !kind) return bad(res, `kind must be one of ${PIN_KINDS.join(", ")}`);

res.json({ tld, label, pins: await listPins(tld, label, kind) });
});

/**
* POST /api/moshpit/tlds/:tld/pins { label, pin, kind, note? } — publish a key.
*
* Adds rather than replaces, so rotation has a window: publish the new key
* alongside the old, deploy it, then withdraw the old. Replacing outright would
* break every client between the write and the deploy.
*/
moshpitRouter.post("/api/moshpit/tlds/:tld/pins", async (req, res) => {
if (!req.user) return unauthorized(res);
const result = await addPin({
tld: req.params.tld,
label: req.body?.label,
pin: req.body?.pin,
kind: req.body?.kind,
note: req.body?.note,
userId: req.user.id,
});
// 409 when the pin is already published under another kind: the request was
// well formed, it just contradicts what is already there.
if (!result.ok) return bad(res, result.error || "could not publish that pin", result.taken ? 409 : 400);
res.status(201).json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), kind: req.body?.kind });
});

/** DELETE /api/moshpit/tlds/:tld/pins { label, pin } — withdraw a key. */
moshpitRouter.delete("/api/moshpit/tlds/:tld/pins", async (req, res) => {
if (!req.user) return unauthorized(res);
const result = await removePin({
tld: req.params.tld, label: req.body?.label, pin: req.body?.pin, userId: req.user.id,
});
if (!result.ok) return bad(res, result.error || "could not withdraw that pin", 404);
res.json({ tld: normalizeTld(req.params.tld), label: normalizeLabel(req.body?.label), withdrawn: true });
});

/* ---- the market ---- */

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