Skip to content

Commit 5548640

Browse files
ralyodioclaude
andcommitted
feat(api): API keys, so a pin can be published without a browser
Every account-scoped endpoint authenticated by session cookie alone: const s = readSession(req.cookies.get(SESSION_COOKIE)?.value); So no CLI, script or CI job could call one. For key pins that is not an inconvenience but a correctness problem. A Moshpit name's TLS is unverifiable until its pin is published, and publishing meant running a script, reading a base64 hash out of its output, and pasting that into a web form. Three steps where the interesting one is invisible, so the honest outcome is that most names never get a pin at all. Adds `account_api_keys` and a bearer path, opted into by the two pin routes rather than folded into `resolveAccountId` — a key path that silently widened every account endpoint at once would be a much larger change than the diff makes it look. Ownership is unaffected: `addPin` already refuses an ending the account does not own, so a token can do no more than its account could. Security choices worth the review: - only the hash is stored, so a leaked backup is not a set of credentials - plain SHA-256, not a password KDF: this is 32 CSPRNG bytes rather than a guessable secret, and it is verified on every request, where a slow hash would be a self-inflicted DoS - lookup is by hash, so the compare happens in the index — no string comparison to leak timing, no probing for a valid token - revocation is checked in the query, effective on the next request - revoke is scoped to the account inside the UPDATE, so there is no check-then-write window and a wrong id is indistinguishable from someone else's - keys cannot mint keys: that route is session-only, or one leak becomes access that outlives revoking the key that leaked Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent b5f6bed commit 5548640

6 files changed

Lines changed: 308 additions & 3 deletions

File tree

app/api/account/keys/route.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// API keys for the signed-in account.
2+
//
3+
// Session-authenticated only, deliberately: a key must not be able to mint
4+
// another key. Otherwise one leak becomes permanent access that outlives
5+
// revoking the key that leaked, and the revoke button stops meaning anything.
6+
7+
import { NextRequest, NextResponse } from "next/server";
8+
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
9+
import { createApiKey, listApiKeys, revokeApiKey } from "@/lib/apikeys";
10+
11+
export const runtime = "nodejs";
12+
export const dynamic = "force-dynamic";
13+
14+
/** GET /api/account/keys — the account's keys. Never the tokens; they are not stored. */
15+
export async function GET(req: NextRequest) {
16+
const accountId = await resolveAccountId(req);
17+
if (!accountId) return unauthorized();
18+
return NextResponse.json({ keys: await listApiKeys(accountId) });
19+
}
20+
21+
/**
22+
* POST /api/account/keys { name? } — mint one.
23+
*
24+
* The token comes back exactly once. Only its hash is stored, so it cannot be
25+
* shown again by us or by anyone who reaches the database — the response says
26+
* so, because a UI that does not will produce a support ticket instead of a
27+
* saved credential.
28+
*/
29+
export async function POST(req: NextRequest) {
30+
const accountId = await resolveAccountId(req);
31+
if (!accountId) return unauthorized();
32+
33+
const body = await req.json().catch(() => ({}));
34+
const { token, row } = await createApiKey(accountId, body?.name);
35+
36+
return NextResponse.json(
37+
{ key: row, token, note: "Copy this now — it is stored only as a hash and cannot be shown again." },
38+
{ status: 201 },
39+
);
40+
}
41+
42+
/** DELETE /api/account/keys?id=... — revoke, effective on the next request. */
43+
export async function DELETE(req: NextRequest) {
44+
const accountId = await resolveAccountId(req);
45+
if (!accountId) return unauthorized();
46+
47+
const id = req.nextUrl.searchParams.get("id") ?? "";
48+
if (!id) return bad("id is required");
49+
50+
// Scoped to the account inside the UPDATE, so a wrong id is indistinguishable
51+
// from someone else's id: neither reveals whether that key exists.
52+
return (await revokeApiKey(accountId, id))
53+
? NextResponse.json({ id, revoked: true })
54+
: bad("no such key", 404);
55+
}

app/api/moshpit/tlds/[tld]/pins/route.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { NextRequest, NextResponse } from "next/server";
2-
import { resolveAccountId, bad, unauthorized } from "@/lib/api";
2+
import { resolveAccountIdOrToken, bad, unauthorized } from "@/lib/api";
33
import { PIN_KINDS, addPin, listPins, normalizePinKind, removePin } from "@/lib/moshpit";
44

