diff --git a/multiplayer-todo.md b/multiplayer-todo.md index 699f5675..e546ca0c 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1135,7 +1135,7 @@ New `--role=client-reconnect` grades the returning player: not a spectator, owns | 7.3 `[D:7.2]` `[P]` | `server_browser.tscn` via `ISteamMatchmakingServers` | Internet, LAN, favourites and history lists all populate | | 7.4 `[D:7.2]` `[P]` | Auth tickets in `hello` → `BeginAuthSession`; Steam identity in the roster; persistent ban list | Ownership, VAC and ban state verified server-side | | 7.5 `[D:7.2]` `[P]` | Feature-gate every Steam call behind `OS.has_feature("steam") and ClassDB.class_exists("SteamMultiplayerPeer")`; verify the ENet path end to end | Non-Steam build is fully functional | -| 7.6 `[D:7.4]` | Separate single-use tickets for backend Web API login and game-server auth; wait for Steam's asynchronous validation and cancel/end every ticket session | Replayed, cancelled, wrong-App-ID and not-yet-validated identities cannot enter a roster or queue; no client-supplied SteamID is trusted | +| 7.6 `[D:7.4]` | **IN PROGRESS.** Pure Go `AuthCoordinator` models pending/accepted/rejected/cancelled backend auth sessions around the single-use verifier; separate Web API/game-server ticket lifecycles remain | `server/domain/auth.go` and adversarial tests keep identity unavailable while pending, reject cancellation/expiry/wrong-attempt/replay, and release identity only after verifier success; real Steam BeginAuthSession/EndAuthSession adapter and persistent session integration remain | | 7.7 `[D:7.1]` `[P]` | Obtain the production App ID, publisher key, SDR coordinator SDK/signing approval, certificates and hosted-data-centre support from Valve | Production prerequisites and rotation owners are recorded; no Spacewar credential or development certificate can reach a release build | | 7.8 `[D:7.6,7.7]` | Ticketed Hosted Dedicated Server SDR: routing registration, coordinator-issued player→server relay tickets, client ticket installation, reconnect and expiry | Two real accounts complete and reconnect to an assigned dedicated match through SDR; server/player IPs are not exposed; ENet gates remain green | diff --git a/server/domain/auth.go b/server/domain/auth.go index 805b04e0..3135354b 100644 --- a/server/domain/auth.go +++ b/server/domain/auth.go @@ -28,6 +28,108 @@ type TicketVerifier struct { consumed map[string]time.Time } +type AuthAttemptState string + +const ( + AuthPending AuthAttemptState = "PENDING" + AuthAccepted AuthAttemptState = "ACCEPTED" + AuthRejected AuthAttemptState = "REJECTED" + AuthCancelled AuthAttemptState = "CANCELLED" +) + +type AuthAttempt struct { + AttemptID string + TicketID string + State AuthAttemptState + Identity VerifiedIdentity + ExpiresAt time.Time +} + +// AuthCoordinator models the asynchronous BeginAuthSession lifecycle. The +// external Steam adapter calls Complete only after Steam confirms the ticket; +// clients never supply the verified identity or transition state themselves. +type AuthCoordinator struct { + mu sync.Mutex + attempts map[string]AuthAttempt +} + +var ( + ErrAuthAttemptRejected = fmt.Errorf("auth attempt rejected") + ErrAuthAttemptPending = fmt.Errorf("auth attempt pending") +) + +func NewAuthCoordinator() *AuthCoordinator { + return &AuthCoordinator{attempts: make(map[string]AuthAttempt)} +} + +func (c *AuthCoordinator) Begin(attemptID string, ticket SteamTicket, now time.Time) error { + if c == nil || attemptID == "" || ticket.TicketID == "" || ticket.ExpiresAt.IsZero() || !now.Before(ticket.ExpiresAt) { + return ErrAuthAttemptRejected + } + c.mu.Lock() + defer c.mu.Unlock() + if _, exists := c.attempts[attemptID]; exists { + return ErrAuthAttemptRejected + } + c.attempts[attemptID] = AuthAttempt{AttemptID: attemptID, TicketID: ticket.TicketID, State: AuthPending, ExpiresAt: ticket.ExpiresAt} + return nil +} + +func (c *AuthCoordinator) Complete(attemptID string, ticket SteamTicket, verifier *TicketVerifier, resolve func(string) (string, bool), now time.Time) (VerifiedIdentity, error) { + if c == nil || verifier == nil { + return VerifiedIdentity{}, ErrAuthAttemptRejected + } + c.mu.Lock() + attempt, ok := c.attempts[attemptID] + if !ok || attempt.State != AuthPending || attempt.TicketID != ticket.TicketID || !now.Before(attempt.ExpiresAt) { + c.mu.Unlock() + return VerifiedIdentity{}, ErrAuthAttemptRejected + } + identity, err := verifier.Verify(ticket, resolve, now) + if err != nil { + attempt.State = AuthRejected + c.attempts[attemptID] = attempt + c.mu.Unlock() + return VerifiedIdentity{}, ErrAuthAttemptRejected + } + attempt.State = AuthAccepted + attempt.Identity = identity + c.attempts[attemptID] = attempt + c.mu.Unlock() + return identity, nil +} + +func (c *AuthCoordinator) Cancel(attemptID string) error { + if c == nil || attemptID == "" { + return ErrAuthAttemptRejected + } + c.mu.Lock() + defer c.mu.Unlock() + attempt, ok := c.attempts[attemptID] + if !ok || attempt.State != AuthPending { + return ErrAuthAttemptRejected + } + attempt.State = AuthCancelled + c.attempts[attemptID] = attempt + return nil +} + +func (c *AuthCoordinator) Get(attemptID string) (AuthAttempt, error) { + if c == nil { + return AuthAttempt{}, ErrAuthAttemptRejected + } + c.mu.Lock() + defer c.mu.Unlock() + attempt, ok := c.attempts[attemptID] + if !ok { + return AuthAttempt{}, ErrAuthAttemptRejected + } + if attempt.State != AuthAccepted { + return attempt, ErrAuthAttemptPending + } + return attempt, nil +} + var ( ErrTicketRejected = fmt.Errorf("steam ticket rejected") ErrSessionRejected = fmt.Errorf("session rejected") diff --git a/server/domain/auth_test.go b/server/domain/auth_test.go index 0350d88c..99d5f62f 100644 --- a/server/domain/auth_test.go +++ b/server/domain/auth_test.go @@ -61,3 +61,51 @@ func TestSessionIsOpaqueShortLivedAndRevocable(t *testing.T) { t.Fatalf("expired session accepted: %v", err) } } + +func TestAuthCoordinatorOnlyReleasesBackendVerifiedIdentity(t *testing.T) { + now := time.Unix(1000, 0) + verifier, _ := NewTicketVerifier(480) + coordinator := NewAuthCoordinator() + ticket := SteamTicket{TicketID: "ticket-1", SteamID: "steam-1", AppID: 480, ExpiresAt: now.Add(time.Minute)} + if err := coordinator.Begin("attempt-1", ticket, now); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Get("attempt-1"); !errors.Is(err, ErrAuthAttemptPending) { + t.Fatalf("pending identity exposed: %v", err) + } + if err := coordinator.Cancel("attempt-1"); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Complete("attempt-1", ticket, verifier, func(string) (string, bool) { return "player-1", true }, now); !errors.Is(err, ErrAuthAttemptRejected) { + t.Fatalf("cancelled attempt completed: %v", err) + } + if err := coordinator.Begin("attempt-2", ticket, now); err != nil { + t.Fatal(err) + } + wrongAttemptTicket := ticket + wrongAttemptTicket.TicketID = "ticket-2" + if _, err := coordinator.Complete("attempt-2", wrongAttemptTicket, verifier, func(string) (string, bool) { return "player-1", true }, now); !errors.Is(err, ErrAuthAttemptRejected) { + t.Fatalf("wrong ticket completed: %v", err) + } + identity, err := coordinator.Complete("attempt-2", ticket, verifier, func(id string) (string, bool) { return "player-1", id == "steam-1" }, now) + if err != nil || identity.PlayerID != "player-1" { + t.Fatalf("verified identity = %+v err=%v", identity, err) + } + attempt, err := coordinator.Get("attempt-2") + if err != nil || attempt.State != AuthAccepted || attempt.Identity != identity { + t.Fatalf("accepted attempt = %+v err=%v", attempt, err) + } +} + +func TestAuthCoordinatorRejectsExpiredCompletion(t *testing.T) { + now := time.Unix(1000, 0) + verifier, _ := NewTicketVerifier(480) + coordinator := NewAuthCoordinator() + ticket := SteamTicket{TicketID: "ticket-1", SteamID: "steam-1", AppID: 480, ExpiresAt: now.Add(time.Second)} + if err := coordinator.Begin("attempt-1", ticket, now); err != nil { + t.Fatal(err) + } + if _, err := coordinator.Complete("attempt-1", ticket, verifier, func(string) (string, bool) { return "player-1", true }, now.Add(time.Second)); !errors.Is(err, ErrAuthAttemptRejected) { + t.Fatalf("expired attempt completed: %v", err) + } +}