diff --git a/app/api/account/keys/route.ts b/app/api/account/keys/route.ts new file mode 100644 index 0000000..2120021 --- /dev/null +++ b/app/api/account/keys/route.ts @@ -0,0 +1,55 @@ +// API keys for the signed-in account. +// +// Session-authenticated only, deliberately: a key must not be able to mint +// another key. Otherwise one leak becomes permanent access that outlives +// revoking the key that leaked, and the revoke button stops meaning anything. + +import { NextRequest, NextResponse } from "next/server"; +import { resolveAccountId, bad, unauthorized } from "@/lib/api"; +import { createApiKey, listApiKeys, revokeApiKey } from "@/lib/apikeys"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** GET /api/account/keys — the account's keys. Never the tokens; they are not stored. */ +export async function GET(req: NextRequest) { + const accountId = await resolveAccountId(req); + if (!accountId) return unauthorized(); + return NextResponse.json({ keys: await listApiKeys(accountId) }); +} + +/** + * POST /api/account/keys { name? } — mint one. + * + * The token comes back exactly once. Only its hash is stored, so it cannot be + * shown again by us or by anyone who reaches the database — the response says + * so, because a UI that does not will produce a support ticket instead of a + * saved credential. + */ +export async function POST(req: NextRequest) { + const accountId = await resolveAccountId(req); + if (!accountId) return unauthorized(); + + const body = await req.json().catch(() => ({})); + const { token, row } = await createApiKey(accountId, body?.name); + + return NextResponse.json( + { key: row, token, note: "Copy this now — it is stored only as a hash and cannot be shown again." }, + { status: 201 }, + ); +} + +/** DELETE /api/account/keys?id=... — revoke, effective on the next request. */ +export async function DELETE(req: NextRequest) { + const accountId = await resolveAccountId(req); + if (!accountId) return unauthorized(); + + const id = req.nextUrl.searchParams.get("id") ?? ""; + if (!id) return bad("id is required"); + + // Scoped to the account inside the UPDATE, so a wrong id is indistinguishable + // from someone else's id: neither reveals whether that key exists. + return (await revokeApiKey(accountId, id)) + ? NextResponse.json({ id, revoked: true }) + : bad("no such key", 404); +} diff --git a/app/api/moshpit/tlds/[tld]/pins/route.ts b/app/api/moshpit/tlds/[tld]/pins/route.ts index d2a885a..47d274d 100644 --- a/app/api/moshpit/tlds/[tld]/pins/route.ts +++ b/app/api/moshpit/tlds/[tld]/pins/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { resolveAccountId, bad, unauthorized } from "@/lib/api"; +import { resolveAccountIdOrToken, bad, unauthorized } from "@/lib/api"; import { PIN_KINDS, addPin, listPins, normalizePinKind, removePin } from "@/lib/moshpit"; export const runtime = "nodejs"; @@ -23,7 +23,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ tld: string * 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); + const accountId = await resolveAccountIdOrToken(req); if (!accountId) return unauthorized(); const { tld } = await ctx.params; @@ -44,7 +44,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ tld: strin /** 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); + const accountId = await resolveAccountIdOrToken(req); if (!accountId) return unauthorized(); const { tld } = await ctx.params; diff --git a/lib/api.ts b/lib/api.ts index 088ecd1..dcc4829 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { sessionUser, getOrCreateUser } from "./authz"; import { readSession, authConfigured, SESSION_COOKIE } from "./session"; import { findOrCreateAccountByEmail } from "./db"; +import { accountIdForToken, bearerToken } from "./apikeys"; /** Resolve the authenticated user (provisioning a default org/team on first hit). */ export async function requireUser(req: NextRequest) { @@ -25,5 +26,22 @@ export async function resolveAccountId(req: NextRequest): Promise return null; } +/** + * The account for a request, accepting an API key as well as a session. + * + * Separate from `resolveAccountId` rather than folded into it, so that adding + * a key path does not silently widen every account-scoped endpoint at once. + * A route opts in by calling this one, and the diff shows which routes did. + * + * Session first: a browser request carries both a cookie and, sometimes, an + * unrelated Authorization header, and the cookie is the stronger statement of + * who is driving. + */ +export async function resolveAccountIdOrToken(req: NextRequest): Promise { + const session = await resolveAccountId(req); + if (session) return session; + return accountIdForToken(bearerToken(req.headers.get("authorization"))); +} + export const unauthorized = () => NextResponse.json({ error: "Sign in first." }, { status: 401 }); export const bad = (msg: string, status = 400) => NextResponse.json({ error: msg }, { status }); diff --git a/lib/apikeys.ts b/lib/apikeys.ts new file mode 100644 index 0000000..6e1766a --- /dev/null +++ b/lib/apikeys.ts @@ -0,0 +1,138 @@ +// API keys: letting something other than a browser act for an account. +// +// Every account-scoped endpoint here authenticated by session cookie alone, +// which meant no CLI, script, or CI job could call one. For key pins that was +// not an inconvenience but a correctness problem — a Moshpit name's TLS is +// unverifiable until its pin is published, and publishing meant a person +// reading a base64 hash out of a script and pasting it into a form. Steps like +// that do not happen at scale, so most names simply had no pin. +// +// Only the hash of a key is stored. A leaked backup of the table cannot be +// used to authenticate, and nobody — including whoever runs the registry — can +// read a key back after it is created. That is why creation returns the token +// exactly once and the UI has to say so. + +import crypto from "node:crypto"; +import { db, ensureSchema } from "./db"; + +/** Recognisable in a log or an env var, and greppable in a leak scan. */ +const PREFIX = "mpk_"; +/** Kept in clear so a person can tell two keys apart when revoking one. */ +const PREFIX_KEEP = PREFIX.length + 6; + +export type ApiKeyRow = { + id: string; + name: string | null; + prefix: string; + created_at: string; + last_used: string | null; + revoked_at: string | null; +}; + +/** + * Hash a presented token. + * + * Plain SHA-256 rather than a password KDF, deliberately. A password is short, + * low-entropy and guessable, so it needs a slow hash. This token is 32 random + * bytes from a CSPRNG — brute force is not a threat model that arithmetic + * supports — and it is verified on every API request, where a slow hash would + * be a denial-of-service surface pointed at ourselves. + */ +function hash(token: string): string { + return crypto.createHash("sha256").update(token, "utf8").digest("hex"); +} + +/** 32 bytes of CSPRNG, base64url so it survives env vars, headers and shells. */ +export function generateToken(): string { + return PREFIX + crypto.randomBytes(32).toString("base64url"); +} + +/** Pull the bearer token out of a request, if it carries one. */ +export function bearerToken(header: string | null | undefined): string | null { + const value = String(header ?? "").trim(); + if (!value) return null; + const match = /^Bearer\s+(.+)$/i.exec(value); + const token = (match ? match[1] : value).trim(); + return token.startsWith(PREFIX) ? token : null; +} + +/** + * Mint a key. The token is returned once and never again. + * + * Session-authenticated callers only — a key must not be able to mint another + * key, or a single leak becomes permanent access that survives revoking the + * key that leaked. + */ +export async function createApiKey(accountId: string, name?: string | null): Promise<{ token: string; row: ApiKeyRow }> { + await ensureSchema(); + const token = generateToken(); + const clean = typeof name === "string" && name.trim() ? name.trim().slice(0, 80) : null; + const prefix = token.slice(0, PREFIX_KEEP); + + const created = await db().execute({ + sql: `INSERT INTO account_api_keys (account_id, name, token_hash, prefix) + VALUES (?,?,?,?) + RETURNING id, name, prefix, created_at, last_used, revoked_at`, + args: [accountId, clean, hash(token), prefix], + }); + + return { token, row: created.rows[0] as unknown as ApiKeyRow }; +} + +/** + * The account a token belongs to, or null. + * + * Looked up by hash, so the comparison happens inside the index rather than in + * our code — there is no string compare here to leak timing, and no way to + * probe for a valid token by measuring the response. + * + * A revoked key resolves to null immediately: revocation has to take effect on + * the next request, not on the next deploy or cache expiry. + */ +export async function accountIdForToken(token: string | null | undefined): Promise { + if (!token || !token.startsWith(PREFIX)) return null; + await ensureSchema(); + + const found = await db().execute({ + sql: `SELECT id, account_id FROM account_api_keys WHERE token_hash = ? AND revoked_at IS NULL`, + args: [hash(token)], + }); + const row = found.rows[0] as unknown as { id: string; account_id: string } | undefined; + if (!row) return null; + + // Best-effort: a failed timestamp update must not fail the request it was + // recording. The write is only there so an unused key can be spotted later. + db() + .execute({ sql: `UPDATE account_api_keys SET last_used = datetime('now') WHERE id = ?`, args: [row.id] }) + .catch(() => {}); + + return row.account_id; +} + +/** Keys for an account. Never includes the token — it does not exist here. */ +export async function listApiKeys(accountId: string): Promise { + await ensureSchema(); + const rows = await db().execute({ + sql: `SELECT id, name, prefix, created_at, last_used, revoked_at + FROM account_api_keys WHERE account_id = ? ORDER BY created_at DESC`, + args: [accountId], + }); + return rows.rows as unknown as ApiKeyRow[]; +} + +/** + * Revoke a key. + * + * Scoped to the account in the statement itself rather than checked + * beforehand, so there is no window between the check and the write, and no + * way to revoke somebody else's key by guessing an id. + */ +export async function revokeApiKey(accountId: string, id: string): Promise { + await ensureSchema(); + const done = await db().execute({ + sql: `UPDATE account_api_keys SET revoked_at = datetime('now') + WHERE id = ? AND account_id = ? AND revoked_at IS NULL`, + args: [id, accountId], + }); + return (done.rowsAffected ?? 0) > 0; +} diff --git a/lib/db.ts b/lib/db.ts index 9116edb..76ccee5 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -312,6 +312,38 @@ async function initSchema(): Promise { ) `); + // ---- API keys, so something other than a browser can act for an account. + // + // Every account-scoped endpoint authenticated by session cookie only, which + // meant no CLI, script, or CI job could call one. For key pins that was not + // an inconvenience but a correctness problem: a name's TLS is unverifiable + // until its pin is published, and publishing was a person reading a hash out + // of a script and pasting it into a form. Steps like that do not happen, so + // most names had no pin. + // + // Only the hash is stored. A leaked backup of this table cannot be used to + // authenticate, and nobody — including whoever runs the registry — can read + // a key back out after it is created. + // + // `prefix` is the first few characters, kept in clear on purpose: it is what + // lets a person recognise which key a row refers to when revoking one, + // without it being enough to use. + await d.execute(` + CREATE TABLE IF NOT EXISTS account_api_keys ( + id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + account_id TEXT NOT NULL, + name TEXT, + token_hash TEXT NOT NULL UNIQUE, + prefix TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_used TEXT, + revoked_at TEXT + ) + `); + // Lookup is by hash on every authenticated request, so it must not be a scan. + await d.execute(`CREATE INDEX IF NOT EXISTS idx_account_api_keys_hash ON account_api_keys (token_hash)`); + await d.execute(`CREATE INDEX IF NOT EXISTS idx_account_api_keys_acct ON account_api_keys (account_id)`); + // ---- domain auctions: one per domain, runs FOREVER (no expiry) — the owner // collects bids until they accept one. Owner sets an optional reserve (hidden // from bidders) and buy-now (a bid >= buy_now auto-wins). Managed on /dashboard. diff --git a/tests/apikeys.test.mjs b/tests/apikeys.test.mjs new file mode 100644 index 0000000..2afd07a --- /dev/null +++ b/tests/apikeys.test.mjs @@ -0,0 +1,62 @@ +// API keys — the properties that make them safe to hand to a script. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import crypto from "node:crypto"; + +import { generateToken, bearerToken } from "../lib/apikeys.ts"; + +test("a token is 32 bytes of randomness behind a recognisable prefix", () => { + const token = generateToken(); + assert.match(token, /^mpk_/, "greppable in a leak scan, and obvious in a log"); + + const body = token.slice("mpk_".length); + assert.equal(Buffer.from(body, "base64url").length, 32, "32 bytes from a CSPRNG"); + // base64url, so it survives env vars, shells and headers without quoting. + assert.match(body, /^[A-Za-z0-9_-]+$/); +}); + +test("tokens do not repeat", () => { + const seen = new Set(Array.from({ length: 200 }, () => generateToken())); + assert.equal(seen.size, 200); +}); + +test("the bearer header is parsed, and anything else is not a token", () => { + const token = generateToken(); + assert.equal(bearerToken(`Bearer ${token}`), token); + assert.equal(bearerToken(`bearer ${token}`), token, "the scheme is case-insensitive per RFC 7235"); + assert.equal(bearerToken(token), token, "a bare token is accepted too"); + + // A session cookie, a Basic credential or an unrelated header must not be + // mistaken for a key — they would be hashed and looked up, and a miss is + // indistinguishable from a revoked key in the logs. + assert.equal(bearerToken("Basic dXNlcjpwYXNz"), null); + assert.equal(bearerToken("Bearer eyJhbGciOiJIUzI1NiJ9.abc.def"), null, "a JWT is not one of ours"); + assert.equal(bearerToken(""), null); + assert.equal(bearerToken(null), null); + assert.equal(bearerToken(undefined), null); +}); + +test("the stored hash cannot be turned back into a token", () => { + // The property that matters if the table leaks: SHA-256 over 32 random bytes + // has no shortcut, so a dump is not a set of usable credentials. + const token = generateToken(); + const stored = crypto.createHash("sha256").update(token, "utf8").digest("hex"); + + assert.equal(stored.length, 64); + assert.ok(!stored.includes(token.slice(4, 20)), "no part of the token survives in the hash"); + assert.equal( + crypto.createHash("sha256").update(token, "utf8").digest("hex"), + stored, + "the same token always hashes the same, which is what makes lookup by hash work", + ); +}); + +test("the retained prefix identifies a key without being enough to use one", () => { + const token = generateToken(); + const prefix = token.slice(0, "mpk_".length + 6); + + assert.ok(token.startsWith(prefix)); + // Six characters is enough to tell two keys apart in a list and far too few + // to guess the remaining 32 bytes. + assert.ok(prefix.length < token.length / 4); +});