From cf212d94f93fdaeb4a3c1b307dc2874f03125673 Mon Sep 17 00:00:00 2001 From: Josh Creek <8179928+jcreek@users.noreply.github.com> Date: Mon, 31 Aug 2026 20:21:55 +0100 Subject: [PATCH] feat: validate matchmaking latency evidence --- multiplayer-todo.md | 2 +- server/domain/probes.go | 100 +++++++++++++++++++++++++++++++++++ server/domain/probes_test.go | 40 ++++++++++++++ 3 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 server/domain/probes.go create mode 100644 server/domain/probes_test.go diff --git a/multiplayer-todo.md b/multiplayer-todo.md index fa654b46..509b7542 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, 10 s heartbeat/30 s expiry, retry-safe create/heartbeat/cancel and deterministic candidate projection | `server/domain/queue.go` has adversarial ownership/expiry/idempotency tests; PostgreSQL transaction adapter, Redis candidate index and cache-loss repair remain | -| 8.15 `[D:7.8,8.3]` | Submit opaque Steam ping location plus nonce-bound probes; backend computes estimates, enforces 30 s freshness and quarantines 3 discrepancies >25 ms or 30% until 5 clean matches | A client cannot directly choose its placement RTT; stale/forged evidence is rejected; quarantine behavior and server-observed comparison are deterministic | +| 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.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]` | Ten-second proposal to **every selected human**: ranked 6; casual largest compatible 6→2 after 60 s with disclosed teams/bots; apply exact decline/timeout/no-show cooldown and queue-precedence rules | Allocation starts only after selected humans accept; 2–5-human casual is reachable; accepter timestamps restore exactly; ranked pre-match no-show has cooldown but no rating loss | | 8.18 `[D:8.5,8.14,8.17]` | Horizontally replicated matcher: Redis candidates, then PostgreSQL serializable proposal/participant fence, then cache cleanup/repair | Forced loss of the last acknowledged Redis write, retries, worker death and failover cannot claim a player into two proposals/matches | diff --git a/server/domain/probes.go b/server/domain/probes.go new file mode 100644 index 00000000..9c47156b --- /dev/null +++ b/server/domain/probes.go @@ -0,0 +1,100 @@ +package domain + +import ( + "crypto/subtle" + "errors" + "fmt" + "time" +) + +const ( + ProbeFreshness = 30 * time.Second + ProbeFutureSkew = 5 * time.Second + MaxOpaqueLocationBytes = 512 + DiscrepancyWindow = 24 * time.Hour + DiscrepancyLimit = 3 + CleanSamplesToRelease = 5 +) + +var ( + ErrInvalidProbe = errors.New("invalid latency probe evidence") + ErrProbeQuarantined = errors.New("latency samples are quarantined") +) + +// ProbeEvidence deliberately treats Steam's location as opaque. The backend +// validates freshness/nonce and computes RTT from its own receive timestamps; +// no client-provided RTT is used for placement. +type ProbeEvidence struct { + OpaqueLocation []byte + Nonce []byte + IssuedAt time.Time + Region string + ServerRTT time.Duration +} + +func ValidateProbe(evidence ProbeEvidence, expectedNonce []byte, now time.Time) error { + if len(evidence.OpaqueLocation) == 0 || len(evidence.OpaqueLocation) > MaxOpaqueLocationBytes || len(expectedNonce) == 0 { + return ErrInvalidProbe + } + if len(evidence.Nonce) != len(expectedNonce) || subtle.ConstantTimeCompare(evidence.Nonce, expectedNonce) != 1 { + return fmt.Errorf("%w: nonce mismatch", ErrInvalidProbe) + } + if evidence.IssuedAt.After(now.Add(ProbeFutureSkew)) || now.Sub(evidence.IssuedAt) > ProbeFreshness { + return fmt.Errorf("%w: stale or future timestamp", ErrInvalidProbe) + } + if evidence.Region != "EU" && evidence.Region != "NA" { + return fmt.Errorf("%w: unsupported region", ErrInvalidProbe) + } + if evidence.ServerRTT < 0 { + return fmt.Errorf("%w: negative RTT", ErrInvalidProbe) + } + return nil +} + +type DiscrepancyTracker struct { + BadSamples []time.Time + CleanSamples int + Quarantined bool +} + +// RecordComparison compares backend-computed predicted and observed RTT. A +// discrepancy is over 25 ms or 30% (whichever is larger). Three bad samples +// in 24 hours quarantine placement evidence; five clean samples release it. +func (tracker *DiscrepancyTracker) RecordComparison(predicted, observed time.Duration, now time.Time) { + tracker.prune(now) + maxAllowed := 25 * time.Millisecond + if predicted > 0 { + percent := time.Duration(float64(predicted) * 0.30) + if percent > maxAllowed { maxAllowed = percent } + } + delta := predicted - observed + if delta < 0 { delta = -delta } + if delta > maxAllowed { + tracker.BadSamples = append(tracker.BadSamples, now) + tracker.CleanSamples = 0 + if len(tracker.BadSamples) >= DiscrepancyLimit { tracker.Quarantined = true } + return + } + if tracker.Quarantined { + tracker.CleanSamples++ + if tracker.CleanSamples >= CleanSamplesToRelease { + tracker.Quarantined = false + tracker.BadSamples = nil + tracker.CleanSamples = 0 + } + } +} + +func (tracker *DiscrepancyTracker) prune(now time.Time) { + cutoff := now.Add(-DiscrepancyWindow) + kept := tracker.BadSamples[:0] + for _, sample := range tracker.BadSamples { + if !sample.Before(cutoff) { kept = append(kept, sample) } + } + tracker.BadSamples = kept +} + +func (tracker DiscrepancyTracker) PlacementAllowed() error { + if tracker.Quarantined { return ErrProbeQuarantined } + return nil +} diff --git a/server/domain/probes_test.go b/server/domain/probes_test.go new file mode 100644 index 00000000..c8f7cbb7 --- /dev/null +++ b/server/domain/probes_test.go @@ -0,0 +1,40 @@ +package domain + +import ( + "errors" + "testing" + "time" +) + +func TestValidateProbeRequiresOpaqueFreshNonceAndServerRTT(t *testing.T) { + now := time.Unix(100000, 0) + valid := ProbeEvidence{OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now, Region: "EU", ServerRTT: 40 * time.Millisecond} + if err := ValidateProbe(valid, []byte("nonce"), now); err != nil { t.Fatal(err) } + for name, invalid := range map[string]ProbeEvidence{ + "empty location": {Nonce: []byte("nonce"), IssuedAt: now, Region: "EU"}, + "wrong nonce": {OpaqueLocation: []byte("opaque"), Nonce: []byte("other"), IssuedAt: now, Region: "EU"}, + "stale": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now.Add(-ProbeFreshness - time.Nanosecond), Region: "EU"}, + "client chosen negative RTT": {OpaqueLocation: []byte("opaque"), Nonce: []byte("nonce"), IssuedAt: now, Region: "EU", ServerRTT: -time.Millisecond}, + } { + if err := ValidateProbe(invalid, []byte("nonce"), now); !errors.Is(err, ErrInvalidProbe) { t.Fatalf("%s error = %v", name, err) } + } +} + +func TestDiscrepancyQuarantineAndFiveCleanRelease(t *testing.T) { + now := time.Unix(100000, 0) + var tracker DiscrepancyTracker + for i := 0; i < DiscrepancyLimit; i++ { tracker.RecordComparison(40*time.Millisecond, 100*time.Millisecond, now.Add(time.Duration(i)*time.Minute)) } + if !tracker.Quarantined { t.Fatal("three discrepancies did not quarantine samples") } + if err := tracker.PlacementAllowed(); !errors.Is(err, ErrProbeQuarantined) { t.Fatal("quarantine not enforced") } + for i := 0; i < CleanSamplesToRelease; i++ { tracker.RecordComparison(40*time.Millisecond, 45*time.Millisecond, now.Add(time.Hour+time.Duration(i)*time.Minute)) } + if tracker.Quarantined { t.Fatal("five clean samples did not release quarantine") } +} + +func TestDiscrepancyThresholdUsesLargerOfAbsoluteAndRelativeLimit(t *testing.T) { + now := time.Unix(100000, 0) + var tracker DiscrepancyTracker + tracker.RecordComparison(200*time.Millisecond, 250*time.Millisecond, now) + if tracker.Quarantined { t.Fatal("50ms discrepancy should be allowed when 30%% limit is 60ms") } + tracker.RecordComparison(200*time.Millisecond, 270*time.Millisecond, now.Add(time.Minute)) + if len(tracker.BadSamples) != 1 { t.Fatal("70ms discrepancy should be recorded") } +}