Skip to content

feat(api): API keys, so a pin can be published without a browser - #101

Merged
ralyodio merged 1 commit into
masterfrom
feat/api-keys
Aug 3, 2026
Merged

feat(api): API keys, so a pin can be published without a browser#101
ralyodio merged 1 commit into
masterfrom
feat/api-keys

Conversation

@ralyodio

@ralyodio ralyodio commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

The server half of moshcode#244. Without this, nothing but a browser can publish a key pin.

Why

Every account-scoped endpoint authenticates by session cookie alone:

export async function resolveAccountId(req) {
  const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);   // that's the whole thing

So no CLI, script, or CI job can call one. For key pins that is not an inconvenience, it is a correctness problem: a Moshpit name's TLS is unverifiable until its pin is published, and publishing meant running setup-origin.sh, reading a base64 hash out of its output, and pasting that into a web form.

Three steps where the interesting one is invisible. The predictable result is that most names never get a pin, and unverifiable HTTPS looks exactly like protected HTTPS to the person who set it up.

What this adds

account_api_keys, a bearer path, and /api/account/keys to mint and revoke.

The bearer path is a separate resolver, resolveAccountIdOrToken, opted into by the two pin routes — not folded into resolveAccountId. Folding it in would have widened every account-scoped endpoint in the codebase at once, which is a much larger change than the diff would appear to be. This way the routes that accept keys are visible in the diff.

Ownership is unaffected: addPin already refuses an ending the account does not own, so a token can do no more than the account behind it could.

Security choices worth arguing with

  • Only the hash is stored. A leaked backup of the table is not a set of usable credentials, and nobody — including whoever runs the registry — can read a key back after creation. Creation returns the token exactly once and the response says so.
  • Plain SHA-256, not bcrypt/argon2. A password is short and guessable and needs a slow hash. This is 32 bytes from a CSPRNG, where brute force is not a threat the arithmetic supports, and it is verified on every API request — a deliberately slow hash there is a denial-of-service surface pointed at ourselves.
  • Lookup is by hash, so the comparison happens inside the index. No string compare in our code to leak timing, and no way to probe for a valid token by measuring responses.
  • Revocation is in the query (AND revoked_at IS NULL), so it takes effect on the next request rather than the next deploy.
  • Revoke is scoped inside the UPDATE (WHERE id = ? AND account_id = ?), so there is no check-then-write window, and a wrong id is indistinguishable from someone else's id — neither reveals whether that key exists.
  • Keys cannot mint keys. /api/account/keys is session-only. Otherwise one leak becomes permanent access that outlives revoking the key that leaked, and the revoke button stops meaning anything.
  • mpk_ prefix, so a leaked key is greppable in a scan and obvious in a log; the first six characters are kept in clear so a person can tell two keys apart when revoking one, which is far too few to guess the remaining 32 bytes.

Tests

5 tests on the properties that make a key safe to hand to a script: the token is 32 CSPRNG bytes, tokens do not repeat, a JWT or Basic credential is not mistaken for one of ours, no part of a token survives in its hash, and the retained prefix identifies without being usable.

147 pass, 0 fail. Typecheck clean.

Next

moshcode site --install can now do key → cert → pin → publish in the one command people already run, with no hash ever visible to a person. That is the last piece of moshcode#244.

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>
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown

vu1nz Security Review

0 finding(s) in PR #?

No security issues found.

@ralyodio
ralyodio merged commit 83f3ec2 into master Aug 3, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant