diff --git a/multiplayer-todo.md b/multiplayer-todo.md index cf8aed16..6255f9f2 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1181,7 +1181,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]` | Issue match-scoped join authorisations bound to SteamID/match/server/team/slot/protocol/expiry; allow same-identity slot reclaim while fencing prior connection generations | Altered/expired/wrong identity/server/slot is rejected; reconnect works without backend/Steam; a newer generation makes the old connection unable to send gameplay | +| 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.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 | diff --git a/server/domain/reconnect.go b/server/domain/reconnect.go index 2d3f853e..c2579e59 100644 --- a/server/domain/reconnect.go +++ b/server/domain/reconnect.go @@ -29,6 +29,7 @@ type JoinAuthorisation struct { MatchID string ServerID string PlayerID string + SteamID string Slot int Team int Protocol string @@ -40,6 +41,7 @@ type rankedConnection struct { PlayerID string Slot int Team int + SteamID string Generation uint64 ConnectedAt time.Time LostAt time.Time @@ -65,13 +67,18 @@ func NewRankedConnections(matchID, serverID, protocol string, players []JoinAuth if _, exists := r.players[auth.PlayerID]; exists { return nil, fmt.Errorf("%w: duplicate player", ErrJoinAuthorisation) } - r.players[auth.PlayerID] = rankedConnection{PlayerID: auth.PlayerID, Slot: auth.Slot, Team: auth.Team, Generation: 1} + for _, existing := range r.players { + if existing.Slot == auth.Slot { + return nil, fmt.Errorf("%w: duplicate slot", ErrJoinAuthorisation) + } + } + r.players[auth.PlayerID] = rankedConnection{PlayerID: auth.PlayerID, SteamID: auth.SteamID, Slot: auth.Slot, Team: auth.Team, Generation: 1} } return r, nil } func (r *RankedConnections) validate(auth JoinAuthorisation, now time.Time) error { - if auth.MatchID != r.MatchID || auth.ServerID != r.ServerID || auth.Protocol != r.Protocol || auth.PlayerID == "" || auth.Slot < 0 || auth.Team < 0 || auth.ExpiresAt.IsZero() { + if auth.MatchID != r.MatchID || auth.ServerID != r.ServerID || auth.Protocol != r.Protocol || auth.PlayerID == "" || auth.SteamID == "" || auth.Slot < 0 || auth.Team < 0 || auth.ExpiresAt.IsZero() { return ErrJoinAuthorisation } if !now.IsZero() && !now.Before(auth.ExpiresAt) { @@ -88,7 +95,7 @@ func (r *RankedConnections) Admit(auth JoinAuthorisation, now time.Time) (uint64 return 0, err } player, ok := r.players[auth.PlayerID] - if !ok || player.Slot != auth.Slot || player.Team != auth.Team { + if !ok || player.SteamID != auth.SteamID || player.Slot != auth.Slot || player.Team != auth.Team { return 0, ErrJoinAuthorisation } // Generation in the authorisation identifies the backend-issued assignment diff --git a/server/domain/reconnect_test.go b/server/domain/reconnect_test.go index edbdc502..74cac082 100644 --- a/server/domain/reconnect_test.go +++ b/server/domain/reconnect_test.go @@ -9,7 +9,7 @@ import ( func testRoster(now time.Time) []JoinAuthorisation { roster := make([]JoinAuthorisation, 6) for i := range roster { - roster[i] = JoinAuthorisation{MatchID: "match-1", ServerID: "server-1", Protocol: "v1", PlayerID: string(rune('a' + i)), Slot: i, Team: i % 2, Generation: 1, ExpiresAt: now.Add(time.Hour)} + roster[i] = JoinAuthorisation{MatchID: "match-1", ServerID: "server-1", Protocol: "v1", PlayerID: string(rune('a' + i)), SteamID: string(rune('A' + i)), Slot: i, Team: i % 2, Generation: 1, ExpiresAt: now.Add(time.Hour)} } return roster } @@ -52,6 +52,11 @@ func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) { if _, err := r.Admit(bad, now); !errors.Is(err, ErrJoinAuthorisation) { t.Fatalf("wrong server accepted: %v", err) } + wrongIdentity := testRoster(now)[0] + wrongIdentity.SteamID = "steam-attacker" + if _, err := r.Admit(wrongIdentity, now); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("wrong SteamID accepted: %v", err) + } if err := r.Disconnect("a", 1, now); err != nil { t.Fatal(err) } @@ -60,6 +65,15 @@ func TestRankedReconnectRejectsWrongBindingAndExpiredGrace(t *testing.T) { } } +func TestRankedRosterRejectsDuplicateSlots(t *testing.T) { + now := time.Unix(1000, 0) + roster := testRoster(now) + roster[1].Slot = roster[0].Slot + if _, err := NewRankedConnections("match-1", "server-1", "v1", roster); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("duplicate slot accepted: %v", err) + } +} + func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) { now := time.Unix(1000, 0) r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now))