From 7b6e22292a5ddbd0ae685e9e0cfb1c4a67520f54 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:44:27 +0100 Subject: [PATCH] feat: sign reconnect authorisations --- multiplayer-next.md | 4 +++- multiplayer-todo.md | 2 +- server/domain/join_auth.go | 37 +++++++++++++++++++++++++++++ server/domain/reconnect_test.go | 41 +++++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 server/domain/join_auth.go diff --git a/multiplayer-next.md b/multiplayer-next.md index 7aef3d9f..71e7458c 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -48,7 +48,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). - [ ] **IN PROGRESS:** Validate Steam Web API tickets only in the secure backend; issue revocable sessions and reconnect-safe match/identity/slot authorisations with server-owned connection-generation fencing. Pure Go ticket/session and - reconnect policies exist; production Steam/backend adapters remain. + reconnect policies exist, including canonical signed join-authorisation + issuance/verification; production Steam/backend adapters and persistent + lease fencing remain. - [ ] **IN PROGRESS:** Authenticate results with pod/GameServer-bound workload identity; make identical duplicates idempotent and conflicting results inert/alerting. Pure Go credential-claim validation, binding, hashing, diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 947661d6..9fde524e 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1201,7 +1201,7 @@ the local/CI/community transport, not a silent production fallback. | 8.21 `[D:8.5,8.20]` | **IN PROGRESS.** Pure Go rating core implements canonical Glicko-2, daily inactivity, ranked 1/3 and casual 1/N human-opponent weights, deterministic opponent ordering, and authoritative draw/overtime/abandon scoring | `server/domain/rating.go` has canonical/inactivity/weight/invalid-input plus draw/OT/abandon fixtures; PostgreSQL snapshot locking, rating transaction integration, seasons and concurrent result transaction tests remain | | 8.22 `[D:8.21]` | **IN PROGRESS.** Pure Go ranked profile exposes the first ten games as provisional, derives tiers only through validated backend-owned rating bands, and keeps casual ratings outside the API; authenticated HTTP now returns the authoritative ranked view | `server/domain/rating.go`, `tier_test.go` and `server/api/service.go` cover provisional override, exact band boundaries, malformed policy rejection, session authentication and ranked-only response fields; persisted tier policy, client UI and reconnect transport 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; migration and Go store now persist a per-player/per-season marker and rating update atomically | `server/domain/rating.go`, `season_test.go`, `server/migrations/0001_initial.sql` and `server/store/season_sql.go` cover compression, floor/cap, duplicate replay, window boundary, completed-season idempotence, row locking and conflict-safe rollover markers; live PostgreSQL execution 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.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; canonical signed authorisation issuance/verification now gates admission | `server/domain/reconnect.go`, `join_auth.go` and adversarial fixtures cover repeated backend-independent reclaim, all signed claim binding, tampering, failed verification, old-generation fencing, grace boundary and deterministic cooldown audit ordering; 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`, `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/join_auth.go b/server/domain/join_auth.go new file mode 100644 index 00000000..b4661398 --- /dev/null +++ b/server/domain/join_auth.go @@ -0,0 +1,37 @@ +package domain + +import ( + "fmt" + "time" +) + +// SignedJoinAuthorisation is the transport envelope. The signing primitive is +// supplied by the backend signer so this policy stays independent of key +// storage and cryptographic algorithm choice. +type SignedJoinAuthorisation struct { + Authorisation JoinAuthorisation + Signature []byte +} + +func JoinAuthorisationBytes(auth JoinAuthorisation) []byte { + return []byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%s\x00%d\x00%s", + auth.MatchID, auth.ServerID, auth.PlayerID, auth.SteamID, auth.Slot, auth.Team, auth.Protocol, auth.Generation, auth.ExpiresAt.UTC().Format(time.RFC3339Nano))) +} + +func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, error)) (SignedJoinAuthorisation, error) { + if sign == nil { + return SignedJoinAuthorisation{}, ErrJoinAuthorisation + } + signature, err := sign(JoinAuthorisationBytes(auth)) + if err != nil || len(signature) == 0 { + return SignedJoinAuthorisation{}, ErrJoinAuthorisation + } + return SignedJoinAuthorisation{Authorisation: auth, Signature: append([]byte(nil), signature...)}, nil +} + +func (r *RankedConnections) AdmitSigned(signed SignedJoinAuthorisation, verify func([]byte, []byte) bool, now time.Time) (uint64, error) { + if len(signed.Signature) == 0 || verify == nil || !verify(JoinAuthorisationBytes(signed.Authorisation), signed.Signature) { + return 0, ErrJoinAuthorisation + } + return r.Admit(signed.Authorisation, now) +} diff --git a/server/domain/reconnect_test.go b/server/domain/reconnect_test.go index 74cac082..3abf8644 100644 --- a/server/domain/reconnect_test.go +++ b/server/domain/reconnect_test.go @@ -1,6 +1,8 @@ package domain import ( + "crypto/hmac" + "crypto/sha256" "errors" "testing" "time" @@ -92,3 +94,42 @@ func TestRankedAbandonCooldownUsesRollingSevenDayLadder(t *testing.T) { t.Fatalf("abandonment repeated: %+v", again) } } + +func TestSignedJoinAuthorisationBindsEveryClaimBeforeReclaim(t *testing.T) { + now := time.Unix(1000, 0).UTC() + r, err := NewRankedConnections("match-1", "server-1", "v1", testRoster(now)) + if err != nil { + t.Fatal(err) + } + key := []byte("test-signing-key") + sign := func(payload []byte) ([]byte, error) { + mac := hmac.New(sha256.New, key) + _, _ = mac.Write(payload) + return mac.Sum(nil), nil + } + verify := func(payload, signature []byte) bool { + expected, _ := sign(payload) + return hmac.Equal(expected, signature) + } + signed, err := SignJoinAuthorisation(testRoster(now)[0], sign) + if err != nil { + t.Fatal(err) + } + if gen, err := r.AdmitSigned(signed, verify, now); err != nil || gen != 1 { + t.Fatalf("signed initial admit = %d, %v", gen, err) + } + if err := r.Disconnect("a", 1, now); err != nil { + t.Fatal(err) + } + tampered := signed + tampered.Authorisation.Slot = 1 + if _, err := r.AdmitSigned(tampered, verify, now.Add(time.Second)); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("tampered slot accepted: %v", err) + } + if _, err := r.AdmitSigned(signed, func([]byte, []byte) bool { return false }, now.Add(RankedReconnectGrace)); !errors.Is(err, ErrJoinAuthorisation) { + t.Fatalf("unverified signature accepted: %v", err) + } + if gen, err := r.AdmitSigned(signed, verify, now.Add(RankedReconnectGrace)); err != nil || gen != 2 { + t.Fatalf("signed reclaim = %d, %v", gen, err) + } +}