Skip to content

Security model

This page describes what protects players, what protects the server, and, stated as plainly as the rest of the project, what the server operator can still do. Everything here comes from the code. Where a point is a consequence of the code and not something the code comments state themselves, the page says so.

HTTP headers

Every JSON response, static file, email-link page and 429 carries (SECURITY_HEADERS in src/server/index.js):

Content-Security-Policy: default-src 'self'; script-src 'self';
  style-src 'self' 'unsafe-inline'; img-src 'self' data:;
  connect-src 'self' ws: wss:; base-uri 'none'; form-action 'none';
  frame-ancestors 'none'; object-src 'none'
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer
X-Frame-Options: DENY

Why the CSP is strict: the client is entirely self-hosted, with no CDN, no inline script and no remote font, so a strict policy costs nothing. The code comment calls it the second line of defence behind escaping. A record file is arbitrary attacker-supplied JSON that users are actively encouraged to open. In a page without a CSP, one missed escape would mean a stolen signing key.

Notes on the policy:

  • style-src 'unsafe-inline' is allowed because the skin picker sets swatch colours inline. Scripts are still 'self' only.
  • connect-src allows ws: and wss: to any host. This is what makes the ?server= override below possible.
  • referrer-policy: no-referrer stops sign-in tokens in URLs from leaking through the Referer header.

GET /records/<id>.json sends the same headers, as an application/json attachment.

No Strict-Transport-Security

Neither the app nor the committed Caddy site file sets HSTS. If you want it, enable it at Cloudflare or add a header line in Caddy.

Escaping and XSS

  • The client escapes interpolated values with esc() (& < > ") when it builds HTML (public/app.js, public/settings.js). The client uses innerHTML in over twenty places, so each new template has to keep using esc().
  • The server removes control characters from display names and cuts them to 24 characters. It does not HTML-escape them, so escaping is the client's job.
  • Email templates escape interpolated text (& < >) and attribute values ("). Every link in them is built from PUBLIC_URL plus a random hex token.
  • Records can be verified in the page by loading a file. The verifier works on parsed JSON and never evaluates it.

A guest's private key sits in localStorage

The Ed25519 key is stored as an extractable JWK (gammonchain.identity.v1), so any script running on the origin can read it. That is why the CSP and escaping matter: for a guest, an XSS bug means an identity theft that cannot be undone.

Identity and sessions

  • Proof of key. Every connection gets a fresh 16-byte challenge, and a public key is bound to the session only if the client signs that challenge. Without it, anyone could type in another player's public key and play on their rating.
  • One session per key. When a key identifies on a new connection, the old connection gets superseded, and a live match follows the newest connection.
  • Resuming a seat requires the same proven key.
  • Account sign-in tokens are 24 random bytes, single-use and valid for 30 minutes. A token is cleared the moment anyone tries to redeem it.
  • Address enumeration. register with an address that already has an account, and login-link for any address, both give a reply that is identical whether or not the account exists.
  • Mail abuse. Emails are limited to 5 per hour per key and per address, so one socket cannot use the server to send unlimited mail.
  • Account deletion needs a proven key on the current connection plus the literal confirmation delete my account. That is why it runs over the socket and not as an HTTP endpoint, which would need its own challenge and replay protection to reach the same level.

A ?server= link can point the page at another server

public/app.js connects to ?server=<url> if that query parameter is present. On such a link, the page connects to whatever server the URL names, with your stored key, and signs the challenge that server sends. The following is a consequence of the code, not something its comments discuss: a malicious server could forward the challenge from the real server and use your signature to claim your key there for that one connection. If you have an account, anything the page sends to that server (for example a register request with the private key) goes to that server too. Only open ?server= links you trust.

What the server cannot do to players

the server cannot…because
predict your next rollit does not have your next chain link until you ask to roll. See Dice fairness
steer any rollits seed is committed in a header you signed before revealing anything, and every link is pinned by your published tip
change a finished record without detectionthe dice re-derive, the plays re-validate, the hash chain recomputes, and the signatures must verify. See Verification
fabricate a match in a guest's namethe participation check needs a signature from the guest's key, which never leaves their browser
claim a guest's key on its own serverbinding a key to a session requires a signature over a fresh challenge
cache illegal analysis from a workerevery worker play is replayed against the rules engine before it enters the cache

What the server operator can do

The operator's remaining powers

  • Sign as any registered account. The account's private key is stored in the players.wrapped column as a plain JWK. Despite the column name, it is not encrypted. Anyone with the database file, or a copy of it, holds every account's key. This is the documented trade-off of email sign-in. See Accounts & email.
  • Leak the seed to a colluding player, who then knows their own upcoming rolls, though not the opponent's, and cannot change any die. This is the stated residual of the dice protocol.
  • Know the bot's rolls in advance. In a bot match the server holds the bot's chain as well as the seed.
  • Drop a live match. An aborted match produces no record and no rating change.
  • Decide clocks and forfeits. A consequence of the code: time forfeits and abandonment forfeits are server-driven forfeit events (p: null) that the server signs. The record does not prove when anything happened, and the verifier checks that players signed some event, not the final one. So a record that ends with a forfeit verifies whether or not the flag really fell.
  • Withhold records. Records are stored and served by the server. There is no public Merkle root over them yet (the README lists it as not built). Nothing stops an operator from never publishing a match.
  • Change ratings. Ratings live in SQLite and are not part of any signed record.
  • Read personal data it holds: email addresses and subscription state. TLS ends at the operator's proxy.

Server-side protections

  • Secrets stay out of git and out of images. .gitignore and .dockerignore exclude server-key.json, *.pkcs8/*.pem/*.p8, *.db* and .env* (with .env.example allowed in git). server-key.json is written with mode 0600. If either file ever reaches a remote, .gitignore says to rotate the signing key, invalidate outstanding sign-in tokens and treat the accounts as compromised. Old records stay verifiable against the old public key, which is why that key is published.

  • Deploy never overwrites secrets. push.sh does not copy any .env (at any depth), server-key.json, *.db or records/, and its --delete cannot remove them — nor anything under projects/ or deploy/sites/*.caddy.

  • Worker writes need a key. With GAMMONET_KEY unset, the worker endpoints return 503, so a forgotten key fails closed.

  • Unauthenticated CPU is bounded. Only today's puzzle is built on demand. Analysis is rate-limited and computed once per match. Request bodies are capped at 1 MB and WebSocket messages at 64 KB.

  • Actions are validated before the rules engine. validateAction() rejects malformed hop lists, dice and levels. Client signatures must be well-formed and refer to an event that exists.

  • Errors do not leak. An HTTP route that throws returns only internal error. Reports have emails and long hex strings scrubbed out.

  • Network. The app container publishes no port. Only Caddy is reachable, and a DOCKER-USER default-drop rule plus an external cloud firewall bound what can be opened. See Firewall.

  • Secrets stay out of the image. .dockerignore patterns match only at the root of the build context unless prefixed with **/, so it uses **/.env, **/.env.* (keeping **/.env.example), **/*.db*, **/server-key.json and **/node_modules, and excludes docs/, e2e/, test/ and projects/ outright. deploy/.env on the VPS is therefore not copied into the image by COPY . ..

Known information leaks

  • GET /api/puzzle ignores any pub parameter and never includes yours. HTTP proves nothing about who is asking, and keys are public, so a player's own scored answer and streak are sent only over the WebSocket, whose key has signed the connection challenge.
  • GET /api/matches and /api/player/<pub> list full public keys, player names and results. That is by design: records are public.

Rules engine, fairness protocol, verifier, analysis and worker: MIT. Server and client: AGPL-3.0-or-later.