55
export const runtime = "nodejs";
@@ -23,7 +23,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ tld: string
2323
* would break every client between the write and the deploy.
2424
*/
2525
export async function POST(req: NextRequest, ctx: { params: Promise<{ tld: string }> }) {
26-
const accountId = await resolveAccountId(req);
26+
const accountId = await resolveAccountIdOrToken(req);
2727
if (!accountId) return unauthorized();
2828
const { tld } = await ctx.params;
2929

@@ -44,7 +44,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ tld: strin
4444

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

lib/api.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
22
import { sessionUser, getOrCreateUser } from "./authz";
33
import { readSession, authConfigured, SESSION_COOKIE } from "./session";
44
import { findOrCreateAccountByEmail } from "./db";
5+
import { accountIdForToken, bearerToken } from "./apikeys";
56

67
/** Resolve the authenticated user (provisioning a default org/team on first hit). */
78
export async function requireUser(req: NextRequest) {
@@ -25,5 +26,22 @@ export async function resolveAccountId(req: NextRequest): Promise<string | null>
2526
return null;
2627
}
2728

29+
/**
30+
* The account for a request, accepting an API key as well as a session.
31+
*
32+
* Separate from `resolveAccountId` rather than folded into it, so that adding
33+
* a key path does not silently widen every account-scoped endpoint at once.
34+
* A route opts in by calling this one, and the diff shows which routes did.
35+
*
36+
* Session first: a browser request carries both a cookie and, sometimes, an
37+
* unrelated Authorization header, and the cookie is the stronger statement of
38+
* who is driving.
39+
*/
40+
export async function resolveAccountIdOrToken(req: NextRequest): Promise<string | null> {
41+
const session = await resolveAccountId(req);
42+
if (session) return session;
43+
return accountIdForToken(bearerToken(req.headers.get("authorization")));
44+
}
45+
2846
export const unauthorized = () => NextResponse.json({ error: "Sign in first." }, { status: 401 });
2947
export const bad = (msg: string, status = 400) => NextResponse.json({ error: msg }, { status });

lib/apikeys.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// API keys: letting something other than a browser act for an account.
2+
//
3+
// Every account-scoped endpoint here authenticated by session cookie alone,
4+
// which meant no CLI, script, or CI job could call one. For key pins that was
5+
// not an inconvenience but a correctness problem — a Moshpit name's TLS is
6+
// unverifiable until its pin is published, and publishing meant a person
7+
// reading a base64 hash out of a script and pasting it into a form. Steps like
8+
// that do not happen at scale, so most names simply had no pin.
9+
//
10+
// Only the hash of a key is stored. A leaked backup of the table cannot be
11+
// used to authenticate, and nobody — including whoever runs the registry — can
12+
// read a key back after it is created. That is why creation returns the token
13+
// exactly once and the UI has to say so.
14+
15+
import crypto from "node:crypto";
16+
import { db, ensureSchema } from "./db";
17+
18+
/** Recognisable in a log or an env var, and greppable in a leak scan. */
19+
const PREFIX = "mpk_";
20+
/** Kept in clear so a person can tell two keys apart when revoking one. */
21+
const PREFIX_KEEP = PREFIX.length + 6;
22+
23+
export type ApiKeyRow = {
24+
id: string;
25+
name: string | null;
26+
prefix: string;
27+
created_at: string;
28+
last_used: string | null;
29+
revoked_at: string | null;
30+
};
31+
32+
/**
33+
* Hash a presented token.
34+
*
35+
* Plain SHA-256 rather than a password KDF, deliberately. A password is short,
36+
* low-entropy and guessable, so it needs a slow hash. This token is 32 random
37+
* bytes from a CSPRNG — brute force is not a threat model that arithmetic
38+
* supports — and it is verified on every API request, where a slow hash would
39+
* be a denial-of-service surface pointed at ourselves.
40+
*/
41+
function hash(token: string): string {
42+
return crypto.createHash("sha256").update(token, "utf8").digest("hex");
43+
}
44+
45+
/** 32 bytes of CSPRNG, base64url so it survives env vars, headers and shells. */
46+
export function generateToken(): string {
47+
return PREFIX + crypto.randomBytes(32).toString("base64url");
48+
}
49+
50+
/** Pull the bearer token out of a request, if it carries one. */
51+
export function bearerToken(header: string | null | undefined): string | null {
52+
const value = String(header ?? "").trim();
53+
if (!value) return null;
54+
const match = /^Bearer\s+(.+)$/i.exec(value);
55+
const token = (match ? match[1] : value).trim();
56+
return token.startsWith(PREFIX) ? token : null;
57+
}
58+
59+
/**
60+
* Mint a key. The token is returned once and never again.
61+
*
62+
* Session-authenticated callers only — a key must not be able to mint another
63+
* key, or a single leak becomes permanent access that survives revoking the
64+
* key that leaked.
65+
*/
66+
export async function createApiKey(accountId: string, name?: string | null): Promise<{ token: string; row: ApiKeyRow }> {
67+
await ensureSchema();
68+
const token = generateToken();
69+
const clean = typeof name === "string" && name.trim() ? name.trim().slice(0, 80) : null;
70+
const prefix = token.slice(0, PREFIX_KEEP);
71+
72+
const created = await db().execute({
73+
sql: `INSERT INTO account_api_keys (account_id, name, token_hash, prefix)
74+
VALUES (?,?,?,?)
75+
RETURNING id, name, prefix, created_at, last_used, revoked_at`,
76+
args: [accountId, clean, hash(token), prefix],
77+
});
78+
79+
return { token, row: created.rows[0] as unknown as ApiKeyRow };
80+
}
81+
82+
/**
83+
* The account a token belongs to, or null.
84+
*
85+
* Looked up by hash, so the comparison happens inside the index rather than in
86+
* our code — there is no string compare here to leak timing, and no way to
87+
* probe for a valid token by measuring the response.
88+
*
89+
* A revoked key resolves to null immediately: revocation has to take effect on
90+
* the next request, not on the next deploy or cache expiry.
91+
*/
92+
export async function accountIdForToken(token: string | null | undefined): Promise<string | null> {
93+
if (!token || !token.startsWith(PREFIX)) return null;
94+
await ensureSchema();
95+
96+
const found = await db().execute({
97+
sql: `SELECT id, account_id FROM account_api_keys WHERE token_hash = ? AND revoked_at IS NULL`,
98+
args: [hash(token)],
99+
});
100+
const row = found.rows[0] as unknown as { id: string; account_id: string } | undefined;
101+
if (!row) return null;
102+
103+
// Best-effort: a failed timestamp update must not fail the request it was
104+
// recording. The write is only there so an unused key can be spotted later.
105+
db()
106+
.execute({ sql: `UPDATE account_api_keys SET last_used = datetime('now') WHERE id = ?`, args: [row.id] })
107+
.catch(() => {});
108+
109+
return row.account_id;
110+
}
111+
112+
/** Keys for an account. Never includes the token — it does not exist here. */
113+
export async function listApiKeys(accountId: string): Promise<ApiKeyRow[]> {
114+
await ensureSchema();
115+
const rows = await db().execute({
116+
sql: `SELECT id, name, prefix, created_at, last_used, revoked_at
117+
FROM account_api_keys WHERE account_id = ? ORDER BY created_at DESC`,
118+
args: [accountId],
119+
});
120+
return rows.rows as unknown as ApiKeyRow[];
121+
}
122+
123+
/**
124+
* Revoke a key.
125+
*
126+
* Scoped to the account in the statement itself rather than checked
127+
* beforehand, so there is no window between the check and the write, and no
128+
* way to revoke somebody else's key by guessing an id.
129+
*/
130+
export async function revokeApiKey(accountId: string, id: string): Promise<boolean> {
131+
await ensureSchema();
132+
const done = await db().execute({
133+
sql: `UPDATE account_api_keys SET revoked_at = datetime('now')
134+
WHERE id = ? AND account_id = ? AND revoked_at IS NULL`,
135+
args: [id, accountId],
136+
});
137+
return (done.rowsAffected ?? 0) > 0;
138+
}

lib/db.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,38 @@ async function initSchema(): Promise<void> {
312312
)
313313
`);
314314

315+
// ---- API keys, so something other than a browser can act for an account.
316+
//
317+
// Every account-scoped endpoint authenticated by session cookie only, which
318+
// meant no CLI, script, or CI job could call one. For key pins that was not
319+
// an inconvenience but a correctness problem: a name's TLS is unverifiable
320+
// until its pin is published, and publishing was a person reading a hash out
321+
// of a script and pasting it into a form. Steps like that do not happen, so
322+
// most names had no pin.
323+
//
324+
// Only the hash is stored. A leaked backup of this table cannot be used to
325+
// authenticate, and nobody — including whoever runs the registry — can read
326+
// a key back out after it is created.
327+
//
328+
// `prefix` is the first few characters, kept in clear on purpose: it is what
329+
// lets a person recognise which key a row refers to when revoking one,
330+
// without it being enough to use.
331+
await d.execute(`
332+
CREATE TABLE IF NOT EXISTS account_api_keys (
333+
id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
334+
account_id TEXT NOT NULL,
335+
name TEXT,
336+
token_hash TEXT NOT NULL UNIQUE,
337+
prefix TEXT NOT NULL,
338+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
339+
last_used TEXT,
340+
revoked_at TEXT
341+
)
342+
`);
343+
// Lookup is by hash on every authenticated request, so it must not be a scan.
344+
await d.execute(`CREATE INDEX IF NOT EXISTS idx_account_api_keys_hash ON account_api_keys (token_hash)`);
345+
await d.execute(`CREATE INDEX IF NOT EXISTS idx_account_api_keys_acct ON account_api_keys (account_id)`);
346+
315347
// ---- domain auctions: one per domain, runs FOREVER (no expiry) — the owner
316348
// collects bids until they accept one. Owner sets an optional reserve (hidden
317349
// from bidders) and buy-now (a bid >= buy_now auto-wins). Managed on /dashboard.

tests/apikeys.test.mjs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// API keys — the properties that make them safe to hand to a script.
2+
import { test } from "node:test";
3+
import assert from "node:assert/strict";
4+
import crypto from "node:crypto";
5+
6+
import { generateToken, bearerToken } from "../lib/apikeys.ts";
7+
8+
test("a token is 32 bytes of randomness behind a recognisable prefix", () => {
9+
const token = generateToken();
10+
assert.match(token, /^mpk_/, "greppable in a leak scan, and obvious in a log");
11+
12+
const body = token.slice("mpk_".length);
13+
assert.equal(Buffer.from(body, "base64url").length, 32, "32 bytes from a CSPRNG");
14+
// base64url, so it survives env vars, shells and headers without quoting.
15+
assert.match(body, /^[A-Za-z0-9_-]+$/);
16+
});
17+
18+
test("tokens do not repeat", () => {
19+
const seen = new Set(Array.from({ length: 200 }, () => generateToken()));
20+
assert.equal(seen.size, 200);
21+
});
22+
23+
test("the bearer header is parsed, and anything else is not a token", () => {
24+
const token = generateToken();
25+
assert.equal(bearerToken(`Bearer ${token}`), token);
26+
assert.equal(bearerToken(`bearer ${token}`), token, "the scheme is case-insensitive per RFC 7235");
27+
assert.equal(bearerToken(token), token, "a bare token is accepted too");
28+
29+
// A session cookie, a Basic credential or an unrelated header must not be
30+
// mistaken for a key — they would be hashed and looked up, and a miss is
31+
// indistinguishable from a revoked key in the logs.
32+
assert.equal(bearerToken("Basic dXNlcjpwYXNz"), null);
33+
assert.equal(bearerToken("Bearer eyJhbGciOiJIUzI1NiJ9.abc.def"), null, "a JWT is not one of ours");
34+
assert.equal(bearerToken(""), null);
35+
assert.equal(bearerToken(null), null);
36+
assert.equal(bearerToken(undefined), null);
37+
});
38+
39+
test("the stored hash cannot be turned back into a token", () => {
40+
// The property that matters if the table leaks: SHA-256 over 32 random bytes
41+
// has no shortcut, so a dump is not a set of usable credentials.
42+
const token = generateToken();
43+
const stored = crypto.createHash("sha256").update(token, "utf8").digest("hex");
44+
45+
assert.equal(stored.length, 64);
46+
assert.ok(!stored.includes(token.slice(4, 20)), "no part of the token survives in the hash");
47+
assert.equal(
48+
crypto.createHash("sha256").update(token, "utf8").digest("hex"),
49+
stored,
50+
"the same token always hashes the same, which is what makes lookup by hash work",
51+
);
52+
});
53+
54+
test("the retained prefix identifies a key without being enough to use one", () => {
55+
const token = generateToken();
56+
const prefix = token.slice(0, "mpk_".length + 6);
57+
58+
assert.ok(token.startsWith(prefix));
59+
// Six characters is enough to tell two keys apart in a list and far too few
60+
// to guess the remaining 32 bytes.
61+
assert.ok(prefix.length < token.length / 4);
62+
});

0 commit comments

Comments
 (0)