|
| 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 | +} |
0 commit comments