Security Posture¶
This chapter states what the system defends, how, and — as importantly — what it knowingly does not defend. A posture that only lists its wins is not a posture.
What is protected, and where¶
Asset |
Protection |
|---|---|
The fleet’s code path |
Minisign signatures on every OTA bundle, public key baked into the image, nothing unpacked before verification (D-87). |
Device tokens |
Stored SHA-256-hashed server-side; presented as a header, never a query string (D-74); a wrong token is a 401, never a downgrade (D-73). |
Sessions and link tokens |
Hashed at rest as of the 2026-07 pass — a database leak no longer hands out live sessions or login URLs. |
Provisioning |
One single-use key per physical unit, hashed at rest, claimed before the device row is inserted so two boards racing one key cannot both win. |
Game integrity |
The relay re-validates every move against authoritative state (D-69); the live feed is a mirror that validates nothing and can only misreport its own board (D-75). |
The appliance itself |
Outbound-only connections — no inbound surface on the board at all. |
Player data |
A separate database from the company’s legal and CAD store (D-72). |
D-93 — Tokens are hashed at rest, the email-sending endpoints are rate-limited, and the board service runs unprivileged
Context A security pass over the deployed surface found three exposures of different kinds: a credential-at-rest gap, an abuse channel, and an over-privileged process.
Decision Three fixes, one per kind.
Hashed at rest. Session ids and link tokens now store a SHA-256 digest rather than the value — the same posture device tokens and provisioning keys always had. No schema change: the columns keep their names and hold digests from here on.
Rate limiting. The two endpoints that send email on demand (signup and request-link) are guarded by a per-key fixed-window limiter, in two scopes: per-IP (one attacker, many addresses) and per-address (many attackers, one victim).
Unprivileged service. The board service runs as the operator account with supplementary groups gpio, i2c and dialout, plus NoNewPrivileges, ProtectSystem=full, PrivateTmp and the kernel-facing protections. Everything the binary touches is group-accessible, so root buys nothing — it only widens what a bug in serial or sensor parsing could reach. /dev stays shared, because reaching the GPIO, I^2^C and serial nodes is the entire point of the process.
Rejected Storing bearer values in the clear (the previous state for sessions and link tokens); leaving the email endpoints unguarded, which let anyone pump unlimited sends at a victim’s inbox, burn the sender reputation, and mint unbounded user rows with fake addresses; and running the board service as root, which was convenient and bought nothing.
Consequences Migrating the hashed columns deleted the existing plaintext rows — they are exactly the exposure the change removes, and hashed lookups could never match them again. The one-time cost was signing every user out and restarting any in-flight link flow: minutes of inconvenience at the current fleet size. The 8-character link code deliberately stays plaintext: at roughly $2^{40}$ a digest of it is offline-brute-forceable in minutes, and it lives for fifteen minutes on a physical screen — hashing it would add complexity, not protection.
D-94 — The launcher runs a fixed canonical deploy path
Context The launch script used to run the newest kirin-gui it could find anywhere under the home directory.
Decision Launch one binary at one fixed path (with the fleet branch of Chapter The Golden Image layered on top), and make the deploy script the one supported way to update a board.
Rejected Newest-wins discovery. A second checkout or a leftover hand-build could silently become what the board ran — and it would keep running it, correctly, for weeks.
Consequences Deploys are reproducible, and “which build is this board running?” has an answer you can read off a path rather than infer from timestamps.
Accepted risks¶
These are known, deliberate, and unresolved. Each is recorded with what would actually close it.
The token sits in plaintext settings on a Pi its owner controls, so the owner can copy it out and drive the relay from a script. Two accounts and two extracted tokens could farm a leaderboard with no hardware moving. This is unenforceable in software: closing it needs a secure element that generates a key on-chip, never releases it, and signs a per-session challenge — a hardware decision touching the board design and unit cost. Correctly deferred while there are no rating stakes; revisit when the leaderboard is public and worth gaming. Signed OTA bundles are unaffected by this, and are the direction that matters: they protect the board from a malicious server, not the server from a modified board.
One request per unit at bring-up, with the key hand-placed. Fine at hand-assembly scale; a real production run wants it folded into the flashing process with a key manifest.
A game that ends and is never rebuilt could miss its row — acceptable for anonymous statistics at v1 volume.
Anti-cheat is genuinely hard when the engine is not merely allowed on the device but is the device. v1 is friends-only, and the schema leaves room for the problem without pretending to have solved it.
Rules that are structural, not configured¶
Three constraints in this system are enforced by construction rather than by a setting, because a setting is one refactor away from being wrong:
No engine assistance during Lichess-rated activity — the session type takes no hint source, pinned by
static_assertand by a metaobject walk at the QML boundary (D-77).A watch socket can never move a board — inbound frames on the read-only feed are ignored, rather than the client being trusted not to send them (D-75).
The server decides who is sitting at a board — the Worker forwards a decided seat identity in place of the client’s claim, and the relay object is not publicly reachable (D-73).