Status: Living — update when a trust boundary, secret, or accepted risk changes. Disclosure process:
SECURITY.md. Companion:compatibility.md(contracts),docs/privacy.md(user-facing commitments).
This page states what the system defends against and — just as important — what it deliberately accepts. A security finding that lands in the “accepted” table is a known trade-off, not a gap.
| Asset | Where it lives | Sensitivity |
|---|---|---|
| Puzzle library, solve progress, stats | Device SQLite (Drift) | User-private; no server copy |
| Sync blobs (library/sessions/completions/settings) | User’s own iCloud container / Drive AppData | User-private; provider-managed |
| Challenge auth token | Platform secure storage (Keychain/Keystore via SecureKeyValueStore) |
Bearer credential — device-local by design, excluded from sync and OS backup |
| Challenge recovery bundle (player id + recovery secret) | App DB (app_settings), synced to the user’s own cloud |
Credential-equivalent; deliberately survives backup/restore (see privacy.md) |
| Server data (players, boards, results, events) | Cloudflare D1 | Pseudonymous; secrets stored only as SHA-256 hashes |
| Invite links | User-shared URLs | Capability tokens — possession grants join |
| Signing/release secrets (keystore, Apple certs, ASC API key, Play service account) | GitHub Actions Secrets | Highest impact — supply-chain |
API.md / privacy.md).| Threat | Defense |
|---|---|
| Server database theft exposing credentials | Auth tokens, recovery secrets, and invite secrets stored only as SHA-256 hashes; no Worker-side secrets exist at all (wrangler.toml carries only public vars) |
| Token theft from a device backup | Auth token lives in Keychain/Keystore; Android backup rules exclude the secure-prefs file (#240) |
| Token clobbering / double-submit via settings sync | challenge_* auth token and result outbox excluded from sync (#235/#239) |
| Invite brute force / link replay | 256-bit hashed invite secrets, expiry + versioning (regeneration invalidates old links), preview discloses nothing until the secret verifies (#237), write rate limits |
| Mass anonymous identity creation | IP-keyed rate limit on bootstrap/restore (RL_IDENTITY) |
| Board-write abuse | Player-keyed rate limit (RL_WRITE); board caps (5 boards/player, 20 players/board) |
| Fabricated solve times (gross) | Elapsed-time floor, future-timestamp rejection, server-side normalization of clean-eligibility (#228/#237) |
| SQL injection | Parameterized SQL throughout the Worker (verified 2026-06-10 review) |
| Offensive/impersonating handles | Server-side display-name validation: reserved handles + blocklist + normalization (D2) |
| Secret leakage via logs | Structured JSON logs with an explicit no-secrets/no-invite-URL convention |
| Supply-chain via mutable action tags | All GitHub Actions pinned to commit SHAs (#238) |
| Abusive or broken legacy clients that can’t otherwise be retired | X-Crosscue-Client header + optional MIN_SUPPORTED_CLIENT var → structured 426 client_too_old force-upgrade lever on every route (#256) |
| Malicious puzzle files | 5 MB parser cap, typed Result error paths, checksum dedupe; parsers never execute content |
| Accepted risk | Rationale | Revisit when |
|---|---|---|
| Honor-system results — clients self-report times; a motivated user can submit fake (plausible) times | Boards are private, invite-only groups of friends; only bounded sanity checks are worth their complexity in v1 | Boards grow beyond friend groups, or a puzzle-registry/canonical-source check lands (D4 in the #159 breakdown) |
| Invite links are capability URLs — anyone holding a valid link can join | Matches the product model (“share a link with friends”); regeneration + expiry bound the exposure | — |
| Long-lived bearer tokens with no scheduled rotation | Anonymous identity; rotation exists via restore (old token invalidated); compromise blast radius is one pseudonymous player | Tokens ever gate anything beyond leaderboards |
| Recovery bundle in the synced DB rather than secure storage | Must survive OS backup + device restore to keep identity durable; documented in privacy.md; server stores only its hash | — |
| No E2E encryption of sync blobs | The user’s own cloud account is the stated trust boundary; provider-at-rest encryption applies | Product identity changes (it shouldn’t) |
CORS * with Authorization allowed |
Token-only API, no cookies — browser same-origin protections aren’t load-bearing | A web client or cookies appear (finding 16, 2026-06-10) |
| Join caps are check-then-insert — concurrent joins can briefly exceed 20 | Overshoot is cosmetic at this scale (#237) | Caps become contractual |
| No client attestation / anti-tamper | Pointless against a determined user of an open-source app; the data at stake is friend-group times | — |
AVATARS bucket is provisioned — until then photos are
base64 PNGs stored inline in D1 (PNG-validated, #237). Provision the bucket
wrangler tail) — see DEPLOYMENT.md
“Monitoring & alerting” for the planned push signals.Compromised devices, compromised Apple/Google accounts, malicious OS keyboards, and attacks on Cloudflare/Apple/Google infrastructure itself.