diff --git a/multiplayer-next.md b/multiplayer-next.md index 8a524448..f48de9ad 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -51,8 +51,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). reconnect policies exist; production Steam/backend adapters remain. - [ ] **IN PROGRESS:** Authenticate results with pod/GameServer-bound workload identity; make identical duplicates idempotent and conflicting results - inert/alerting. Pure Go binding, hashing, reconciliation, and SQL boundaries exist; - production credential validation remains. + inert/alerting. Pure Go credential-claim validation, binding, hashing, + reconciliation, and SQL boundaries exist; projected-token/JWT adapters, + trusted-cluster verification, and production alerting remain. - [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)). diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 5248a9bf..d8020933 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1182,7 +1182,7 @@ the local/CI/community transport, not a silent production fallback. | 8.7 `[D:7.6,8.3]` | **IN PROGRESS.** Pure Go ticket policy binds the expected App ID and verified identity, rejects expiry/replay/wrong app/malformed tickets, and consumes each ticket once | `server/domain/auth.go` covers single-use and binding invariants; real `AuthenticateUserTicket` backend adapter, bans, publisher secret store and Steam verification remain | | 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.10 `[D:8.5,8.31]` | **IN PROGRESS.** Dependency-free workload credential policy validates an adapter-verified signature, issuer/audience/time bounds, namespace/service account, pod UID, GameServer UID, allocation ID, match ID and server ID before result submission | `server/domain/workload.go` and adversarial tests reject every binding mutation, missing/unverified signature and time boundary; `server/testkit/pipeline_test.go` carries allocation identity through the offline result path; projected-token/JWT adapter, trusted-cluster verification and live duplicate/conflict alerting remain | | 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 | @@ -1202,7 +1202,7 @@ the local/CI/community transport, not a silent production fallback. | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional and keeps casual ratings outside the API | `RankedIsProvisional` covers the 0–9/10 boundary; authoritative tier derivation and UI remain | | 8.23 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked-only season policy compresses 25% toward 1500, clamps RD to 200–350, preserves volatility/history, is idempotent by season ID, and defines exact 12-week windows/due detection | `server/domain/rating.go` and `season_test.go` cover compression, floor/cap, duplicate replay, window boundary and completed-season idempotence; PostgreSQL locking, persisted rollover transaction and maintenance scheduler remain | | 8.24 `[D:8.9,8.20,8.21]` | **IN PROGRESS.** Pure Go ranked connection policy binds match/server/player/team/slot/protocol, supports 60 s reclaim with server-owned generations, fences old connections, and applies the rolling 7-day 5 m/15 m/1 h/24 h abandon ladder | `server/domain/reconnect.go` covers repeated backend-independent reclaim, binding rejection, old-generation fencing, grace boundary and deterministic cooldown audit ordering; signed authorisations, persistent lease fencing, join transport and full match/result integration remain | -| 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go` plus `server/store/result_sql.go` and adversarial fixtures cover binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain | +| 8.25 `[D:8.10,8.24]` | **IN PROGRESS.** Pure Go result policy binds match/server/workload identity, hashes canonical payloads, makes identical retries idempotent, leaves conflicts inert, separates integrity eligibility, classifies roster/simulation/result/fairness evidence, validates annotation signatures/digests, and exposes 5 m alert/30 m review delivery thresholds; Go store SQL defines conflict-safe receipt insert, deterministic match/rating locks and atomic completion/outbox boundaries | `server/domain/result.go`, `server/domain/workload.go` and `server/store/result_sql.go` plus adversarial fixtures cover credential binding, duplicate/conflict, annotation forgery, delivery-outage-versus-integrity classification, commit and lock ordering; production credential verification, Agones annotation persistence/reconciliation, live PostgreSQL execution and integrity evidence adapters remain | #### 8D — Agones, allocation and regional scaling diff --git a/server/domain/result.go b/server/domain/result.go index 9dd1b27e..498f744b 100644 --- a/server/domain/result.go +++ b/server/domain/result.go @@ -55,6 +55,7 @@ type WorkloadBinding struct { ServiceAcct string PodUID string GameServerUID string + AllocationID string MatchID string ServerID string } @@ -169,7 +170,7 @@ func RatingEligible(receipt ResultReceipt) bool { } func validateBinding(binding WorkloadBinding) error { - if binding.Issuer == "" || binding.Audience == "" || binding.Namespace == "" || binding.ServiceAcct == "" || binding.PodUID == "" || binding.GameServerUID == "" || binding.MatchID == "" || binding.ServerID == "" { + if binding.Issuer == "" || binding.Audience == "" || binding.Namespace == "" || binding.ServiceAcct == "" || binding.PodUID == "" || binding.GameServerUID == "" || binding.AllocationID == "" || binding.MatchID == "" || binding.ServerID == "" { return ErrResultBinding } return nil diff --git a/server/domain/result_test.go b/server/domain/result_test.go index 69d0cf81..bb070e0b 100644 --- a/server/domain/result_test.go +++ b/server/domain/result_test.go @@ -7,7 +7,7 @@ import ( ) func testBinding() WorkloadBinding { - return WorkloadBinding{Issuer: "https://issuer", Audience: "cosmic-result", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: "match-1", ServerID: "server-1"} + return WorkloadBinding{Issuer: "https://issuer", Audience: "cosmic-result", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", AllocationID: "allocation-1", MatchID: "match-1", ServerID: "server-1"} } func testResult() MatchResult { diff --git a/server/domain/workload.go b/server/domain/workload.go new file mode 100644 index 00000000..43b0ea74 --- /dev/null +++ b/server/domain/workload.go @@ -0,0 +1,58 @@ +package domain + +import ( + "fmt" + "time" +) + +// WorkloadCredential is the claim set extracted from a projected service +// account token or a one-match attested credential. Signature verification is +// deliberately supplied by the adapter: the domain must not depend on a JWT +// library or trust claims before the secure boundary has verified them. +type WorkloadCredential struct { + Issuer string + Audience string + IssuedAt time.Time + ExpiresAt time.Time + Namespace string + ServiceAcct string + PodUID string + GameServerUID string + AllocationID string + MatchID string + ServerID string + Signature []byte +} + +// WorkloadCredentialPolicy defines the exact one-allocation identity a result +// credential must carry. It is intentionally immutable after construction. +type WorkloadCredentialPolicy struct { + expected WorkloadBinding + verify func(WorkloadCredential) bool +} + +var ErrWorkloadCredential = fmt.Errorf("workload credential rejected") + +func NewWorkloadCredentialPolicy(expected WorkloadBinding, verify func(WorkloadCredential) bool) (*WorkloadCredentialPolicy, error) { + if err := validateBinding(expected); err != nil || verify == nil { + return nil, ErrWorkloadCredential + } + return &WorkloadCredentialPolicy{expected: expected, verify: verify}, nil +} + +// Validate returns the binding only after every claim has matched the +// allocation and the adapter has accepted the credential's signature. +func (p *WorkloadCredentialPolicy) Validate(credential WorkloadCredential, now time.Time) (WorkloadBinding, error) { + if p == nil || len(credential.Signature) == 0 || p.verify == nil || !p.verify(credential) { + return WorkloadBinding{}, ErrWorkloadCredential + } + if credential.Issuer != p.expected.Issuer || credential.Audience != p.expected.Audience || + credential.Namespace != p.expected.Namespace || credential.ServiceAcct != p.expected.ServiceAcct || + credential.PodUID != p.expected.PodUID || credential.GameServerUID != p.expected.GameServerUID || + credential.AllocationID != p.expected.AllocationID || credential.MatchID != p.expected.MatchID || + credential.ServerID != p.expected.ServerID || credential.IssuedAt.IsZero() || credential.ExpiresAt.IsZero() || + !credential.IssuedAt.Before(credential.ExpiresAt) || now.Before(credential.IssuedAt) || !now.Before(credential.ExpiresAt) { + return WorkloadBinding{}, ErrWorkloadCredential + } + return p.expected, nil +} diff --git a/server/domain/workload_test.go b/server/domain/workload_test.go new file mode 100644 index 00000000..25506a5b --- /dev/null +++ b/server/domain/workload_test.go @@ -0,0 +1,80 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func testCredential(binding WorkloadBinding, now time.Time) WorkloadCredential { + return WorkloadCredential{ + Issuer: binding.Issuer, Audience: binding.Audience, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Minute), + Namespace: binding.Namespace, ServiceAcct: binding.ServiceAcct, PodUID: binding.PodUID, + GameServerUID: binding.GameServerUID, AllocationID: binding.AllocationID, MatchID: binding.MatchID, + ServerID: binding.ServerID, Signature: []byte("attestation"), + } +} + +func TestWorkloadCredentialValidatesOneAllocationIdentity(t *testing.T) { + now := time.Unix(1000, 0).UTC() + binding := testBinding() + policy, err := NewWorkloadCredentialPolicy(binding, func(credential WorkloadCredential) bool { + return string(credential.Signature) == "attestation" + }) + if err != nil { + t.Fatal(err) + } + got, err := policy.Validate(testCredential(binding, now), now) + if err != nil || got != binding { + t.Fatalf("valid credential = %+v, err=%v", got, err) + } +} + +func TestWorkloadCredentialRejectsEveryBindingAndTimeMutation(t *testing.T) { + now := time.Unix(1000, 0).UTC() + binding := testBinding() + policy, _ := NewWorkloadCredentialPolicy(binding, func(credential WorkloadCredential) bool { return true }) + mutate := []func(*WorkloadCredential){ + func(c *WorkloadCredential) { c.Issuer = "other" }, + func(c *WorkloadCredential) { c.Audience = "other" }, + func(c *WorkloadCredential) { c.Namespace = "other" }, + func(c *WorkloadCredential) { c.ServiceAcct = "other" }, + func(c *WorkloadCredential) { c.PodUID = "other" }, + func(c *WorkloadCredential) { c.GameServerUID = "other" }, + func(c *WorkloadCredential) { c.AllocationID = "other" }, + func(c *WorkloadCredential) { c.MatchID = "other" }, + func(c *WorkloadCredential) { c.ServerID = "other" }, + func(c *WorkloadCredential) { c.ExpiresAt = now }, + func(c *WorkloadCredential) { c.IssuedAt = now.Add(time.Second) }, + } + for i, change := range mutate { + credential := testCredential(binding, now) + change(&credential) + if _, err := policy.Validate(credential, now); !errors.Is(err, ErrWorkloadCredential) { + t.Fatalf("mutation %d accepted: %v", i, err) + } + } + badSignature, _ := NewWorkloadCredentialPolicy(binding, func(WorkloadCredential) bool { return false }) + if _, err := badSignature.Validate(testCredential(binding, now), now); !errors.Is(err, ErrWorkloadCredential) { + t.Fatalf("unverified signature accepted: %v", err) + } +} + +func TestWorkloadCredentialRejectsMissingClaimsAndBoundaryExpiry(t *testing.T) { + now := time.Unix(1000, 0).UTC() + binding := testBinding() + policy, _ := NewWorkloadCredentialPolicy(binding, func(WorkloadCredential) bool { return true }) + credential := testCredential(binding, now) + credential.Signature = nil + if _, err := policy.Validate(credential, now); !errors.Is(err, ErrWorkloadCredential) { + t.Fatalf("missing signature accepted: %v", err) + } + credential = testCredential(binding, now) + if _, err := policy.Validate(credential, credential.ExpiresAt); !errors.Is(err, ErrWorkloadCredential) { + t.Fatalf("expiry boundary accepted: %v", err) + } + credential = testCredential(binding, now) + if _, err := policy.Validate(credential, credential.IssuedAt.Add(-time.Nanosecond)); !errors.Is(err, ErrWorkloadCredential) { + t.Fatalf("not-before boundary accepted: %v", err) + } +} diff --git a/server/testkit/pipeline_test.go b/server/testkit/pipeline_test.go index e8e45940..a176dbf3 100644 --- a/server/testkit/pipeline_test.go +++ b/server/testkit/pipeline_test.go @@ -57,12 +57,13 @@ func TestOfflineMatchmakingPipelineReachesDurableResult(t *testing.T) { t.Fatalf("assignment = %+v err=%v", assignment, err) } - store, err := domain.NewResultStore(domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: allocation.MatchID, ServerID: allocation.ServerID}) + binding := domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, ServerID: allocation.ServerID} + store, err := domain.NewResultStore(binding) if err != nil { t.Fatal(err) } result := domain.MatchResult{MatchID: allocation.MatchID, ServerID: allocation.ServerID, ResultNonce: "result-nonce-123456", Team0Score: 3, Team1Score: 2, IntegrityState: domain.IntegrityCertified} - receipt, created, err := store.Submit("result-1234567890123456", result, domain.WorkloadBinding{Issuer: "issuer", Audience: "audience", Namespace: "games", ServiceAcct: "match-server", PodUID: "pod-1", GameServerUID: "gs-1", MatchID: allocation.MatchID, ServerID: allocation.ServerID}, now) + receipt, created, err := store.Submit("result-1234567890123456", result, binding, now) if err != nil || !created || !domain.RatingEligible(receipt) { t.Fatalf("receipt = %+v created=%v err=%v", receipt, created, err) }