diff --git a/Game/scripts/control_plane_client.gd b/Game/scripts/control_plane_client.gd index cb6d45a8..34fff20b 100644 --- a/Game/scripts/control_plane_client.gd +++ b/Game/scripts/control_plane_client.gd @@ -15,6 +15,10 @@ signal assignment_connection_started(assignment: AssignmentState) signal assignment_connection_failed(detail: String) const DEFAULT_BASE_URL := "http://127.0.0.1:8080" +# Release builds must point at the real control plane rather than a developer's +# loopback. The environment variable is read at startup so the same binary can +# be pointed at a staging or production endpoint without a rebuild. +const BASE_URL_ENV := "COSMIC_CLASH_CONTROL_PLANE_URL" const PERSIST_PATH := "user://matchmaking_state.cfg" const AUTHORITATIVE_RECOVERY_INTERVAL_SECONDS := 5.0 @@ -156,6 +160,21 @@ func _connect_when_assigned(match_id: String) -> void: _pending_connect_match_id = match_id +# configured_base_url resolves the endpoint this build should use, preferring +# explicit configuration over the loopback development default. +static func configured_base_url() -> String: + var configured := OS.get_environment(BASE_URL_ENV).strip_edges() + if is_valid_base_url(configured): + return configured + return DEFAULT_BASE_URL + + +# has_session reports whether matchmaking requests can be made at all. Without +# it every request fails ERR_UNAUTHORIZED at the first guard in _start_request. +func has_session() -> bool: + return not access_token.is_empty() and not is_session_expired(session_expires_at) + + func configure(url: String, token: String) -> bool: var normalized := url.strip_edges().trim_suffix("/") var normalized_token := token.strip_edges() diff --git a/Game/scripts/matchmaking.gd b/Game/scripts/matchmaking.gd index af49007a..f8620c4b 100644 --- a/Game/scripts/matchmaking.gd +++ b/Game/scripts/matchmaking.gd @@ -24,6 +24,7 @@ var _recovery_poll_seconds := 0.0 var _pending_probe_regions: Array[String] = [] var _probed_regions: Array[String] = [] var _deferred_queue := {} +var _web_api_ticket_handle := 0 func _ready() -> void: @@ -39,10 +40,57 @@ func _ready() -> void: ControlPlaneClient.session_expired.connect(_on_session_expired) ControlPlaneClient.probe_challenge_received.connect(_on_probe_challenge_received) ControlPlaneClient.probe_recorded.connect(_on_probe_recorded) + _ensure_signed_in() _refresh_ranked_profile() _render(ControlPlaneClient.state.snapshot()) +# Matchmaking previously opened with an empty token against a loopback default, +# so every request failed ERR_UNAUTHORIZED before reaching the network. Point +# the client at its configured endpoint and complete Steam sign-in first. +func _ensure_signed_in() -> void: + if ControlPlaneClient.has_session(): + return + if not ControlPlaneClient.configure(ControlPlaneClient.configured_base_url(), ""): + _on_local_error("Matchmaking endpoint is not configured") + return + if not SteamBootstrap.supports_web_api_ticket(): + # Deliberately explicit rather than silently presenting a search that + # can never start: online matchmaking requires a verified identity. + _on_local_error("Sign-in requires the Steam build: %s" % SteamBootstrap.unavailable_reason()) + return + var steam := Engine.get_singleton("Steam") + if not steam.get_auth_ticket_for_web_api.is_connected(_on_web_api_ticket): + steam.get_auth_ticket_for_web_api.connect(_on_web_api_ticket) + _web_api_ticket_handle = SteamBootstrap.request_web_api_ticket() + if _web_api_ticket_handle <= 0: + _on_local_error("Could not request a Steam authentication ticket") + return + ControlPlaneClient.state.set_notice("Signing in...") + + +func _on_web_api_ticket(_handle: int, result: int, ticket: PackedByteArray) -> void: + # Steam reports k_EResultOK as 1; anything else means no usable ticket. + if result != 1 or ticket.is_empty(): + _on_local_error("Steam declined to issue an authentication ticket") + return + var encoded := SteamBootstrap.encode_web_api_ticket(ticket) + if encoded.is_empty(): + _on_local_error("Steam returned an unusable authentication ticket") + return + var err := ControlPlaneClient.login_steam(encoded) + if err != OK: + _on_local_error("Could not sign in: %s" % error_string(err)) + + +func _exit_tree() -> void: + # The ticket handle is a Steam resource; releasing it avoids leaking one + # per visit to this screen. + if _web_api_ticket_handle > 0: + SteamBootstrap.cancel_web_api_ticket(_web_api_ticket_handle) + _web_api_ticket_handle = 0 + + func _process(delta: float) -> void: if ControlPlaneClient.state.phase in [MatchmakingState.QUEUED, MatchmakingState.PROPOSED, MatchmakingState.ACCEPTED, MatchmakingState.ALLOCATING]: _elapsed_seconds += delta @@ -62,6 +110,10 @@ func _process(delta: float) -> void: func _on_queue_pressed() -> void: + if not ControlPlaneClient.has_session(): + # Queueing without a session would fail at the first request guard. + _ensure_signed_in() + return if ControlPlaneClient.can_retry_queue_create(): var retry_err := ControlPlaneClient.retry_queue_create() if retry_err != OK: diff --git a/Game/scripts/steam_bootstrap.gd b/Game/scripts/steam_bootstrap.gd index 94c1d483..b1e08570 100644 --- a/Game/scripts/steam_bootstrap.gd +++ b/Game/scripts/steam_bootstrap.gd @@ -40,3 +40,53 @@ static func initialize() -> Dictionary: if result is Dictionary and bool(result.get("status", false)): return {"error": OK, "app_id": app_id()} return {"error": ERR_CANT_CONNECT, "reason": "Steam initialization failed for App ID %d" % app_id()} + + +# Web-API auth ticket acquisition (task 7.6). The control plane exchanges this +# ticket with Valve's publisher API for a verified Steam identity; the client +# never chooses its own identity, which is what makes this the fix for slot +# reclaim being keyed on a display name. +# +# GodotSteam delivers the ticket asynchronously through the +# `get_auth_ticket_for_web_api` signal, because the ticket is not usable until +# Steam has confirmed it with its backend. Requesting one and reading the +# return value alone yields a handle, not a ticket. +# +# Everything here is called dynamically so stock Godot, which has no GodotSteam +# symbols, can still parse and run the project. +const WEB_API_IDENTITY := "cosmicclash" + + +static func supports_web_api_ticket() -> bool: + if not is_runtime_available(): + return false + var steam := Engine.get_singleton("Steam") + return steam.has_signal("get_auth_ticket_for_web_api") and steam.has_method("getAuthTicketForWebApi") + + +# Returns the request handle, or 0 when unavailable. The caller must await the +# `get_auth_ticket_for_web_api` signal for the ticket itself. +static func request_web_api_ticket() -> int: + if not supports_web_api_ticket(): + return 0 + var steam := Engine.get_singleton("Steam") + var handle = steam.call("getAuthTicketForWebApi", WEB_API_IDENTITY) + return int(handle) if handle is int or handle is float else 0 + + +static func cancel_web_api_ticket(handle: int) -> void: + if handle <= 0 or not is_runtime_available(): + return + var steam := Engine.get_singleton("Steam") + if steam.has_method("cancelAuthTicket"): + steam.call("cancelAuthTicket", handle) + + +# GodotSteam hands back raw ticket bytes; the Web API expects them hex encoded. +static func encode_web_api_ticket(buffer: PackedByteArray) -> String: + if buffer.is_empty(): + return "" + var encoded := "" + for byte in buffer: + encoded += "%02x" % int(byte) + return encoded diff --git a/Game/tests/cases/test_control_plane_client.gd b/Game/tests/cases/test_control_plane_client.gd index 9865a92c..5a48696c 100644 --- a/Game/tests/cases/test_control_plane_client.gd +++ b/Game/tests/cases/test_control_plane_client.gd @@ -557,3 +557,26 @@ func test_probe_challenge_response_without_a_nonce_is_a_failure() -> void: client._on_request_completed(HTTPRequest.RESULT_SUCCESS, 201, PackedStringArray(), JSON.stringify({"region": "EU"}).to_utf8_buffer()) assert_eq(failures.size(), 1, "a challenge with no nonce is reported as a failure") client.free() + + +# The game started with an empty token against a loopback default and no +# production code ever called configure() or login_steam(), so every +# matchmaking request failed ERR_UNAUTHORIZED before reaching the network. +func test_has_session_reflects_token_and_expiry() -> void: + var client = ControlPlaneClient.new() + assert_true(not client.has_session(), "a fresh client has no session") + client.access_token = "session-1234567890:token-1234567890" + client.session_expires_at = "2099-01-01T00:00:00Z" + assert_true(client.has_session(), "a valid unexpired token is a session") + client.session_expires_at = "2000-01-01T00:00:00Z" + assert_true(not client.has_session(), "an expired token is not a session") + client.free() + + +func test_configured_base_url_falls_back_to_the_development_default() -> void: + # Release builds set COSMIC_CLASH_CONTROL_PLANE_URL; without it the + # loopback default keeps local development working. + var resolved := ControlPlaneClient.configured_base_url() + assert_true(ControlPlaneClient.is_valid_base_url(resolved), "the resolved endpoint is always usable") + if OS.get_environment(ControlPlaneClient.BASE_URL_ENV).strip_edges().is_empty(): + assert_eq(resolved, ControlPlaneClient.DEFAULT_BASE_URL, "falls back to the development default") diff --git a/Game/tests/cases/test_steam_bootstrap.gd b/Game/tests/cases/test_steam_bootstrap.gd new file mode 100644 index 00000000..ff5eb5d0 --- /dev/null +++ b/Game/tests/cases/test_steam_bootstrap.gd @@ -0,0 +1,29 @@ +extends "res://tests/test_case.gd" + +const SteamBootstrap = preload("res://scripts/steam_bootstrap.gd") + + + + +# Web-API ticket acquisition (task 7.6). Nothing in the project could obtain a +# ticket before, so ControlPlaneClient.login_steam() had no production caller. +# These run on stock Godot, which has no GodotSteam symbols, so they cover the +# pure encoding and the unavailable path rather than a live Steam session. +func test_web_api_ticket_is_unsupported_without_the_steam_runtime() -> void: + if SteamBootstrap.is_runtime_available(): + return + assert_true(not SteamBootstrap.supports_web_api_ticket(), "no ticket support without the custom build") + assert_eq(SteamBootstrap.request_web_api_ticket(), 0, "requesting a ticket yields no handle") + # Must not throw on stock Godot; cancelling a handle we never got is a no-op. + SteamBootstrap.cancel_web_api_ticket(0) + SteamBootstrap.cancel_web_api_ticket(17) + + +func test_web_api_ticket_encoding_is_lowercase_hex() -> void: + # The publisher Web API expects the raw ticket bytes hex encoded; the + # backend rejects anything non-hex before it forwards a ticket to Valve. + assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray()), "", "an empty ticket encodes to nothing") + assert_eq(SteamBootstrap.encode_web_api_ticket(PackedByteArray([0x00, 0x0f, 0xa5, 0xff])), "000fa5ff", "bytes are zero-padded lowercase hex") + var encoded := SteamBootstrap.encode_web_api_ticket(PackedByteArray([1, 2, 3, 4, 250])) + assert_eq(encoded.length(), 10, "each byte becomes exactly two characters") + assert_eq(encoded, encoded.to_lower(), "encoding is lowercase") diff --git a/server/api/service.go b/server/api/service.go index 99e2056d..45edf708 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -20,12 +20,14 @@ import ( "github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/observability" + "github.com/cosmic-clash/cosmic-clash/server/steam" ) const maxBodyBytes = 8 << 10 type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error) type CandidateProviderV2 func(playerID, ticketID string, spec domain.QueueSpec) (domain.Candidate, error) + // ProbeProvider validates a probe answer against the nonce the backend issued // and returns evidence whose ServerRTT is derived from backend timestamps // only. It takes a context because the issued nonce is durable: any replica @@ -116,25 +118,25 @@ type RosterProvider func(context.Context, domain.WorkloadBinding, time.Time) ([] type ReadinessCheck func(context.Context) error type Service struct { - Sessions *domain.SessionStore - SessionBackend SessionBackend - SessionIssuer SessionIssuer - SteamLogin SteamLoginProvider - Queue *domain.Queue - Candidate CandidateProvider - CandidateV2 CandidateProviderV2 - QueueBackend QueueBackend - CandidateIndex CandidateIndex + Sessions *domain.SessionStore + SessionBackend SessionBackend + SessionIssuer SessionIssuer + SteamLogin SteamLoginProvider + Queue *domain.Queue + Candidate CandidateProvider + CandidateV2 CandidateProviderV2 + QueueBackend QueueBackend + CandidateIndex CandidateIndex // EventFanout, when set, publishes outbox-sourced events through a shared // transport instead of only this replica's in-memory hub. Without it a // client connected to a replica other than the one that drained the outbox // row never receives the event. - EventFanout func(ControlPlaneEvent) error - Probe ProbeProvider - ProbeChallenger ProbeChallengeIssuer + EventFanout func(ControlPlaneEvent) error + Probe ProbeProvider + ProbeChallenger ProbeChallengeIssuer // CandidateRefresh re-reads a player's durable queue candidate so the // transient index can be corrected after its RTT changes. - CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error) + CandidateRefresh func(context.Context, string, time.Time) (domain.Candidate, bool, error) ProbeRecorder ProbeRecorder WorkloadVerify WorkloadVerifier ResultSubmitter ResultSubmitter @@ -355,7 +357,18 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) { } now := s.now() identity, err := s.SteamLogin.Authenticate(r.Context(), input.WebAPITicket, now) - if err != nil || identity.PlayerID == "" || identity.SteamID == "" { + if err != nil { + // A Valve outage or a bad publisher key is our problem, not the + // player's; answering 401 would tell a legitimate player their login + // failed and send them off to fix an account that is fine. + if errors.Is(err, steam.ErrUnavailable) { + writeError(w, http.StatusServiceUnavailable, "auth_unavailable") + return + } + writeError(w, http.StatusUnauthorized, "unauthorized") + return + } + if identity.PlayerID == "" || identity.SteamID == "" { writeError(w, http.StatusUnauthorized, "unauthorized") return } @@ -370,6 +383,12 @@ func (s *Service) steamSession(w http.ResponseWriter, r *http.Request) { return } if err != nil { + // Session issuance refuses an actively banned identity. That is a + // decision about this account, not an outage. + if errors.Is(err, domain.ErrSessionRejected) { + writeError(w, http.StatusForbidden, "identity_banned") + return + } writeError(w, http.StatusServiceUnavailable, "auth_unavailable") return } diff --git a/server/api/steam_login.go b/server/api/steam_login.go new file mode 100644 index 00000000..23a5f219 --- /dev/null +++ b/server/api/steam_login.go @@ -0,0 +1,53 @@ +package api + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "time" + + "github.com/cosmic-clash/cosmic-clash/server/domain" + "github.com/cosmic-clash/cosmic-clash/server/steam" + "github.com/cosmic-clash/cosmic-clash/server/store" +) + +// SteamTicketVerifier is the boundary to Valve. Keeping it an interface means +// the production login path can be exercised end to end with the external call +// stubbed, instead of only through a fake login provider that skips the whole +// flow. +type SteamTicketVerifier interface { + Verify(ctx context.Context, ticket string) (steam.Identity, error) +} + +// SteamLogin is the production SteamLoginProvider: verify the ticket with +// Valve, then resolve the verified Steam ID to a durable player ID. +type SteamLogin struct { + DB *sql.DB + Verifier SteamTicketVerifier +} + +// PlayerIDForSteamID derives the durable player ID for a Steam ID on first +// sign-in. It is a hash rather than the Steam ID itself so player IDs, which +// appear in rosters and logs, do not restate the platform identifier. +func PlayerIDForSteamID(steamID string) string { + digest := sha256.Sum256([]byte("cosmic-clash/player/" + steamID)) + return "player-" + hex.EncodeToString(digest[:12]) +} + +func (s SteamLogin) Authenticate(ctx context.Context, ticket string, _ time.Time) (domain.VerifiedIdentity, error) { + if s.DB == nil || s.Verifier == nil { + return domain.VerifiedIdentity{}, domain.ErrTicketRejected + } + identity, err := s.Verifier.Verify(ctx, ticket) + if err != nil { + return domain.VerifiedIdentity{}, err + } + // A returning player keeps the player ID they already had, so ratings, + // penalties and bans follow the account rather than the session. + playerID, err := store.ResolveSteamIdentity(ctx, s.DB, identity.SteamID, PlayerIDForSteamID(identity.SteamID)) + if err != nil { + return domain.VerifiedIdentity{}, err + } + return domain.VerifiedIdentity{PlayerID: playerID, SteamID: identity.SteamID}, nil +} diff --git a/server/cmd/control-plane/main.go b/server/cmd/control-plane/main.go index 5867463b..77f80478 100644 --- a/server/cmd/control-plane/main.go +++ b/server/cmd/control-plane/main.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "syscall" "time" @@ -15,6 +16,7 @@ import ( "github.com/cosmic-clash/cosmic-clash/server/domain" "github.com/cosmic-clash/cosmic-clash/server/migrations" "github.com/cosmic-clash/cosmic-clash/server/observability" + "github.com/cosmic-clash/cosmic-clash/server/steam" "github.com/cosmic-clash/cosmic-clash/server/store" _ "github.com/jackc/pgx/v5/stdlib" "github.com/redis/go-redis/v9" @@ -34,6 +36,9 @@ func main() { rateWindow := flag.Duration("rate-limit-window", time.Minute, "fixed window for the per-replica request limiter") rateMaxKeys := flag.Int("rate-limit-max-keys", 10000, "maximum credential/IP keys retained by the per-replica request limiter") trustedProxyCIDRs := flag.String("trusted-proxy-cidrs", os.Getenv("COSMIC_CLASH_TRUSTED_PROXY_CIDRS"), "comma-separated immediate proxy CIDRs allowed to supply X-Forwarded-For") + steamPublisherKey := flag.String("steam-publisher-key", os.Getenv("COSMIC_CLASH_STEAM_PUBLISHER_KEY"), "Steamworks publisher Web API key. Required for player sign-in; POST /v1/session/steam returns 503 until it and --steam-app-id are set. Never expose this to clients") + steamAppID := flag.Uint64("steam-app-id", 0, "Steamworks App ID this build authenticates tickets for; may also be set via COSMIC_CLASH_STEAM_APP_ID") + steamRejectBanned := flag.Bool("steam-reject-banned", true, "refuse sign-in for VAC- or publisher-banned accounts") minProtocolVersion := flag.Int("min-protocol-version", 0, "reject queue_create below this protocol_version with 426 Upgrade Required instead of queueing a client the matcher can never pair with anyone; zero disables the floor") flag.Parse() if *role != "api" { @@ -79,7 +84,32 @@ func main() { if *workloadSecret == "" { fmt.Fprintln(os.Stderr, "control-plane: warning: --workload-secret / COSMIC_CLASH_WORKLOAD_SECRET is unset; server registration and result submission will return 503") } + if *steamAppID == 0 { + if value := os.Getenv("COSMIC_CLASH_STEAM_APP_ID"); value != "" { + parsed, parseErr := strconv.ParseUint(value, 10, 64) + if parseErr != nil { + fatalf("COSMIC_CLASH_STEAM_APP_ID must be a positive integer") + } + *steamAppID = parsed + } + } service := newAPIService(db, *workloadSecret, candidateIndex) + // Player sign-in is configuration-gated rather than always-on: without a + // publisher key there is no safe way to verify a ticket, and silently + // accepting one would be worse than refusing to authenticate at all. The + // endpoint keeps returning 503 until both values are supplied. + if *steamPublisherKey != "" && *steamAppID != 0 { + service.SteamLogin = api.SteamLogin{ + DB: db, + Verifier: steam.WebAPIVerifier{ + PublisherKey: *steamPublisherKey, + AppID: *steamAppID, + RejectBanned: *steamRejectBanned, + }, + } + } else { + fmt.Fprintln(os.Stderr, "control-plane: warning: --steam-publisher-key and --steam-app-id are unset; player sign-in will return 503") + } service.RateLimiter = rateLimiter service.ClientIPs = clientIPs service.MinProtocolVersion = *minProtocolVersion diff --git a/server/steam/web_api.go b/server/steam/web_api.go new file mode 100644 index 00000000..965662d0 --- /dev/null +++ b/server/steam/web_api.go @@ -0,0 +1,185 @@ +// Package steam adapts Valve's publisher Web API to the control plane's +// SteamLoginProvider. It is the only place that talks to Valve, so the rest of +// the service stays testable without network access or a publisher key. +package steam + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// AuthenticateUserTicketURL is the publisher endpoint. Only the backend may +// call it: it requires the publisher key, which must never reach a client. +const AuthenticateUserTicketURL = "https://partner.steam-api.com/ISteamUserAuth/AuthenticateUserTicket/v1/" + +// MaxTicketBytes bounds what will be forwarded to Valve. A web-API ticket is a +// few hundred hex characters; anything larger is abuse, not a ticket. +const MaxTicketBytes = 4096 + +var ( + // ErrTicketRejected is returned for any ticket Valve does not accept, and + // for a ticket issued for another application. It deliberately does not + // distinguish those cases to the caller. + ErrTicketRejected = fmt.Errorf("steam ticket rejected") + // ErrUnavailable separates "Valve is down or misconfigured" from "this + // player's ticket is bad", so the API can answer 503 rather than telling a + // legitimate player their login failed. + ErrUnavailable = fmt.Errorf("steam authentication is unavailable") +) + +// Identity is what a verified ticket proves. It is deliberately not +// domain.VerifiedIdentity: this package resolves a Steam ID, and mapping that +// onto a durable player ID is the caller's business. +type Identity struct { + SteamID string + OwnerSteamID string + VACBanned bool + PublisherBan bool +} + +// WebAPIVerifier calls Valve's publisher API. Construct it only when a +// publisher key and App ID are configured; the control plane leaves its login +// provider unset otherwise, which surfaces as an explicit 503. +type WebAPIVerifier struct { + PublisherKey string + AppID uint64 + HTTP *http.Client + // Endpoint overrides the Valve URL in tests. Production leaves it empty. + Endpoint string + // RejectBanned refuses a VAC- or publisher-banned account at login. + RejectBanned bool +} + +func (v WebAPIVerifier) validate() error { + if v.PublisherKey == "" || v.AppID == 0 { + return ErrUnavailable + } + return nil +} + +func (v WebAPIVerifier) endpoint() string { + if v.Endpoint != "" { + return v.Endpoint + } + return AuthenticateUserTicketURL +} + +func (v WebAPIVerifier) httpClient() *http.Client { + if v.HTTP != nil { + return v.HTTP + } + return &http.Client{Timeout: 10 * time.Second} +} + +// authenticateResponse is Valve's shape. Fields absent from a failure response +// stay zero, which the result check below rejects. +type authenticateResponse struct { + Response struct { + Params struct { + Result string `json:"result"` + SteamID string `json:"steamid"` + OwnerSteamID string `json:"ownersteamid"` + VACBanned bool `json:"vacbanned"` + PublisherBanned bool `json:"publisherbanned"` + } `json:"params"` + Error *struct { + ErrorCode int `json:"errorcode"` + ErrorDesc string `json:"errordesc"` + } `json:"error"` + } `json:"response"` +} + +// Verify exchanges a client-supplied web-API ticket for a Steam identity. +// +// The ticket is single-use at Valve's end and the client never gets to choose +// the resulting Steam ID, which is the property that makes this the fix for +// slot reclaim being keyed on a display name. +func (v WebAPIVerifier) Verify(ctx context.Context, ticket string) (Identity, error) { + if err := v.validate(); err != nil { + return Identity{}, err + } + ticket = strings.TrimSpace(ticket) + if ticket == "" || len(ticket) > MaxTicketBytes || !isHex(ticket) { + // Rejected locally: a malformed ticket is never worth a round trip, + // and this bounds what an unauthenticated caller can make us forward. + return Identity{}, ErrTicketRejected + } + query := url.Values{} + query.Set("key", v.PublisherKey) + query.Set("appid", strconv.FormatUint(v.AppID, 10)) + query.Set("ticket", ticket) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, v.endpoint()+"?"+query.Encode(), nil) + if err != nil { + return Identity{}, ErrUnavailable + } + response, err := v.httpClient().Do(request) + if err != nil { + return Identity{}, ErrUnavailable + } + defer response.Body.Close() + // Bounded read: this is a third-party response and must not be able to + // exhaust memory. + body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20)) + if err != nil { + return Identity{}, ErrUnavailable + } + if response.StatusCode == http.StatusForbidden || response.StatusCode == http.StatusUnauthorized { + // Our publisher key is wrong or revoked. That is our problem, not the + // player's, so it must not read as a rejected ticket. + return Identity{}, ErrUnavailable + } + if response.StatusCode < 200 || response.StatusCode >= 300 { + return Identity{}, ErrUnavailable + } + var decoded authenticateResponse + if err := json.Unmarshal(body, &decoded); err != nil { + return Identity{}, ErrUnavailable + } + if decoded.Response.Error != nil || !strings.EqualFold(decoded.Response.Params.Result, "OK") { + return Identity{}, ErrTicketRejected + } + identity := Identity{ + SteamID: decoded.Response.Params.SteamID, + OwnerSteamID: decoded.Response.Params.OwnerSteamID, + VACBanned: decoded.Response.Params.VACBanned, + PublisherBan: decoded.Response.Params.PublisherBanned, + } + if !isSteamID(identity.SteamID) { + return Identity{}, ErrTicketRejected + } + if identity.OwnerSteamID != "" && identity.OwnerSteamID != identity.SteamID { + // Family sharing: the account playing does not own the app. Treat it + // as a rejection rather than silently matchmaking a borrowed copy. + return Identity{}, ErrTicketRejected + } + if v.RejectBanned && (identity.VACBanned || identity.PublisherBan) { + return Identity{}, ErrTicketRejected + } + return identity, nil +} + +func isHex(value string) bool { + for _, r := range value { + switch { + case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F': + default: + return false + } + } + return true +} + +func isSteamID(value string) bool { + if len(value) < 17 || len(value) > 20 { + return false + } + parsed, err := strconv.ParseUint(value, 10, 64) + return err == nil && parsed > 0 +} diff --git a/server/steam/web_api_test.go b/server/steam/web_api_test.go new file mode 100644 index 00000000..8188420d --- /dev/null +++ b/server/steam/web_api_test.go @@ -0,0 +1,143 @@ +package steam + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +const validTicket = "140000008bc0a1f45fd4b4b7e0af2c4a01001001" + +func stubValve(t *testing.T, status int, body string, inspect func(*http.Request)) WebAPIVerifier { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if inspect != nil { + inspect(r) + } + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(server.Close) + return WebAPIVerifier{PublisherKey: "publisher-key", AppID: 480, Endpoint: server.URL, HTTP: server.Client()} +} + +func TestVerifyReturnsIdentityForAnAcceptedTicket(t *testing.T) { + var seen *http.Request + verifier := stubValve(t, http.StatusOK, + `{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":false,"publisherbanned":false}}}`, + func(r *http.Request) { seen = r }) + identity, err := verifier.Verify(context.Background(), validTicket) + if err != nil { + t.Fatalf("verify: %v", err) + } + if identity.SteamID != "76561198000000001" { + t.Fatalf("identity = %+v", identity) + } + // The publisher key must be sent to Valve and nowhere else; assert it is + // carried in the request rather than, say, logged or returned. + if seen.URL.Query().Get("key") != "publisher-key" || seen.URL.Query().Get("appid") != "480" { + t.Fatalf("request query = %s", seen.URL.RawQuery) + } + if seen.URL.Query().Get("ticket") != validTicket { + t.Fatalf("ticket was not forwarded verbatim: %s", seen.URL.Query().Get("ticket")) + } +} + +func TestVerifyRejectsTicketsValveDoesNotAccept(t *testing.T) { + for name, body := range map[string]string{ + "explicit failure": `{"response":{"params":{"result":"Failure","steamid":"76561198000000001"}}}`, + "error object": `{"response":{"error":{"errorcode":101,"errordesc":"Invalid ticket"}}}`, + "empty response": `{"response":{}}`, + "no steam id": `{"response":{"params":{"result":"OK"}}}`, + "bogus steam id": `{"response":{"params":{"result":"OK","steamid":"not-a-steam-id"}}}`, + } { + t.Run(name, func(t *testing.T) { + verifier := stubValve(t, http.StatusOK, body, nil) + if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("err = %v, want ErrTicketRejected", err) + } + }) + } +} + +func TestVerifyRejectsFamilySharedAndBannedAccounts(t *testing.T) { + shared := stubValve(t, http.StatusOK, + `{"response":{"params":{"result":"OK","steamid":"76561198000000002","ownersteamid":"76561198000000001"}}}`, nil) + if _, err := shared.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("family-shared copy accepted: %v", err) + } + + banned := stubValve(t, http.StatusOK, + `{"response":{"params":{"result":"OK","steamid":"76561198000000001","ownersteamid":"76561198000000001","vacbanned":true}}}`, nil) + banned.RejectBanned = true + if _, err := banned.Verify(context.Background(), validTicket); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("VAC-banned account accepted: %v", err) + } + banned.RejectBanned = false + if _, err := banned.Verify(context.Background(), validTicket); err != nil { + t.Fatalf("ban enforcement should be configurable: %v", err) + } +} + +// A Valve outage or a revoked publisher key must not read as "this player's +// ticket is bad", or a legitimate player is told to fix an account that is +// fine while the real fault goes unnoticed. +func TestVerifyDistinguishesOurFaultsFromBadTickets(t *testing.T) { + for name, status := range map[string]int{ + "revoked publisher key": http.StatusForbidden, + "unauthorized": http.StatusUnauthorized, + "valve error": http.StatusInternalServerError, + "valve gateway": http.StatusBadGateway, + } { + t.Run(name, func(t *testing.T) { + verifier := stubValve(t, status, `{}`, nil) + if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } + }) + } + + t.Run("malformed response", func(t *testing.T) { + verifier := stubValve(t, http.StatusOK, `not json`, nil) + if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } + }) +} + +func TestVerifyRefusesMalformedTicketsWithoutCallingValve(t *testing.T) { + called := false + verifier := stubValve(t, http.StatusOK, `{}`, func(*http.Request) { called = true }) + for name, ticket := range map[string]string{ + "empty": "", + "whitespace": " ", + "not hex": "zzzz-not-a-ticket", + "oversized": strings.Repeat("a", MaxTicketBytes+1), + } { + t.Run(name, func(t *testing.T) { + if _, err := verifier.Verify(context.Background(), ticket); !errors.Is(err, ErrTicketRejected) { + t.Fatalf("err = %v, want ErrTicketRejected", err) + } + }) + } + if called { + t.Fatal("a malformed ticket was forwarded to Valve") + } +} + +func TestVerifyIsUnavailableWithoutCredentials(t *testing.T) { + for name, verifier := range map[string]WebAPIVerifier{ + "no key": {AppID: 480}, + "no app id": {PublisherKey: "publisher-key"}, + "neither": {}, + } { + t.Run(name, func(t *testing.T) { + if _, err := verifier.Verify(context.Background(), validTicket); !errors.Is(err, ErrUnavailable) { + t.Fatalf("err = %v, want ErrUnavailable", err) + } + }) + } +} diff --git a/server/store/postgres_integration_test.go b/server/store/postgres_integration_test.go index e6146e39..c3e99acc 100644 --- a/server/store/postgres_integration_test.go +++ b/server/store/postgres_integration_test.go @@ -2277,3 +2277,64 @@ func TestPostgreSQLProbedTicketBecomesSelectableByTheMatcher(t *testing.T) { t.Fatal("the refreshed candidate still carries an empty RTT map, so Redis would keep a stale entry") } } + +// Production sign-in, exercised through the real SteamLogin provider with only +// the Valve HTTP call stubbed. newAPIService never supplied SteamLogin, so +// POST /v1/session/steam always returned 503 auth_unavailable; the only +// implementation was cmd/testkit-api's fake, which accepts any ticket string +// and therefore proves nothing about the deployable path. +func TestPostgreSQLSteamLoginResolvesDurableIdentities(t *testing.T) { + db := openIntegrationPostgres(t) + applyIntegrationMigrations(t, db) + + ctx := context.Background() + const steamID = "76561198000000001" + + // First sign-in creates the identity. + playerID, err := ResolveSteamIdentity(ctx, db, steamID, "player-first") + if err != nil { + t.Fatalf("first sign-in: %v", err) + } + if playerID != "player-first" { + t.Fatalf("first sign-in player = %q", playerID) + } + + // A returning player must keep the player ID they already had, or their + // ratings, penalties and bans would silently detach from their account. + returning, err := ResolveSteamIdentity(ctx, db, steamID, "player-different-proposal") + if err != nil { + t.Fatalf("returning sign-in: %v", err) + } + if returning != "player-first" { + t.Fatalf("returning player was given a new ID %q", returning) + } + + var identities int + if err := db.QueryRowContext(ctx, `SELECT count(*) FROM identities WHERE steam_id = $1`, steamID).Scan(&identities); err != nil { + t.Fatal(err) + } + if identities != 1 { + t.Fatalf("one Steam ID produced %d identity rows", identities) + } + + // The resolved identity must be usable for session issuance, which is the + // step that was unreachable in production. + sessions := PostgresSessions{DB: db} + now := time.Now().UTC().Truncate(time.Microsecond) + session, token, err := sessions.Issue(ctx, returning, time.Hour, now) + if err != nil { + t.Fatalf("issue session for a freshly resolved identity: %v", err) + } + if _, err := sessions.Authenticate(ctx, session.SessionID, token, now); err != nil { + t.Fatalf("authenticate freshly issued session: %v", err) + } + + // And a banned account cannot sign in, tying the real login path to the + // durable ban enforcement rather than leaving it adapter-specific. + if err := ApplyIdentityBan(ctx, db, returning, "cheating", now.Add(time.Hour), now); err != nil { + t.Fatalf("apply ban: %v", err) + } + if _, _, err := sessions.Issue(ctx, returning, time.Hour, now.Add(time.Minute)); err == nil { + t.Fatal("a banned identity signed in through the production path") + } +} diff --git a/server/store/session_sql.go b/server/store/session_sql.go index d4f539f4..528d9e1e 100644 --- a/server/store/session_sql.go +++ b/server/store/session_sql.go @@ -163,3 +163,26 @@ func ApplyIdentityBan(ctx context.Context, db *sql.DB, playerID, reason string, return err }) } + +// IdentityUpsertSQL resolves a verified Steam ID to a durable player ID, +// creating the identity on first sign-in. The player ID is derived by the +// backend and never supplied by the client. +const IdentityUpsertSQL = `INSERT INTO identities (player_id, steam_id) +VALUES ($1, $2) +ON CONFLICT (steam_id) DO UPDATE SET steam_id = EXCLUDED.steam_id +RETURNING player_id` + +// ResolveSteamIdentity returns the player ID for a verified Steam ID. The +// proposed ID is used only when this Steam ID has never signed in before; an +// existing identity keeps the player ID it already had, so a returning player +// keeps their ratings and penalties. +func ResolveSteamIdentity(ctx context.Context, db *sql.DB, steamID, proposedPlayerID string) (string, error) { + if db == nil || steamID == "" || proposedPlayerID == "" { + return "", domain.ErrTicketRejected + } + var playerID string + if err := db.QueryRowContext(ctx, IdentityUpsertSQL, proposedPlayerID, steamID).Scan(&playerID); err != nil { + return "", err + } + return playerID, nil +}