mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat: validate workload-bound result credentials
This commit is contained in:
+3
-2
@@ -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)).
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user