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
55 changes: 55 additions & 0 deletions app/api/moshpit/pins/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from "next/server";
import { bad } from "@/lib/api";
import { PIN_KINDS, normalizePinKind, pinsForName } from "@/lib/moshpit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* 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 — the name exists, nobody vouched for a key
* 200 { pins: [...] } the keys a peer may present
*
* The distinction that matters is between those and a 5xx or a timeout. A
* definite no means refuse the connection; an outage means try again. A client
* that treats them alike either fails closed forever or fails open once, and
* the second one is how pinning gets quietly defeated.
*
* `kind` is optional, and omitting it is safe rather than merely convenient: a
* pin of the wrong kind can never match, since an ML-DSA SPKI hash will not
* equal a presented TLS SPKI hash. Passing it keeps the answer honest about
* what the name actually offers.
*/
export async function GET(req: NextRequest) {
const name = (req.nextUrl.searchParams.get("name") ?? "").trim();
if (!name) return bad("name is required");

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

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

if (!found.pins.length) {
return NextResponse.json(
{ name: found.name, resolved: found.resolved, tld: found.tld, pins: [] },
{ status: 404 },
);
}

return NextResponse.json({
name: found.name,
resolved: found.resolved,
tld: found.tld,
// A flat array of strings first, because that is all a client needs to
// compare against what a peer presented.
pins: found.pins.map((p) => p.pin),
entries: found.pins.map((p) => ({ pin: p.pin, kind: p.kind, note: p.note })),
});
}
57 changes: 57 additions & 0 deletions app/api/moshpit/tlds/[tld]/pins/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
import { PIN_KINDS, addPin, listPins, normalizePinKind, removePin } from "@/lib/moshpit";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/** GET /api/moshpit/tlds/:tld/pins[?kind=tls] — public; pins are public by nature. */
export async function GET(req: NextRequest, ctx: { params: Promise<{ tld: string }> }) {
const { tld } = await ctx.params;
const requested = req.nextUrl.searchParams.get("kind");
const kind = requested ? normalizePinKind(requested) : null;
if (requested && !kind) return bad(`kind must be one of ${PIN_KINDS.join(", ")}`);

return NextResponse.json({ tld, pins: await listPins(tld, kind) });
}

/**
* POST /api/moshpit/tlds/:tld/pins { pin, kind, note? } — publish a key.
*
* Adding rather than replacing, so rotation has a window: publish the new key
* alongside the old one, deploy it, then withdraw the old. Replacing outright
* would break every client between the write and the deploy.
*/
export async function POST(req: NextRequest, ctx: { params: Promise<{ tld: string }> }) {
const accountId = await resolveAccountId(req);
if (!accountId) return unauthorized();
const { tld } = await ctx.params;

const body = await req.json().catch(() => ({}));
const result = await addPin({
tld,
pin: body?.pin,
kind: body?.kind,
note: body?.note,
accountId,
});

// 409 when the pin is already published under a different kind: the request
// was well formed, it just contradicts what is already there.
if (!result.ok) return bad(result.error || "could not publish that pin", result.taken ? 409 : 400);
return NextResponse.json({ tld, pin: body.pin, kind: body.kind }, { status: 201 });
}

