fix(multiplayer): verify complete durable assignment rosters

This commit is contained in:
Josh Creek
2026-09-03 13:22:02 +01:00
parent bef71e1dcf
commit cee0163eac
4 changed files with 171 additions and 12 deletions
+94 -9
View File
@@ -76,8 +76,20 @@ FROM assignments
WHERE match_id = $1 AND server_id = $2 AND expires_at > $3
ORDER BY slot, player_id`
const AssignmentExpectedRosterSQL = `SELECT mp.player_id, i.steam_id, mp.slot, mp.team
FROM match_participants mp
JOIN identities i ON i.player_id = mp.player_id
JOIN matches m ON m.match_id = mp.match_id
JOIN allocations a ON a.allocation_id = m.allocation_id AND a.match_id = m.match_id AND a.server_id = m.server_id
WHERE mp.match_id = $1 AND m.allocation_id = $2 AND m.server_id = $3
AND m.region = $4 AND m.protocol_version = $5
AND a.region = $4 AND a.build = $6 AND a.protocol_version = $5 AND a.transport = $7
AND a.state = 'ALLOCATED' AND mp.participation_active
ORDER BY mp.player_id
FOR UPDATE OF mp`
func validateDurableAssignment(assignment DurableAssignment) error {
if assignment.MatchID == "" || assignment.PlayerID == "" || assignment.AllocationID == "" || assignment.ServerID == "" || assignment.Slot < 0 || assignment.Slot > 5 || (assignment.Region != "EU" && assignment.Region != "NA") || assignment.ClientBuild == "" || assignment.ProtocolVersion < 1 || (assignment.Transport != "enet" && assignment.Transport != "steam_sdr") || assignment.Endpoint == "" || assignment.JoinAuthorisation == "" || len(assignment.ManifestDigest) == 0 || assignment.ExpiresAt.IsZero() || assignment.Revision < 0 {
if assignment.MatchID == "" || assignment.PlayerID == "" || assignment.AllocationID == "" || assignment.ServerID == "" || assignment.Slot < 0 || assignment.Slot > 5 || (assignment.Region != "EU" && assignment.Region != "NA") || assignment.ClientBuild == "" || assignment.ProtocolVersion < 1 || (assignment.Transport != "enet" && assignment.Transport != "steam_sdr") || assignment.Endpoint == "" || assignment.JoinAuthorisation == "" || len(assignment.ManifestDigest) == 0 || assignment.ExpiresAt.IsZero() || assignment.Revision == 0 {
return fmt.Errorf("invalid durable assignment")
}
return nil
@@ -112,21 +124,42 @@ func SaveAssignments(ctx context.Context, db *sql.DB, assignments []DurableAssig
if db == nil || len(assignments) == 0 {
return fmt.Errorf("invalid assignment batch")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
if err := validateAssignmentBatch(assignments); err != nil {
return err
}
defer tx.Rollback()
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
return saveAssignmentsTx(ctx, tx, assignments)
})
}
func validateAssignmentBatch(assignments []DurableAssignment) error {
if len(assignments) == 0 {
return fmt.Errorf("invalid assignment batch")
}
first := assignments[0]
seen := make(map[string]struct{}, len(assignments))
seenSlots := make(map[int]struct{}, len(assignments))
for _, assignment := range assignments {
if err := validateDurableAssignment(assignment); err != nil {
return err
}
key := assignment.MatchID + "\x00" + assignment.PlayerID
if _, ok := seen[key]; ok {
if assignment.MatchID != first.MatchID || assignment.AllocationID != first.AllocationID || assignment.ServerID != first.ServerID || assignment.Region != first.Region || assignment.ClientBuild != first.ClientBuild || assignment.ProtocolVersion != first.ProtocolVersion || assignment.Transport != first.Transport || assignment.Endpoint != first.Endpoint || string(assignment.ManifestDigest) != string(first.ManifestDigest) || assignment.Revision != first.Revision {
return fmt.Errorf("mixed assignment batch")
}
if _, ok := seen[assignment.PlayerID]; ok {
return fmt.Errorf("duplicate assignment in batch")
}
seen[key] = struct{}{}
if _, ok := seenSlots[assignment.Slot]; ok {
return fmt.Errorf("duplicate assignment slot in batch")
}
seen[assignment.PlayerID] = struct{}{}
seenSlots[assignment.Slot] = struct{}{}
}
return nil
}
func saveAssignmentsTx(ctx context.Context, tx *sql.Tx, assignments []DurableAssignment) error {
for _, assignment := range assignments {
result, err := tx.ExecContext(ctx, AssignmentUpsertSQL, assignment.MatchID, assignment.PlayerID, assignment.AllocationID, assignment.ServerID, assignment.Slot, assignment.Region, assignment.ClientBuild, assignment.ProtocolVersion, assignment.Transport, assignment.Endpoint, assignment.JoinAuthorisation, assignment.ManifestDigest, assignment.ExpiresAt, assignment.Revision)
if err != nil {
return err
@@ -139,7 +172,7 @@ func SaveAssignments(ctx context.Context, db *sql.DB, assignments []DurableAssig
return fmt.Errorf("assignment persistence conflict")
}
}
return tx.Commit()
return nil
}
// SaveVerifiedAssignmentRoster converts the backend-verified signed roster to
@@ -179,7 +212,59 @@ func SaveVerifiedAssignmentRoster(ctx context.Context, db *sql.DB, assignment do
ManifestDigest: digest[:], ExpiresAt: auth.ExpiresAt, Revision: 1,
})
}
return SaveAssignments(ctx, db, rows)
if db == nil {
return fmt.Errorf("invalid assignment database")
}
if err := validateAssignmentBatch(rows); err != nil {
return err
}
return RunSerializable(ctx, db, DefaultSerializableAttempts, func(ctx context.Context, tx *sql.Tx) error {
if err := validateExpectedAssignmentRoster(ctx, tx, assignment, roster); err != nil {
return err
}
return saveAssignmentsTx(ctx, tx, rows)
})
}
func validateExpectedAssignmentRoster(ctx context.Context, tx *sql.Tx, assignment domain.Assignment, roster []domain.SignedJoinAuthorisation) error {
rows, err := tx.QueryContext(ctx, AssignmentExpectedRosterSQL,
assignment.Allocation.MatchID, assignment.Allocation.AllocationID, assignment.Allocation.ServerID,
assignment.Allocation.Region, assignment.Allocation.Protocol, assignment.Allocation.Build, assignment.Allocation.Transport)
if err != nil {
return err
}
defer rows.Close()
type expectedPlayer struct {
steamID string
slot int
team int
}
expected := make(map[string]expectedPlayer, len(roster))
for rows.Next() {
var playerID string
var player expectedPlayer
if err := rows.Scan(&playerID, &player.steamID, &player.slot, &player.team); err != nil {
return err
}
expected[playerID] = player
}
if err := rows.Err(); err != nil {
return err
}
if err := rows.Close(); err != nil {
return err
}
if len(expected) == 0 || len(expected) != len(roster) {
return fmt.Errorf("signed assignment roster is incomplete")
}
for _, signed := range roster {
auth := signed.Authorisation
player, ok := expected[auth.PlayerID]
if !ok || player.steamID != auth.SteamID || player.slot != auth.Slot || player.team != auth.Team {
return fmt.Errorf("signed assignment roster does not match durable participants")
}
}
return nil
}
func validateSignedRosterEntry(assignment domain.Assignment, signed domain.SignedJoinAuthorisation, verify func([]byte, []byte) bool) error {