Skip to content

Security: moinsen-dev/challenges-api

SECURITY.md

Security

Reporting a vulnerability

Email business@moinsen.dev with "security" in the subject. Please include what you did, what happened, and what you expected. We will confirm receipt within 72 hours.

Do not open a public issue for anything that lets someone read other people's data, forge results, or escalate to admin.

What is worth reporting

This service exists to make competitive results believable. Anything that breaks that is in scope:

  • forging or altering an entry, match, title or badge for another player
  • reading another player's ledger, region, or session
  • getting authority from a public key (chapi_pk_) that requires a secret key (chapi_sk_)
  • bypassing the admin key
  • an injection through any field that reaches SQL
  • a way to flood the geographic ladder with accounts

Out of scope: rate limits on public read endpoints, missing security headers on the static site, and reports generated by scanners without a working example.

How keys work

An API key is an object with a life, not a column on your app.

  • Two kinds. chapi_pk_… may live in a client. chapi_sk_… never may; it creates disciplines, reports duels and grants collectibles.
  • Shown once. Only the SHA-256 hash and the prefix are stored. Nobody — including us — can read a key back out of the database.
  • Rotation without a gap. Mint a new key, deploy it, then revoke the old one. Both are valid in between, which is the only kind of rotation anyone actually performs.
  • Refusal to strand you. Revoking the last live key of a kind is rejected. That is the difference between rotating a key and taking a game offline.
  • Last used. Every key records when it was last seen, so an old key can be retired on evidence instead of on hope.
  • Expiry. A key can be minted with an expiry date, for a contractor or a jam.
  • Two-factor. Creating a secret key requires two-factor authentication at your sign-in provider. Public keys stay available regardless, so nobody is locked out of their own app.

If a key leaks

  1. Mint a replacement of the same kind in the console.
  2. Deploy it.
  3. Revoke the leaked key with a reason — it stops working immediately and the revocation is recorded in your audit log.

A revoked key answers 401 with app key revoked and the timestamp, rather than a silent failure that costs somebody an hour.

Players: passkeys, and a rescue route that stores nothing extra

An account still needs no personal data. A passkey is a public key and an opaque credential id — nothing about a person — so it is the recovery route that fits the rest of this service.

What is verified on every assertion, because leaving any of it out turns the whole thing into theatre:

  • the ceremony type, so a registration response cannot be replayed as a login
  • the challenge, issued by us, single use, five minutes
  • the origin, so a page on another domain cannot drive the ceremony
  • the RP id hash inside the authenticator data
  • the user-presence flag
  • the signature itself, over exactly the bytes the spec names
  • the signature counter, when the authenticator keeps one — a counter that goes backwards means the credential was cloned

Attestation is deliberately not verified. We do not care which authenticator was used, only that the same one comes back; an attacker registering a key they control to an account they are already signed in to has achieved nothing.

Passkeys are bound to one domain. A game on its own domain sends the player to the platform domain to add or use one — the same hop an OAuth login takes.

Email is a strictly optional rescue route. It is the only place a player address is ever stored, an address that was never confirmed can never recover anything, and one address rescues at most one account. Recovering ends every other session, so a lost phone stops being a way in the moment you get back.

Players can list their sessions and end all the others from the device still in their hand.

Who may sign in at all

An instance can be restricted with DEV_ALLOWLIST, a comma-separated list:

@moinsen.dev        every verified address at that domain
uli@example.com     one exact address
github:octocat      one GitHub login, regardless of address

Empty or missing means the instance is open, which is what a self-hoster wants.

Two details that decide whether this is real:

  • Only a verified address counts. GitHub's /user also carries an email, but that is whatever the person typed into their public profile — changeable to anything. We read /user/emails and accept only addresses GitHub marks as verified. Basing access on the profile field would let somebody claim a domain they do not own.
  • Checked on every sign-in, not only at first contact. Removing somebody from the list locks them out, including an account that already exists.

A rejected sign-in is recorded in the audit log. On the email path there is no answer at all to learn from: an address outside the list gets the same 202 as one inside, and simply receives no mail.

Two ways in, neither of them a password

GitHub for developers who have one, an emailed link or six-digit code for everyone else. There is no password to store, reset, or leak.

What the email path does about the usual magic-link mistakes:

  • The response is identical whether the address has an account or not, so the endpoint cannot be used to ask who is registered here.
  • Link and code expire in 15 minutes and work once. Using one invalidates every outstanding link for that address, so a forwarded older mail is dead.
  • The six-digit code dies after five wrong guesses — six digits are only a million possibilities, and without that limit they are decoration.
  • At most five links an hour per address, and the caller is not told when the limit bites.
  • Nothing secret ever appears in an API response; the token only exists in the mail.

No automatic linking between providers. A GitHub account and an email account with the same address stay separate, because a provider-supplied address is not proof that somebody owns that mailbox — linking on it would be an account takeover waiting to happen.

Minting a secret key asks for a second, recent proof

  • GitHub account: two-factor at the provider.
  • Email account: a sign-in from the last 15 minutes, which means somebody just proved they hold the mailbox.

Public keys stay available either way, so nobody is ever locked out of their own app.

Sign-in is not on the request path

Developer sign-in authenticates the console. It never authenticates an API request. If GitHub is down, or our console is down, every game keeps working, because a game presents a key and nothing else.

Console sessions are HttpOnly, Secure, SameSite=Lax cookies; only their hash is stored; they expire after 30 days, can be listed, and every other session can be ended from the one you are holding.

The one secret we hold in recoverable form

Every key and token here is stored as a hash — which means none of them can sign anything. Join tickets and webhook deliveries have to be signed, so each app carries one signing secret that we can actually use.

It is fetched with the app's secret key (GET /v1/signing-secret), it verifies join tickets offline, and it is the only credential in this service stored in a form we could read. Webhook endpoints get their own separate signing secret, shown once at creation.

Both signatures are HMAC-SHA256 and both put a timestamp inside the signed payload, so a captured delivery or ticket cannot be replayed later.

Running somebody else's code

Replay verification executes a developer's WebAssembly module. Three things make that safe enough to do:

  • A module that imports anything is refused at upload. No imports means no clock, no randomness, no syscalls — it can only compute. That is also what makes re-simulation meaningful.
  • It never runs in the Worker. Cloudflare refuses to compile WebAssembly at runtime, and we do not work around that. The verifier is a separate process.
  • It runs in a worker thread with a hard wall-clock limit and constrained heap. A thread can be killed mid-instruction; anything gentler depends on the code under test cooperating, and the code under test is exactly what we do not trust.

A verifier holds the operator credential, not an app's, because it decides what counts. If you run your own, treat that credential accordingly.

What we already do

Both app keys and every player token are stored as SHA-256 hashes only; a database dump grants no access. Public and secret keys are separate by design. Entries above a discipline's plausibility limit are held for review rather than counted. Retention limits are enforced in code. See legal/TOM.md.

What we do not do yet

No field-level encryption beyond what the platform provides at rest, no four-eyes principle on operator actions, no external audit. The service is operated by one person; that is stated openly rather than dressed up.

There aren't any published security advisories