feat: add ticket and session policy

This commit is contained in:
Josh Creek
2026-08-31 20:49:45 +01:00
parent dfe8d46d99
commit e702661388
3 changed files with 207 additions and 2 deletions
+2 -2
View File
@@ -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 |
+142
View File
@@ -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
}
+63
View File
@@ -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)
}
}