Appearance
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: DENYWhy 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-srcallowsws:andwss:to any host. This is what makes the?server=override below possible.referrer-policy: no-referrerstops sign-in tokens in URLs from leaking through theRefererheader.
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 usesinnerHTMLin over twenty places, so each new template has to keep usingesc(). - 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 fromPUBLIC_URLplus 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.
registerwith an address that already has an account, andlogin-linkfor 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 roll | it does not have your next chain link until you ask to roll. See Dice fairness |
| steer any roll | its 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 detection | the 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 name | the participation check needs a signature from the guest's key, which never leaves their browser |
| claim a guest's key on its own server | binding a key to a session requires a signature over a fresh challenge |
| cache illegal analysis from a worker | every 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.wrappedcolumn 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
forfeitevents (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.
.gitignoreand.dockerignoreexcludeserver-key.json,*.pkcs8/*.pem/*.p8,*.db*and.env*(with.env.exampleallowed in git).server-key.jsonis written with mode0600. If either file ever reaches a remote,.gitignoresays 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.shdoes not copy any.env(at any depth),server-key.json,*.dborrecords/, and its--deletecannot remove them — nor anything underprojects/ordeploy/sites/*.caddy.Worker writes need a key. With
GAMMONET_KEYunset, the worker endpoints return503, 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-USERdefault-drop rule plus an external cloud firewall bound what can be opened. See Firewall.Secrets stay out of the image.
.dockerignorepatterns match only at the root of the build context unless prefixed with**/, so it uses**/.env,**/.env.*(keeping**/.env.example),**/*.db*,**/server-key.jsonand**/node_modules, and excludesdocs/,e2e/,test/andprojects/outright.deploy/.envon the VPS is therefore not copied into the image byCOPY . ..
Known information leaks
GET /api/puzzleignores anypubparameter and never includesyours. 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/matchesand/api/player/<pub>list full public keys, player names and results. That is by design: records are public.