diff --git a/apps/pwa/src/migrations/009_moshpit_pins.sql b/apps/pwa/src/migrations/009_moshpit_pins.sql new file mode 100644 index 0000000..c6244de --- /dev/null +++ b/apps/pwa/src/migrations/009_moshpit_pins.sql @@ -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); diff --git a/apps/pwa/src/moshpit.mjs b/apps/pwa/src/moshpit.mjs index 01c4eaa..f6abe94 100644 --- a/apps/pwa/src/moshpit.mjs +++ b/apps/pwa/src/moshpit.mjs @@ -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 }; @@ -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 }; +} diff --git a/apps/pwa/src/routes/moshpit.mjs b/apps/pwa/src/routes/moshpit.mjs index 7713a56..2d3aada 100644 --- a/apps/pwa/src/routes/moshpit.mjs +++ b/apps/pwa/src/routes/moshpit.mjs @@ -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"; @@ -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. */ diff --git a/apps/pwa/test/moshpit-pins.test.mjs b/apps/pwa/test/moshpit-pins.test.mjs new file mode 100644 index 0000000..32e3b61 --- /dev/null +++ b/apps/pwa/test/moshpit-pins.test.mjs @@ -0,0 +1,226 @@ +// The keys a name may present, against a real (throwaway) libSQL database. +// +// The behaviour worth checking is in the SQL and the ownership checks — who may +// publish under a name that changed hands, what a sold name inherits — and none +// of that survives being mocked. +// +// Skips cleanly when the PWA dependencies are not installed. +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { createHash, randomBytes } from "node:crypto"; +import test from "node:test"; + +const require = createRequire(import.meta.url); +let installed = true; +try { require("@libsql/client"); } catch { installed = false; } + +const workdir = mkdtempSync(path.join(tmpdir(), "moshcode-pins-test-")); +process.env.DATABASE_URL = `file:${path.join(workdir, "test.db")}`; +process.env.SESSION_SECRET = "test-secret"; + +const ALICE = "user-alice"; +const BOB = "user-bob"; + +/** A pin is SHA-256 over an SPKI; any 32 random bytes stand in for one here. */ +const somePin = () => createHash("sha256").update(randomBytes(32)).digest("base64"); + +async function boot() { + const { migrate } = await import("../src/migrate.mjs"); + await migrate(); + const { run } = await import("../src/db.mjs"); + for (const [id, email] of [[ALICE, "alice@example.com"], [BOB, "bob@example.com"]]) { + await run(`INSERT OR IGNORE INTO users (id, email, created_at) VALUES (?,?,?)`, [id, email, Date.now()]); + } + return { moshpit: await import("../src/moshpit.mjs"), run }; +} + +test("moshpit pins", { skip: installed ? false : "pwa dependencies not installed" }, async (t) => { + const { moshpit: m, run } = await boot(); + let n = 0; + const freshTld = async (userId = ALICE) => { + const tld = `p${n++}${randomBytes(3).toString("hex")}`; + const r = await m.registerTld({ tld, userId, ownerEmail: null }); + assert.ok(r.ok, `could not claim .${tld}: ${r.error}`); + return tld; + }; + + await t.test("a pin is 32 base64 bytes and nothing else", () => { + assert.equal(m.isPin(somePin()), true); + // Each of these has been a real bug in someone's pinning code. + assert.equal(m.isPin(""), false); + assert.equal(m.isPin("hunter2"), false); + assert.equal(m.isPin(createHash("sha1").update("x").digest("base64")), false, "sha-1 is 20 bytes"); + assert.equal(m.isPin(createHash("sha512").update("x").digest("base64")), false, "sha-512 is 64 bytes"); + assert.equal(m.isPin(randomBytes(32).toString("hex")), false, "hex is not base64"); + assert.equal(m.isPin(somePin().replace("=", "")), false, "unpadded"); + assert.equal(m.isPin(null), false); + }); + + await t.test("kinds are exactly tls and mtp", () => { + assert.deepEqual(m.PIN_KINDS, ["tls", "mtp"]); + assert.equal(m.normalizePinKind("TLS"), "tls"); + assert.equal(m.normalizePinKind(" mtp "), "mtp"); + assert.equal(m.normalizePinKind("ssh"), null); + assert.equal(m.normalizePinKind(null), null); + }); + + await t.test("a published key comes back for that name", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "blue", userId: ALICE }); + const pin = somePin(); + assert.ok((await m.addPin({ tld, label: "blue", pin, kind: "tls", userId: ALICE })).ok); + + const found = await m.pinsForName(`blue.${tld}`); + assert.deepEqual(found.pins.map((p) => p.pin), [pin]); + assert.equal(found.label, "blue"); + }); + + await t.test("a sibling name is unaffected", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "one", userId: ALICE }); + await m.registerName({ tld, label: "two", userId: ALICE }); + await m.addPin({ tld, label: "one", pin: somePin(), kind: "tls", userId: ALICE }); + + assert.deepEqual((await m.pinsForName(`two.${tld}`)).pins, [], "keys must not leak across names"); + }); + + await t.test("the TLD operator cannot publish a key for a name they sold", async () => { + const tld = await freshTld(ALICE); + // Bob bought `mine.`: the name row is his, the TLD is still Alice's. + await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`, + [tld, "mine", BOB, null, Date.now()]); + + // This is the whole reason pins are per name here rather than per TLD. + const hijack = await m.addPin({ tld, label: "mine", pin: somePin(), kind: "tls", userId: ALICE }); + assert.equal(hijack.ok, false); + assert.match(hijack.error, /do not own/); + + assert.ok((await m.addPin({ tld, label: "mine", pin: somePin(), kind: "tls", userId: BOB })).ok); + }); + + await t.test("kinds are kept apart", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "both", userId: ALICE }); + const tls = somePin(); + const mtp = somePin(); + await m.addPin({ tld, label: "both", pin: tls, kind: "tls", userId: ALICE }); + await m.addPin({ tld, label: "both", pin: mtp, kind: "mtp", userId: ALICE }); + + assert.deepEqual((await m.listPins(tld, "both", "tls")).map((p) => p.pin), [tls]); + assert.deepEqual((await m.listPins(tld, "both", "mtp")).map((p) => p.pin), [mtp]); + assert.equal((await m.listPins(tld, "both")).length, 2); + }); + + await t.test("the same pin cannot be two kinds", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "dup", userId: ALICE }); + const pin = somePin(); + await m.addPin({ tld, label: "dup", pin, kind: "tls", userId: ALICE }); + + const clash = await m.addPin({ tld, label: "dup", pin, kind: "mtp", userId: ALICE }); + assert.equal(clash.ok, false); + assert.equal(clash.taken, true); + // Publishing it twice under the same kind is a no-op, not a duplicate. + assert.equal((await m.addPin({ tld, label: "dup", pin, kind: "tls", userId: ALICE })).ok, true); + assert.equal((await m.listPins(tld, "dup")).length, 1); + }); + + await t.test("rotation: both keys live, then the old one goes", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "rot", userId: ALICE }); + const oldKey = somePin(); + const newKey = somePin(); + await m.addPin({ tld, label: "rot", pin: oldKey, kind: "tls", userId: ALICE }); + await m.addPin({ tld, label: "rot", pin: newKey, kind: "tls", userId: ALICE }); + + assert.equal((await m.pinsForName(`rot.${tld}`)).pins.length, 2, "the window that makes rotation possible"); + + assert.ok((await m.removePin({ tld, label: "rot", pin: oldKey, userId: ALICE })).ok); + assert.deepEqual((await m.pinsForName(`rot.${tld}`)).pins.map((p) => p.pin), [newKey]); + }); + + await t.test("withdrawing the last key is allowed — that is revocation", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "gone", userId: ALICE }); + const pin = somePin(); + await m.addPin({ tld, label: "gone", pin, kind: "tls", userId: ALICE }); + + assert.ok((await m.removePin({ tld, label: "gone", pin, userId: ALICE })).ok); + assert.deepEqual((await m.pinsForName(`gone.${tld}`)).pins, [], "no key means refuse, not allow"); + }); + + await t.test("releasing a name takes its keys with it", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "handover", userId: ALICE }); + await m.addPin({ tld, label: "handover", pin: somePin(), kind: "tls", userId: ALICE }); + + assert.ok((await m.releaseName({ tld, label: "handover", userId: ALICE })).ok); + + // SQLite only honours ON DELETE CASCADE with PRAGMA foreign_keys = ON, which + // this app never sets — so without an explicit delete the next holder would + // inherit Alice's published keys. + assert.deepEqual(await m.listPins(tld, "handover"), [], "a new owner must not inherit old keys"); + + await run(`INSERT INTO moshpit_names (tld, label, user_id, target, created_at) VALUES (?,?,?,?,?)`, + [tld, "handover", BOB, null, Date.now()]); + assert.deepEqual((await m.pinsForName(`handover.${tld}`)).pins, []); + }); + + await t.test("only the name's holder may publish or withdraw", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "mine", userId: ALICE }); + const pin = somePin(); + + assert.equal((await m.addPin({ tld, label: "mine", pin, kind: "tls", userId: BOB })).ok, false); + await m.addPin({ tld, label: "mine", pin, kind: "tls", userId: ALICE }); + assert.equal((await m.removePin({ tld, label: "mine", pin, userId: BOB })).ok, false); + assert.equal((await m.listPins(tld, "mine")).length, 1, "the key survived the attempt"); + }); + + await t.test("an unregistered name cannot be pinned", async () => { + const tld = await freshTld(); + const result = await m.addPin({ tld, label: "ghost", pin: somePin(), kind: "tls", userId: ALICE }); + assert.equal(result.ok, false); + assert.match(result.error, /not registered/); + }); + + await t.test("malformed input is refused at the door", async () => { + const tld = await freshTld(); + await m.registerName({ tld, label: "strict", userId: ALICE }); + for (const bad of ["", "hunter2", randomBytes(32).toString("hex"), null]) { + assert.equal((await m.addPin({ tld, label: "strict", pin: bad, kind: "tls", userId: ALICE })).ok, false, + `accepted ${JSON.stringify(bad)}`); + } + assert.equal((await m.addPin({ tld, label: "strict", pin: somePin(), kind: "ssh", userId: ALICE })).ok, false); + }); + + await t.test("an aliased name is pinned by the TLD it points at", async () => { + const target = await freshTld(); + const alias = await freshTld(); + await m.registerName({ tld: target, label: "foo", userId: ALICE }); + await m.registerName({ tld: alias, label: "foo", userId: ALICE }); + + const targetPin = somePin(); + const aliasPin = somePin(); + await m.addPin({ tld: target, label: "foo", pin: targetPin, kind: "tls", userId: ALICE }); + await m.addPin({ tld: alias, label: "foo", pin: aliasPin, kind: "tls", userId: ALICE }); + assert.ok((await m.setAlias({ from: alias, to: target, userId: ALICE })).ok); + + // foo. connects to whatever serves foo., so that is the key + // which will be presented. Answering with the alias's own would refuse + // every working connection. + const found = await m.pinsForName(`foo.${alias}`); + assert.equal(found.resolved, `foo.${target}`); + assert.deepEqual(found.pins.map((p) => p.pin), [targetPin]); + }); + + await t.test("a name outside the namespace is not merely unpinned", async () => { + // Answering "no key published" about example.com would invite a client to + // treat clearnet as something the pit has an opinion about. + assert.equal(await m.pinsForName("example.com"), null); + assert.equal(await m.pinsForName("not-a-name"), null); + }); +});