From bbabd259b67f8513a4f49a1b496ffa1a4d7238bd Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:58:11 +0100 Subject: [PATCH] docs: complete multiplayer threat model --- docs/THREAT-MODEL.md | 36 ++++++++++++++++++++++++++++ multiplayer-next.md | 5 ++-- multiplayer-todo.md | 2 +- server/security/test_threat_model.py | 23 ++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 docs/THREAT-MODEL.md create mode 100644 server/security/test_threat_model.py diff --git a/docs/THREAT-MODEL.md b/docs/THREAT-MODEL.md new file mode 100644 index 00000000..a7526a39 --- /dev/null +++ b/docs/THREAT-MODEL.md @@ -0,0 +1,36 @@ +# Cosmic Clash multiplayer threat model + +This is the launch threat model for the control plane, dedicated servers and +clients. It records the security boundary and the verification owner for each +class of failure; it does not treat a trusted workload class as a trusted +individual pod. + +| Threat | Prevention | Detection / response | Owner | Residual risk | +|---|---|---|---|---| +| Forged Steam identity or ticket | Backend calls Steam validation for the expected App ID; player ID comes from the verified SteamID mapping, never request JSON | Ticket rejection metrics, replay alerts, ban/revoke identity | Identity/API | Valve/Steam outage pauses new authenticated sessions | +| Ticket/session replay | Single-use ticket nonce; opaque short-lived session token; store token digest and revocation in PostgreSQL | Duplicate-ticket and revoked-session counters; incident revoke all sessions for identity | Identity/API | Stolen live session remains usable until expiry/revocation propagation | +| Queue/proposal flooding or duplicate claims | Body/rate limits, one active ticket partial unique index, idempotency keys, serializable participant fence | Per-identity/IP rate alerts, queue-depth and conflict dashboards, overload shedding | API/matcher | Distributed abusive identities can consume bounded capacity until automated bans act | +| Latency-evidence forgery | Opaque location, nonce/freshness checks, server-computed RTT, discrepancy quarantine; evidence affects placement only | Three-bad/five-clean counters and regional RTT SLO alerts | Matcher/networking | Colluding endpoints can bias placement within the accepted evidence window | +| Join-authorisation theft or slot hijack | Signed match-scoped authorisation binds verified SteamID/match/server/team/slot/protocol/expiry; server-owned generation fences old peers | Rejected-binding/generation metrics and audit events; revoke assignment | Allocator/game-server | A stolen valid authorisation remains usable until expiry unless the server revokes it | +| Forged or replayed match result | Pod/GameServer-bound projected identity or one-match credential; issuer/audience/namespace/SA/pod/GameServer/allocator binding; canonical digest | Receipt conflict is inert and pages; duplicate is idempotent; result lag alerts at 5/30 minutes | Result/maintenance | A compromised authoritative pod can submit before compromise is detected | +| Workload/insider compromise | Per-workload service accounts, least RBAC, private stores, default-deny network, no publisher/root key in game pods | Credential-use audit, pod identity anomaly alerts, immediate workload drain/revoke | Platform/security | Cluster-admin or KMS compromise is outside application controls | +| Gameplay/API DDoS and flood | Connection/body/WebSocket limits, token buckets, overload shedding, edge WAF/DDoS service, live-result priority | Saturation, 5xx, tick-backlog and dropped-work dashboards; shed new queue/allocation work first | SRE/platform | Volumetric attack may require provider mitigation capacity | +| SDR signing-key theft | Offline CA separated from online signer; non-exportable KMS/HSM key; signer allowlist and short-lived tickets | Signer audit and anomaly alerts; rotate/revoke certificates and tickets | Security/networking | Provider/Valve trust or HSM compromise requires external response | +| Dependency/image supply chain | Pin image/dependency digests, SBOM, vulnerability scan, artifact signature and admission verification | CI/admission failures and provenance inventory; critical-fix SLA | Release/security | Unknown zero-days remain possible until detection or patch | +| Denial of wallet / autoscaling abuse | Allocation quotas, budgets, warm-capacity limits, per-identity/IP controls and scale ceilings | Cost-per-match, allocation-rate and quota alerts; disable region/playlist safely | SRE/finance | Legitimate launch spikes can trigger conservative limits | +| Data loss or cache inconsistency | PostgreSQL backups/RPO <=5m, serializable transactions, transactional outbox; Redis is rebuildable only | Restore/failover rehearsal, cache-repair metrics, result reconciliation | Data/SRE | Recovery can pause new work; valid live matches must continue | + +## Trust boundaries + +- Clients are untrusted and cannot submit ratings, outcomes, penalties, + allocation state or exemptions. +- Game servers are authoritative for simulation but are not trusted for + identity, allocation ownership, or unrestricted result submission. +- PostgreSQL is the durable authority. Redis, Agones annotations and local + spool files are recoverable transport/cache state. +- The offline SDR CA and online leaf signer are separate; API, matcher, + allocator and game-server workloads cannot read signer keys. + +Every accepted residual risk above has an owner and a planned detection path. +Security incidents fail closed for identity/result ownership and degrade open +only for recoverable result delivery, where the signed spool is reconciled. diff --git a/multiplayer-next.md b/multiplayer-next.md index 3a476a2c..8a524448 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -53,8 +53,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). identity; make identical duplicates idempotent and conflicting results inert/alerting. Pure Go binding, hashing, reconciliation, and SQL boundaries exist; production credential validation remains. -- [ ] Complete the threat model for forgery, replay, queue/flood/bot abuse, - workload/insider compromise, DDoS, supply chain and denial-of-wallet. +- [x] Complete the threat model for forgery, replay, queue/flood/bot abuse, + workload/insider compromise, DDoS, supply chain and denial-of-wallet + ([THREAT-MODEL.md](docs/THREAT-MODEL.md)). - [ ] Enforce restricted workloads/RBAC/networks/private stores/backups/secrets; isolate SDR signing behind an audited non-exportable signer and add volumetric edge defense, WebSocket limits and overload shedding. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index ec74c02e..431cbd7d 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1183,7 +1183,7 @@ the local/CI/community transport, not a silent production fallback. | 8.8 `[D:8.7]` | **IN PROGRESS.** Pure Go session policy issues opaque short-lived tokens, stores only digests, authenticates by verified player identity and supports revocation | `server/domain/auth.go` covers wrong-token/expiry/revocation behavior; distributed revocation, account/IP limits, request limits and production session persistence remain | | 8.9 `[D:8.4,8.7]` | **IN PROGRESS.** Pure Go join policy binds SteamID/player/match/server/team/slot/protocol/expiry, rejects duplicate roster slots, permits same-identity reclaim through backend loss and fences prior server-owned generations | `server/domain/reconnect.go` covers SteamID/server/slot binding, expiry, repeated reclaim, grace boundary and old-generation fencing; signed token issuance/verification, persistent leases, Godot `hello` transport and production integration remain | | 8.10 `[D:8.5,8.31]` | Authenticate results with pod-bound projected identity or one-match attested credential; validate issuer/audience/expiry, namespace/SA, pod UID, GameServer UID and allocator match binding | Another pod sharing a workload class cannot submit for the allocation; identical duplicates are idempotent; conflicting results are inert and alerting across all trusted clusters | -| 8.11 `[D:8.1]` | Write the threat model: forged clients/replay/queues/results, floods/bots, pod/insider compromise, gameplay and API DDoS, SDR signing-key theft, dependencies and denial-of-wallet | Every threat has prevention/detection/owner/verification; accepted residual risks are explicit; offline CA and online signer trust boundaries are separate | +| 8.11 `[D:8.1]` | **DONE.** Write the threat model: forged clients/replay/queues/results, floods/bots, pod/insider compromise, gameplay and API DDoS, SDR signing-key theft, dependencies and denial-of-wallet | [`docs/THREAT-MODEL.md`](docs/THREAT-MODEL.md) records prevention, detection/response, owner and residual risk for every threat; it separates offline CA/online signer, client/game-server/PostgreSQL/Redis trust boundaries and recovery behavior | | 8.12 `[D:8.11]` | Harden workloads and edge: restricted containers, least RBAC, private DB/Redis, default-deny networks, backups/secrets, volumetric DDoS/WAF/origin shielding, WebSocket limits and overload shedding | Policy/network tests enforce declared flows; edge load test preserves result ingress/live matches while rejecting new work; no credential appears in Git/images/args/telemetry | | 8.13 `[D:8.12]` | Pin images by digest; generate SBOMs, scan dependencies/images, sign artifacts, verify signatures at admission and document a critical-fix SLA | CI blocks a vulnerable/disallowed or unsigned release artifact and records the exact provenance deployed | diff --git a/server/security/test_threat_model.py b/server/security/test_threat_model.py new file mode 100644 index 00000000..9a22428b --- /dev/null +++ b/server/security/test_threat_model.py @@ -0,0 +1,23 @@ +from pathlib import Path +import unittest + + +MODEL = (Path(__file__).parents[2] / "docs" / "THREAT-MODEL.md").read_text() + + +class ThreatModelTest(unittest.TestCase): + def test_required_threat_classes_have_controls_and_owners(self): + for term in ( + "Forged Steam identity", "Ticket/session replay", "Queue/proposal", + "Latency-evidence forgery", "Join-authorisation", "Forged or replayed match result", + "Workload/insider compromise", "DDoS", "SDR signing-key theft", + "supply chain", "Denial of wallet / autoscaling abuse", "Data loss", + ): + self.assertIn(term, MODEL) + self.assertIn("| Owner |", MODEL) + self.assertIn("Residual risk", MODEL) + self.assertIn("PostgreSQL is the durable authority", MODEL) + + +if __name__ == "__main__": + unittest.main()