/** DELETE /api/moshpit/tlds/:tld/pins?pin=... — withdraw a key. */
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ tld: string }> }) {
const accountId = await resolveAccountId(req);
if (!accountId) return unauthorized();
const { tld } = await ctx.params;

const pin = req.nextUrl.searchParams.get("pin") ?? "";
if (!pin) return bad("pin is required");

const result = await removePin({ tld, pin, accountId });
if (!result.ok) return bad(result.error || "could not withdraw that pin", 404);
return NextResponse.json({ tld, pin, withdrawn: true });
}
25 changes: 25 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,31 @@ async function initSchema(): Promise<void> {
)
`);

// Key pins, at TLD granularity. `.eggs` publishes the keys that names under
// it may present, so a client that resolved `scrambled.eggs` knows what to
// expect without the registry ever having to know that `scrambled` exists.
//
// `kind` keeps the transports apart. A `tls` pin covers a certificate's
// SubjectPublicKeyInfo (moshpit-proxy); an `mtp` pin covers an ML-DSA-65
// identity (moshpit-transport). Both are SHA-256 over an SPKI, so as strings
// they are indistinguishable — nothing but this column stops a client from
// being handed the wrong one and failing with no idea why.
//
// Several rows per (tld, kind) on purpose: a key cannot rotate without a
// window in which the old and the new one are both published.
await d.execute(`
CREATE TABLE IF NOT EXISTS moshpit_tld_pins (
tld TEXT NOT NULL,
pin TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('tls','mtp')),
note TEXT,
account_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (tld, pin)
)
`);
await d.execute(`CREATE INDEX IF NOT EXISTS idx_moshpit_tld_pins ON moshpit_tld_pins (tld, kind)`);

// Append-only. No UPDATE or DELETE is ever issued against this table: "who
// claimed .eggs first" has to stay answerable after the fact, including when
// the answer is inconvenient.
Expand Down
181 changes: 181 additions & 0 deletions lib/moshpit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,184 @@ export async function clearExempt(opts: {
});
return { ok: true };
}

// ---- key pins --------------------------------------------------------------
//
// A TLD publishes the keys that names under it are allowed to present. Pinning
// at this granularity rather than per name follows from how the namespace is
// actually held: you claim `.eggs`, not `scrambled.eggs`, so the registry has
// no row for an individual name to hang a key on and would have to invent one.
//
// The trade is honest and worth stating. Every name under a TLD shares a key,
// so the operator of `.eggs` can impersonate any name under it — which they
// could do anyway, since they own the namespace and decide where its names
// point. What this does not give you is isolation *between* names under one
// TLD, which would need a per-name registry and a rotation story for each.

export type PinKind = "tls" | "mtp";
export const PIN_KINDS: readonly PinKind[] = ["tls", "mtp"];

export type MoshpitPin = {
tld: string;
pin: string;
kind: PinKind;
note: string | null;
created_at: string;
};

export type PinResult = { ok: boolean; error?: string; taken?: boolean };

/**
* 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 from a key that simply never matches:
* the connection fails, and nothing anywhere says why.
*/
export function isPin(value: unknown): value is string {
if (typeof value !== "string" || !/^[A-Za-z0-9+/]{43}=$/.test(value)) return false;
return Buffer.from(value, "base64").length === 32;
}

export function normalizePinKind(value: unknown): PinKind | null {
const kind = String(value ?? "").trim().toLowerCase();
return (PIN_KINDS as readonly string[]).includes(kind) ? (kind as PinKind) : null;
}

export async function listPins(tld: string, kind?: PinKind | null): Promise<MoshpitPin[]> {
await ensureSchema();
const normalized = normalizeTld(tld);
if (!normalized) return [];

const r = await db().execute(
kind
? {
sql: `SELECT tld, pin, kind, note, created_at FROM moshpit_tld_pins
WHERE tld = ? AND kind = ? ORDER BY created_at DESC`,
args: [normalized, kind],
}
: {
sql: `SELECT tld, pin, kind, note, created_at FROM moshpit_tld_pins
WHERE tld = ? ORDER BY kind, created_at DESC`,
args: [normalized],
},
);
return r.rows as unknown as MoshpitPin[];
}

export type PinsForName = {
name: string;
/** Where the name actually points; pins come from this TLD, not the typed one. */
resolved: string;
tld: string;
pins: MoshpitPin[];
};

/**
* The pins a client should accept for `scrambled.eggs`.
*
* Aliases are followed first. When `.agentic` points at `.agent`, a client
* asking about `foo.agentic` is going to connect to whatever serves
* `foo.agent`, so the keys that matter are `.agent`'s. Answering with
* `.agentic`'s pins would refuse every working connection.
*/
export async function pinsForName(input: string, kind?: PinKind | null): Promise<PinsForName | null> {
const resolution = await resolveMoshpitName(input);
if (!resolution) return null;

// An unregistered TLD is not a Moshpit name, and saying so matters. Without
// this check `example.com` parses as label `example` under TLD `com`, nobody
// holds `.com`, and the caller is told "registered, no key published" about
// a clearnet name it should have been told to leave alone. The two answers
// are cached differently by clients and mean different things.
if (!resolution.registered) return null;

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

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

export async function addPin(opts: {
tld: string;
pin: string;
kind: unknown;
note?: unknown;
accountId: string;
}): Promise<PinResult> {
await ensureSchema();
const tld = normalizeTld(opts.tld);
if (!tld) return { ok: false, error: "not a valid TLD" };
if (!isPin(opts.pin)) {
return { ok: false, error: "pin must be base64 SHA-256 over a SubjectPublicKeyInfo (44 chars)" };
}
const kind = normalizePinKind(opts.kind);
if (!kind) return { ok: false, error: `kind must be one of ${PIN_KINDS.join(", ")}` };

const owner = await getTld(tld);
if (!owner) return { ok: false, error: `.${tld} is not registered` };
if (owner.account_id !== opts.accountId) return { ok: false, error: `you do not own .${tld}` };

const note = typeof opts.note === "string" && opts.note.trim() ? opts.note.trim().slice(0, 200) : null;

// A pin already present under a different kind is a mistake worth naming.
// Silently ignoring it would leave the operator convinced they published an
// `mtp` key while clients keep being told it is `tls`.
const existing = await db().execute({
sql: `SELECT kind FROM moshpit_tld_pins WHERE tld = ? AND pin = ?`,
args: [tld, opts.pin],
});
const priorKind = (existing.rows[0] as unknown as { kind: PinKind } | undefined)?.kind;
if (priorKind && priorKind !== kind) {
return { ok: false, error: `that pin is already published for .${tld} as ${priorKind}`, taken: true };
}
if (priorKind === kind) return { ok: true };

await db().execute({
sql: `INSERT INTO moshpit_tld_pins (tld, pin, kind, note, account_id) VALUES (?,?,?,?,?)`,
args: [tld, opts.pin, kind, note, opts.accountId],
});
await db().execute({
sql: `INSERT INTO moshpit_tld_log (tld, account_id, action) VALUES (?,?,?)`,
args: [tld, opts.accountId, `pin:add:${kind}:${opts.pin}`],
});
return { ok: true };
}

/**
* Withdraw a key.
*
* Removing the last pin of a kind is allowed. It means "no key published",
* which clients treat as a refusal rather than as permission — so this is how
* an operator takes a compromised key out of service, and it must not be
* blocked on the grounds that it breaks connections. Breaking them is the point.
*/
export async function removePin(opts: {
tld: string;
pin: string;
accountId: string;
}): Promise<PinResult> {
await ensureSchema();
const tld = normalizeTld(opts.tld);
if (!tld) return { ok: false, error: "not a valid TLD" };

const owner = await getTld(tld);
if (!owner) return { ok: false, error: `.${tld} is not registered` };
if (owner.account_id !== opts.accountId) return { ok: false, error: `you do not own .${tld}` };

const r = await db().execute({
sql: `DELETE FROM moshpit_tld_pins WHERE tld = ? AND pin = ?`,
args: [tld, opts.pin],
});
if (!r.rowsAffected) return { ok: false, error: "that pin is not published for this TLD" };

await db().execute({
sql: `INSERT INTO moshpit_tld_log (tld, account_id, action) VALUES (?,?,?)`,
args: [tld, opts.accountId, `pin:remove:${opts.pin}`],
});
return { ok: true };
}
Loading
Loading