From 5dfe22d1c9dd8ff63cca0aaf93c66499944d0538 Mon Sep 17 00:00:00 2001 From: anthony Date: Fri, 31 Jul 2026 07:57:02 +0000 Subject: [PATCH] moshpit: publish key pins per TLD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The registry has never had a per-name record — you claim `.eggs`, not `scrambled.eggs` — so there was nowhere for a key to hang. Pins therefore attach to the TLD: `.eggs` publishes the keys that names under it may present, and a client that resolved `scrambled.eggs` knows what to expect without the registry ever having to know that `scrambled` exists. The trade is real and is written down in the code: names under one TLD share a key, so there is no isolation between them. The TLD's operator could impersonate any name under it — which they can do regardless, since they own the namespace and decide where its names point. `kind` separates the transports. 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 idea why. The same pin cannot be published under two kinds. Several rows per (tld, kind) on purpose: a key cannot rotate without a window where the old and new are both published. Withdrawing the last key of a kind is allowed — that is how a compromised key is revoked, and refusing it on the grounds that it breaks connections would be refusing the point. GET /api/moshpit/pins?name=&kind= public; 400 not a name, 404 no key GET /api/moshpit/tlds/:tld/pins public POST /api/moshpit/tlds/:tld/pins owner only DELETE /api/moshpit/tlds/:tld/pins?pin= owner only Aliases are followed before pins are read: `foo.agentic` under an alias to `.agent` connects to whatever serves `foo.agent`, so `.agent`'s keys are the ones that will be presented. 15 tests against a real SQLite database rather than a stub, since the behaviour worth checking is in the SQL and the ownership checks. One of them caught a genuine bug: `pinsForName` did not check `registered`, so `example.com` parsed as label `example` under TLD `com` and was answered "no key published" instead of "not a Moshpit name" — two things clients cache differently and act on differently. Co-Authored-By: Claude Opus 5 (1M context) --- app/api/moshpit/pins/route.ts | 55 +++++++ app/api/moshpit/tlds/[tld]/pins/route.ts | 57 +++++++ lib/db.ts | 25 +++ lib/moshpit.ts | 181 +++++++++++++++++++++ tests/moshpit-pins.test.mjs | 195 +++++++++++++++++++++++ 5 files changed, 513 insertions(+) create mode 100644 app/api/moshpit/pins/route.ts create mode 100644 app/api/moshpit/tlds/[tld]/pins/route.ts create mode 100644 tests/moshpit-pins.test.mjs diff --git a/app/api/moshpit/pins/route.ts b/app/api/moshpit/pins/route.ts new file mode 100644 index 0000000..ca02c1d --- /dev/null +++ b/app/api/moshpit/pins/route.ts @@ -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 })), + }); +} diff --git a/app/api/moshpit/tlds/[tld]/pins/route.ts b/app/api/moshpit/tlds/[tld]/pins/route.ts new file mode 100644 index 0000000..d2a885a --- /dev/null +++ b/app/api/moshpit/tlds/[tld]/pins/route.ts @@ -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 }); +} diff --git a/lib/db.ts b/lib/db.ts index b49bdc6..9116edb 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -274,6 +274,31 @@ async function initSchema(): Promise { ) `); + // 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. diff --git a/lib/moshpit.ts b/lib/moshpit.ts index 28472af..69fc1f8 100644 --- a/lib/moshpit.ts +++ b/lib/moshpit.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }; +} diff --git a/tests/moshpit-pins.test.mjs b/tests/moshpit-pins.test.mjs new file mode 100644 index 0000000..c7dfb55 --- /dev/null +++ b/tests/moshpit-pins.test.mjs @@ -0,0 +1,195 @@ +// Key pins, against a real SQLite database rather than a stub. +// +// The interesting behaviour here is all in SQL and in ownership checks — which +// rows come back for an aliased name, whether a second account can publish +// under your TLD — and none of that survives being mocked. libsql takes a +// `file:` URL, so a throwaway database costs a temp directory. +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash, randomBytes } from "node:crypto"; + +// Set before the module graph loads: lib/db reads this at first use and caches. +const dir = await mkdtemp(join(tmpdir(), "moshpit-pins-")); +process.env.TURSO_DATABASE_URL = `file:${join(dir, "test.db")}`; + +const { + PIN_KINDS, addPin, isPin, listPins, normalizePinKind, pinsForName, registerTld, removePin, setAlias, +} = await import("../lib/moshpit.ts"); + +/** A pin is SHA-256 over an SPKI; any 32 bytes stand in for one here. */ +const somePin = () => createHash("sha256").update(randomBytes(32)).digest("base64"); + +const OWNER = "acct-owner"; +const STRANGER = "acct-stranger"; +let counter = 0; +const freshTld = async (accountId = OWNER) => { + const tld = `t${counter++}${randomBytes(3).toString("hex")}`; + const result = await registerTld({ tld, accountId, ownerEmail: null, ownerKey: null }); + assert.ok(result.ok, `could not register .${tld}: ${result.error}`); + return tld; +}; + +test("a pin is 32 base64 bytes, and nothing else passes", () => { + assert.equal(isPin(somePin()), true); + + // Every one of these has been a real bug in someone's pinning code. + assert.equal(isPin(""), false); + assert.equal(isPin("hunter2"), false); + assert.equal(isPin(createHash("sha1").update("x").digest("base64")), false, "sha-1 is 20 bytes"); + assert.equal(isPin(createHash("sha512").update("x").digest("base64")), false, "sha-512 is 64 bytes"); + assert.equal(isPin(randomBytes(32).toString("hex")), false, "hex is not base64"); + assert.equal(isPin(somePin().replace("=", "")), false, "unpadded"); + assert.equal(isPin(` ${somePin()}`), false, "whitespace"); + assert.equal(isPin(null), false); + assert.equal(isPin(undefined), false); + assert.equal(isPin(12345), false); +}); + +test("kinds are exactly tls and mtp", () => { + assert.deepEqual([...PIN_KINDS], ["tls", "mtp"]); + assert.equal(normalizePinKind("TLS"), "tls"); + assert.equal(normalizePinKind(" mtp "), "mtp"); + assert.equal(normalizePinKind("ssh"), null); + assert.equal(normalizePinKind(""), null); + assert.equal(normalizePinKind(null), null); +}); + +test("a published pin comes back for every name under the TLD", async () => { + const tld = await freshTld(); + const pin = somePin(); + assert.ok((await addPin({ tld, pin, kind: "tls", accountId: OWNER })).ok); + + // The point of pinning per TLD: the registry has no row for either name. + for (const name of [`scrambled.${tld}`, `anything.${tld}`]) { + const found = await pinsForName(name); + assert.deepEqual(found.pins.map((p) => p.pin), [pin], `wrong pins for ${name}`); + assert.equal(found.tld, tld); + } +}); + +test("a name under an unpinned TLD returns nothing, not an error", async () => { + const tld = await freshTld(); + const found = await pinsForName(`bare.${tld}`); + assert.deepEqual(found.pins, []); +}); + +test("a name outside the namespace resolves to null", async () => { + assert.equal(await pinsForName("example.com"), null); + assert.equal(await pinsForName("not-a-name"), null); +}); + +test("kinds are kept apart", async () => { + const tld = await freshTld(); + const tls = somePin(); + const mtp = somePin(); + await addPin({ tld, pin: tls, kind: "tls", accountId: OWNER }); + await addPin({ tld, pin: mtp, kind: "mtp", accountId: OWNER }); + + assert.deepEqual((await listPins(tld, "tls")).map((p) => p.pin), [tls]); + assert.deepEqual((await listPins(tld, "mtp")).map((p) => p.pin), [mtp]); + assert.equal((await listPins(tld)).length, 2, "unfiltered returns both"); +}); + +test("the same pin cannot be published as two kinds", async () => { + const tld = await freshTld(); + const pin = somePin(); + await addPin({ tld, pin, kind: "tls", accountId: OWNER }); + + const result = await addPin({ tld, pin, kind: "mtp", accountId: OWNER }); + assert.equal(result.ok, false); + assert.equal(result.taken, true); + assert.match(result.error, /already published/); +}); + +test("publishing the same pin twice is a no-op, not a duplicate", async () => { + const tld = await freshTld(); + const pin = somePin(); + await addPin({ tld, pin, kind: "tls", accountId: OWNER }); + assert.equal((await addPin({ tld, pin, kind: "tls", accountId: OWNER })).ok, true); + assert.equal((await listPins(tld)).length, 1); +}); + +test("rotation: both keys live at once, then the old one goes", async () => { + const tld = await freshTld(); + const oldKey = somePin(); + const newKey = somePin(); + + await addPin({ tld, pin: oldKey, kind: "tls", accountId: OWNER }); + await addPin({ tld, pin: newKey, kind: "tls", accountId: OWNER }); + + // The window that makes rotation possible without a flag day. + const during = (await pinsForName(`site.${tld}`)).pins.map((p) => p.pin); + assert.equal(during.length, 2); + assert.ok(during.includes(oldKey) && during.includes(newKey)); + + assert.equal((await removePin({ tld, pin: oldKey, accountId: OWNER })).ok, true); + assert.deepEqual((await pinsForName(`site.${tld}`)).pins.map((p) => p.pin), [newKey]); +}); + +test("withdrawing the last key is allowed — that is how a key is revoked", async () => { + const tld = await freshTld(); + const pin = somePin(); + await addPin({ tld, pin, kind: "tls", accountId: OWNER }); + + assert.equal((await removePin({ tld, pin, accountId: OWNER })).ok, true); + assert.deepEqual((await pinsForName(`site.${tld}`)).pins, [], "no key published means refuse, not allow"); +}); + +test("only the TLD's owner can publish or withdraw", async () => { + const tld = await freshTld(OWNER); + const pin = somePin(); + + const hijack = await addPin({ tld, pin, kind: "tls", accountId: STRANGER }); + assert.equal(hijack.ok, false); + assert.match(hijack.error, /do not own/); + + await addPin({ tld, pin, kind: "tls", accountId: OWNER }); + const theft = await removePin({ tld, pin, accountId: STRANGER }); + assert.equal(theft.ok, false); + assert.match(theft.error, /do not own/); + assert.equal((await listPins(tld)).length, 1, "the pin survived the attempt"); +}); + +test("an unregistered TLD cannot be pinned", async () => { + const result = await addPin({ tld: "nobodyholdsthis", pin: somePin(), kind: "tls", accountId: OWNER }); + assert.equal(result.ok, false); + assert.match(result.error, /not registered/); +}); + +test("a malformed pin is refused at the door", async () => { + const tld = await freshTld(); + for (const bad of ["", "hunter2", randomBytes(32).toString("hex"), null]) { + const result = await addPin({ tld, pin: bad, kind: "tls", accountId: OWNER }); + assert.equal(result.ok, false, `accepted ${JSON.stringify(bad)}`); + } + assert.equal((await addPin({ tld, pin: somePin(), kind: "ssh", accountId: OWNER })).ok, false); +}); + +test("an aliased name is pinned by the TLD it actually points at", async () => { + const target = await freshTld(); + const alias = await freshTld(); + + const targetPin = somePin(); + const aliasPin = somePin(); + await addPin({ tld: target, pin: targetPin, kind: "tls", accountId: OWNER }); + await addPin({ tld: alias, pin: aliasPin, kind: "tls", accountId: OWNER }); + + assert.ok((await setAlias({ from: alias, to: target, accountId: OWNER })).ok); + + // foo. connects to whatever serves foo., so the target's key + // is the one that will be presented. Answering with the alias's own pin + // would refuse every working connection. + const found = await pinsForName(`foo.${alias}`); + assert.equal(found.resolved, `foo.${target}`); + assert.equal(found.tld, target); + assert.deepEqual(found.pins.map((p) => p.pin), [targetPin]); +}); + +test("withdrawing a pin that was never published fails", async () => { + const tld = await freshTld(); + const result = await removePin({ tld, pin: somePin(), accountId: OWNER }); + assert.equal(result.ok, false); +});