package store import ( "context" "crypto/rand" "database/sql" "fmt" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) // ProbeNonceBytes is the challenge size. It only needs to be unguessable // within the freshness window, not long-lived key material. const ProbeNonceBytes = 16 const ( ProbeChallengeUpsertSQL = `INSERT INTO probe_challenges (player_id, region, nonce, issued_at) VALUES ($1, $2, $3, $4) ON CONFLICT (player_id, region) DO UPDATE SET nonce = EXCLUDED.nonce, issued_at = EXCLUDED.issued_at` // Consuming deletes in the same statement: a challenge is single-use, so a // captured probe response cannot be replayed to refresh a stale RTT. ProbeChallengeConsumeSQL = `DELETE FROM probe_challenges WHERE player_id = $1 AND region = $2 RETURNING nonce, issued_at` ProbeChallengePurgeSQL = `DELETE FROM probe_challenges WHERE issued_at < $1` ) // IssueProbeChallenge mints and stores a fresh nonce for one player and // region. It is durable rather than per-process because any control-plane // replica may serve the follow-up submission. func IssueProbeChallenge(ctx context.Context, db *sql.DB, playerID, region string, now time.Time) ([]byte, error) { if db == nil || playerID == "" || (region != "EU" && region != "NA") || now.IsZero() { return nil, fmt.Errorf("invalid probe challenge arguments") } nonce := make([]byte, ProbeNonceBytes) if _, err := rand.Read(nonce); err != nil { return nil, err } if _, err := db.ExecContext(ctx, ProbeChallengeUpsertSQL, playerID, region, nonce, now); err != nil { return nil, err } return nonce, nil } // ConsumeProbeChallenge returns the outstanding nonce and when it was issued, // removing it so it cannot be reused. func ConsumeProbeChallenge(ctx context.Context, db *sql.DB, playerID, region string) ([]byte, time.Time, error) { if db == nil || playerID == "" || (region != "EU" && region != "NA") { return nil, time.Time{}, fmt.Errorf("invalid probe challenge arguments") } var nonce []byte var issuedAt time.Time err := db.QueryRowContext(ctx, ProbeChallengeConsumeSQL, playerID, region).Scan(&nonce, &issuedAt) if err == sql.ErrNoRows { return nil, time.Time{}, domain.ErrInvalidProbe } if err != nil { return nil, time.Time{}, err } return nonce, issuedAt, nil } // PurgeExpiredProbeChallenges drops challenges that can no longer be answered // within the freshness window, so an abandoned probe cannot accumulate. func PurgeExpiredProbeChallenges(ctx context.Context, db *sql.DB, now time.Time) (int64, error) { if db == nil || now.IsZero() { return 0, fmt.Errorf("invalid probe challenge purge arguments") } result, err := db.ExecContext(ctx, ProbeChallengePurgeSQL, now.Add(-domain.ProbeFreshness)) if err != nil { return 0, err } return result.RowsAffected() } // ProbeEvidenceFromChallenge is the production ProbeProvider. The RTT is // derived entirely from backend timestamps -- the interval between issuing the // challenge and receiving the answer -- so no client-reported latency // influences placement, which is the property docs/MATCHMAKING.md ยง4 requires. func ProbeEvidenceFromChallenge(ctx context.Context, db *sql.DB, playerID, region string, opaqueLocation, nonce []byte, receivedAt time.Time) (domain.ProbeEvidence, []byte, error) { expectedNonce, issuedAt, err := ConsumeProbeChallenge(ctx, db, playerID, region) if err != nil { return domain.ProbeEvidence{}, nil, err } rtt := receivedAt.Sub(issuedAt) if rtt < 0 { // Clock skew between replicas; treat as immediate rather than letting // a negative duration through to placement. rtt = 0 } return domain.ProbeEvidence{ OpaqueLocation: opaqueLocation, Nonce: nonce, IssuedAt: issuedAt, Region: region, ServerRTT: rtt, }, expectedNonce, nil }