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/account/keys/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 3 additions & 3 deletions app/api/moshpit/tlds/[tld]/pins/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;

Expand All @@ -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;

Expand Down
18 changes: 18 additions & 0 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -25,5 +26,22 @@ export async function resolveAccountId(req: NextRequest): Promise<string | null>
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<string | null> {
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 });
138 changes: 138 additions & 0 deletions lib/apikeys.ts
Original file line number Diff line number Diff line change
@@ -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<string | null> {
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<ApiKeyRow[]> {
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<boolean> {
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;
}
32 changes: 32 additions & 0 deletions lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,38 @@ async function initSchema(): Promise<void> {
)
`);

// ---- 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.
Expand Down
62 changes: 62 additions & 0 deletions tests/apikeys.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
Loading