From 02a11704ce4d966873be7414332359a4ef52a6fa Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 21:34:27 +0100 Subject: [PATCH] feat: add authenticated probe evidence boundary --- multiplayer-next.md | 4 +++- multiplayer-todo.md | 2 +- server/api/service.go | 43 ++++++++++++++++++++++++++++++++++++++ server/api/service_test.go | 35 +++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 2 deletions(-) diff --git a/multiplayer-next.md b/multiplayer-next.md index 369b617f..95c84112 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -76,7 +76,9 @@ product policy are in [`docs/MATCHMAKING.md`](docs/MATCHMAKING.md). PostgreSQL/Redis wiring remains. - [ ] **IN PROGRESS:** Validate opaque Steam ping locations and nonce-bound probes server-side; require <=100 ms, enforce discrepancy quarantine and the locked widening/ - region/team tie-break rules. + region/team tie-break rules. Authenticated probe transport now routes opaque + location/nonce data through a server-owned evidence provider and refuses + client RTT values; Steam/coordinator adapters remain. - [ ] **IN PROGRESS:** Send 10 s proposals to every selected human: ranked six, relaxed casual two to six with disclosed bots; enforce exact cooldown and queue-precedence behavior. diff --git a/multiplayer-todo.md b/multiplayer-todo.md index e57edfe0..d759239f 100644 --- a/multiplayer-todo.md +++ b/multiplayer-todo.md @@ -1192,7 +1192,7 @@ the local/CI/community transport, not a silent production fallback. | # | Task | Acceptance | |---|---|---| | 8.14 `[D:8.4,8.5,8.8]` | **IN PROGRESS.** Pure Go queue domain enforces one active ticket per verified player under concurrent mutation, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel, owner-only recovery reads and deterministic candidate projection; store layer adds a rebuildable candidate-cache boundary and authenticated HTTP queue adapter | `server/domain/queue.go`, `server/store/candidates.go` and `server/api/service.go` cover ownership/expiry/idempotency, concurrent create fencing, expired recovery as a terminal error, server-owned candidate resolution, bounded/strict JSON input and cache loss/atomic rebuild; PostgreSQL row adapter, real Redis index/TTLs and restart/failover integration remain | -| 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release | `server/domain/probes.go` and adversarial fixtures cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine and five-clean release; Steam coordinator and regional probe adapters remain | +| 8.15 `[D:7.8,8.3]` | **IN PROGRESS.** Pure Go probe validation treats Steam location as opaque, requires nonce/freshness/region and server-computed RTT, and implements discrepancy quarantine/release; authenticated HTTP now accepts only opaque location/nonce input through a server-owned probe provider | `server/domain/probes.go`, adversarial fixtures and `server/api/service.go` cover stale/wrong/forged evidence, the 25 ms/30% threshold, three-sample quarantine, five-clean release, authenticated provider arguments and rejection of client RTT fields; Steam coordinator and regional probe adapters remain | | 8.16 `[D:8.14,8.15]` | **IN PROGRESS.** Pure Go candidate/team selection implements the <=100 ms ceiling, pairwise widening tolerance, anchor inclusion, deterministic set/region scoring and balanced team partitioning | `server/domain/matcher.go`, `teams.go` and adversarial fixtures cover no-common-region, tolerance boundaries, lexical ties, mean-rating balance and input-order independence; queue-backed candidate loading and full population fixtures remain | | 8.17 `[D:8.14,8.16]` | **IN PROGRESS.** Pure Go proposal policy sends a 10-second response window to every selected human, requires unanimous acceptance, applies exact decline/timeout cooldowns and ranked escalation; authenticated API now exposes revisioned accept/decline mutations | `server/domain/proposal.go` and `server/api/service.go` plus adversarial fixtures cover partial/unanimous response, expiry, replay/conflict, stale API revision and ranked six-player/cooldown rules; casual 6→2 composition, queue precedence and allocation integration remain | | 8.18 `[D:8.5,8.14,8.17]` | **IN PROGRESS.** Go store layer defines PostgreSQL SERIALIZABLE whole-transaction retries and queue candidate/proposal claim SQL using `FOR UPDATE SKIP LOCKED` plus durable uniqueness/revision fences | `server/store/serializable.go` and tests cover retry classification and claim-boundary invariants; live PostgreSQL adapter/row decoding, Redis candidate index/repair, worker-failure and concurrent two-matcher integration tests remain | diff --git a/server/api/service.go b/server/api/service.go index a6a8b927..baeb0588 100644 --- a/server/api/service.go +++ b/server/api/service.go @@ -19,11 +19,13 @@ import ( const maxBodyBytes = 8 << 10 type CandidateProvider func(playerID, ticketID string) (domain.Candidate, error) +type ProbeProvider func(playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) type Service struct { Sessions *domain.SessionStore Queue *domain.Queue Candidate CandidateProvider + Probe ProbeProvider Now func() time.Time Proposals map[string]*domain.Proposal RankedProfiles map[string]domain.RankedProfile @@ -38,6 +40,7 @@ func (s *Service) Handler() http.Handler { mux.HandleFunc("/v1/queue/", s.queueMutation) mux.HandleFunc("/v1/proposals/", s.proposalMutation) mux.HandleFunc("/v1/profile/ranked", s.rankedProfile) + mux.HandleFunc("/v1/probes/", s.probe) return mux } @@ -232,6 +235,46 @@ func (s *Service) rankedProfile(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, rankedProfileResponse{Rating: profile.Value, RD: profile.RD, Volatility: profile.Volatility, RankedGames: profile.RankedGames, Tier: string(tier), Provisional: domain.RankedIsProvisional(profile), SeasonID: profile.LastSeasonID}) } +type probeRequest struct { + OpaqueLocation []byte `json:"opaque_location"` + Nonce []byte `json:"nonce"` +} + +func (s *Service) probe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeError(w, http.StatusMethodNotAllowed, "method_not_allowed") + return + } + playerID, ok := s.authenticate(w, r) + if !ok { + return + } + region := strings.TrimPrefix(r.URL.Path, "/v1/probes/") + if (region != "EU" && region != "NA") || strings.Contains(region, "/") { + writeError(w, http.StatusNotFound, "not_found") + return + } + if s.Probe == nil { + writeError(w, http.StatusServiceUnavailable, "probe_unavailable") + return + } + var input probeRequest + if !decodeBody(w, r, &input) { + return + } + receivedAt := s.now() + evidence, expectedNonce, err := s.Probe(playerID, region, input.OpaqueLocation, input.Nonce, receivedAt) + if err != nil { + writeError(w, http.StatusUnprocessableEntity, "probe_unavailable") + return + } + if evidence.Region != region || domain.ValidateProbe(evidence, expectedNonce, receivedAt) != nil { + writeError(w, http.StatusUnprocessableEntity, "invalid_probe") + return + } + writeJSON(w, http.StatusAccepted, map[string]any{"region": region, "server_rtt_ms": evidence.ServerRTT.Milliseconds(), "status": "accepted"}) +} + func (s *Service) authenticate(w http.ResponseWriter, r *http.Request) (string, bool) { if s.Sessions == nil { writeError(w, http.StatusServiceUnavailable, "auth_unavailable") diff --git a/server/api/service_test.go b/server/api/service_test.go index c345e750..d84fd332 100644 --- a/server/api/service_test.go +++ b/server/api/service_test.go @@ -236,3 +236,38 @@ func TestRankedProfileAPIReturnsBackendTierAndHidesCasualData(t *testing.T) { t.Fatalf("ranked profile response = %+v", body) } } + +func TestProbeAPIUsesServerEvidenceAndRejectsClientRTTField(t *testing.T) { + now := time.Unix(1000, 0).UTC() + sessions := domain.NewSessionStore() + session, token, err := sessions.Issue("player-a", time.Hour, now) + if err != nil { + t.Fatal(err) + } + called := false + service := &Service{Sessions: sessions, Now: func() time.Time { return now }, Probe: func(playerID, region string, location, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) { + called = true + if playerID != "player-a" || region != "EU" || string(location) != "opaque" || string(nonce) != "nonce" || !receivedAt.Equal(now) { + t.Fatalf("probe provider arguments = %q %s %q %q %v", playerID, region, location, nonce, receivedAt) + } + return domain.ProbeEvidence{OpaqueLocation: location, Nonce: nonce, IssuedAt: now, Region: region, ServerRTT: 40 * time.Millisecond}, nonce, nil + }} + server := httptest.NewServer(service.Handler()) + defer server.Close() + request := `{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U="}` + req, _ := http.NewRequest(http.MethodPost, server.URL+"/v1/probes/EU", strings.NewReader(request)) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + response, err := http.DefaultClient.Do(req) + if err != nil || response.StatusCode != http.StatusAccepted || !called { + t.Fatalf("valid probe status=%v err=%v called=%v", response.StatusCode, err, called) + } + _ = response.Body.Close() + request = `{"opaque_location":"b3BhcXVl","nonce":"bm9uY2U=","server_rtt_ms":1}` + req, _ = http.NewRequest(http.MethodPost, server.URL+"/v1/probes/EU", strings.NewReader(request)) + req.Header.Set("Authorization", "Bearer "+session.SessionID+":"+token) + response, err = http.DefaultClient.Do(req) + if err != nil || response.StatusCode != http.StatusBadRequest { + t.Fatalf("client RTT field status=%v err=%v", response.StatusCode, err) + } + _ = response.Body.Close() +}