From e702661388fc1f94365cb6a199811f10f0744eaf Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:49:45 +0100 Subject: [PATCH] feat: add ticket and session policy --- multiplayer-todo.md | 4 +- server/domain/auth.go | 142 +++++++++++++++++++++++++++++++++++++ server/domain/auth_test.go | 63 ++++++++++++++++ 3 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 server/domain/auth.go create mode 100644 server/domain/auth_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index bbd0384a..3a9ce611 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1179,8 +1179,8 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| -| 8.7 `[D:7.6,8.3]` | Validate `AuthenticateUserTicket` only in the secure backend with the expected App ID and identity string; reject expiry, replay, wrong app, bans and malformed input | Publisher credentials exist only in the backend secret store; forged/replayed tickets and client-supplied SteamIDs never create a session | -| 8.8 `[D:8.7]` | Issue short-lived revocable sessions bound to verified Steam identity; add account/IP limits, body/schema limits, replay checks and generic public errors | Revocation takes effect across replicas; abuse cannot cause unbounded memory, work or response amplification | +| 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.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 | diff --git a/server/domain/auth.go b/server/domain/auth.go new file mode 100644 index 00000000..805b04e0 --- /dev/null +++ b/server/domain/auth.go @@ -0,0 +1,142 @@ +package domain + +import ( + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "fmt" + "sync" + "time" +) + +type SteamTicket struct { + TicketID string + SteamID string + AppID uint64 + ExpiresAt time.Time +} + +type VerifiedIdentity struct { + PlayerID string + SteamID string +} + +type TicketVerifier struct { + mu sync.Mutex + expectedApp uint64 + consumed map[string]time.Time +} + +var ( + ErrTicketRejected = fmt.Errorf("steam ticket rejected") + ErrSessionRejected = fmt.Errorf("session rejected") +) + +func NewTicketVerifier(expectedApp uint64) (*TicketVerifier, error) { + if expectedApp == 0 { + return nil, ErrTicketRejected + } + return &TicketVerifier{expectedApp: expectedApp, consumed: make(map[string]time.Time)}, nil +} + +// Verify consumes a backend-validated ticket exactly once. In production the +// adapter must obtain the Steam Web API response before calling this policy; +// callers never get to choose the verified SteamID independently. +func (v *TicketVerifier) Verify(ticket SteamTicket, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) { + v.mu.Lock() + defer v.mu.Unlock() + if ticket.TicketID == "" || ticket.SteamID == "" || resolve == nil || ticket.AppID != v.expectedApp || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) { + return VerifiedIdentity{}, ErrTicketRejected + } + if _, used := v.consumed[ticket.TicketID]; used { + return VerifiedIdentity{}, ErrTicketRejected + } + playerID, ok := resolve(ticket.SteamID) + if !ok || playerID == "" { + return VerifiedIdentity{}, ErrTicketRejected + } + v.consumed[ticket.TicketID] = now + return VerifiedIdentity{PlayerID: playerID, SteamID: ticket.SteamID}, nil +} + +type Session struct { + SessionID string + PlayerID string + ExpiresAt time.Time + RevokedAt time.Time +} + +type SessionStore struct { + mu sync.Mutex + sessions map[string]Session + digests map[string]string +} + +func NewSessionStore() *SessionStore { + return &SessionStore{sessions: make(map[string]Session), digests: make(map[string]string)} +} + +func (s *SessionStore) Issue(playerID string, lifetime time.Duration, now time.Time) (Session, string, error) { + if playerID == "" || lifetime <= 0 { + return Session{}, "", ErrSessionRejected + } + token, err := randomToken() + if err != nil { + return Session{}, "", err + } + sessionID, err := randomToken() + if err != nil { + return Session{}, "", err + } + session := Session{SessionID: sessionID, PlayerID: playerID, ExpiresAt: now.Add(lifetime)} + s.mu.Lock() + s.sessions[sessionID] = session + s.digests[sessionID] = digestToken(token) + s.mu.Unlock() + return session, token, nil +} + +func (s *SessionStore) Authenticate(sessionID, token string, now time.Time) (Session, error) { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[sessionID] + if !ok || session.RevokedAt != (time.Time{}) || !now.Before(session.ExpiresAt) || !constantTimeEqual(s.digests[sessionID], digestToken(token)) { + return Session{}, ErrSessionRejected + } + return session, nil +} + +func (s *SessionStore) Revoke(sessionID string, now time.Time) error { + s.mu.Lock() + defer s.mu.Unlock() + session, ok := s.sessions[sessionID] + if !ok { + return ErrSessionRejected + } + if session.RevokedAt.IsZero() { + session.RevokedAt = now + s.sessions[sessionID] = session + } + return nil +} + +func randomToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +func digestToken(token string) string { + digest := sha256.Sum256([]byte(token)) + return hex.EncodeToString(digest[:]) +} + +func constantTimeEqual(a, b string) bool { + if len(a) != len(b) { + return false + } + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/server/domain/auth_test.go b/server/domain/auth_test.go new file mode 100644 index 00000000..0350d88c --- /dev/null +++ b/server/domain/auth_test.go @@ -0,0 +1,63 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func TestTicketVerifierBindsAppIdentityExpiryAndSingleUse(t *testing.T) { + now := time.Unix(1000, 0) + verifier, _ := NewTicketVerifier(480) + ticket := SteamTicket{TicketID: "ticket-1", SteamID: "steam-1", AppID: 480, ExpiresAt: now.Add(time.Minute)} + resolve := func(steamID string) (string, bool) { return "player-1", steamID == "steam-1" } + identity, err := verifier.Verify(ticket, resolve, now) + if err != nil || identity.PlayerID != "player-1" || identity.SteamID != "steam-1" { + t.Fatalf("identity = %+v err=%v", identity, err) + } + if _, err := verifier.Verify(ticket, resolve, now); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("ticket replay accepted: %v", err) + } + wrong := ticket + wrong.TicketID = "ticket-2" + wrong.AppID = 481 + if _, err := verifier.Verify(wrong, resolve, now); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("wrong app accepted: %v", err) + } + expired := ticket + expired.TicketID = "ticket-3" + expired.ExpiresAt = now + if _, err := verifier.Verify(expired, resolve, now); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("expired ticket accepted: %v", err) + } + unknown := ticket + unknown.TicketID = "ticket-4" + unknown.SteamID = "steam-unknown" + if _, err := verifier.Verify(unknown, resolve, now); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("unresolved SteamID accepted: %v", err) + } +} + +func TestSessionIsOpaqueShortLivedAndRevocable(t *testing.T) { + now := time.Unix(1000, 0) + store := NewSessionStore() + session, token, err := store.Issue("player-1", time.Minute, now) + if err != nil || token == "" || session.PlayerID != "player-1" { + t.Fatalf("issue = %+v token=%q err=%v", session, token, err) + } + if _, err := store.Authenticate(session.SessionID, "wrong", now); !errors.Is(err, ErrSessionRejected) { + t.Fatalf("wrong token accepted: %v", err) + } + if got, err := store.Authenticate(session.SessionID, token, now.Add(59*time.Second)); err != nil || got.SessionID != session.SessionID { + t.Fatalf("valid auth = %+v err=%v", got, err) + } + if err := store.Revoke(session.SessionID, now); err != nil { + t.Fatal(err) + } + if _, err := store.Authenticate(session.SessionID, token, now); !errors.Is(err, ErrSessionRejected) { + t.Fatalf("revoked session accepted: %v", err) + } + if _, err := store.Authenticate(session.SessionID, token, now.Add(time.Minute)); !errors.Is(err, ErrSessionRejected) { + t.Fatalf("expired session accepted: %v", err) + } +}