package allocator import ( "context" "fmt" "strconv" "time" "github.com/cosmic-clash/cosmic-clash/server/domain" ) // JoinAuthorisationLifetime bounds how long an issued authorisation may be // replayed. It must outlive the initial-connect window (a player still loading // must be able to join) without leaving a usable credential lying around after // the match it belongs to is over. const JoinAuthorisationLifetime = 30 * time.Minute // AssignmentRosterSource reads the authoritative participants of an allocated // match. It is deliberately the same query the persistence boundary // re-validates against, so the allocator cannot construct a roster that // disagrees with the durable match_participants rows. type AssignmentRosterSource interface { LoadAssignmentParticipants(context.Context, domain.Allocation) ([]domain.AssignmentParticipant, error) } // JoinSigningKeys is the allocator's key material. ActiveKeyID names the key // new authorisations are signed with; Keys holds every currently-valid key so // verification (including the re-check at the persistence boundary) still // accepts authorisations issued before a rotation. type JoinSigningKeys struct { ActiveKeyID string Keys map[string][]byte } func (k JoinSigningKeys) validate() error { if k.ActiveKeyID == "" || len(k.Keys) == 0 { return fmt.Errorf("join signing keys are not configured") } if len(k.Keys[k.ActiveKeyID]) == 0 { return fmt.Errorf("active join signing key %q is not present in the key set", k.ActiveKeyID) } return nil } // BuildSignedRoster turns the durable participants into one signed join // authorisation each, plus the manifest that commits to the whole set. // // Signing each entry proves each individual claim; the manifest's roster // digest additionally commits to the set, so a server cannot be handed a // truncated roster whose surviving entries are each individually valid. func BuildSignedRoster(allocation domain.Allocation, participants []domain.AssignmentParticipant, keys JoinSigningKeys, now time.Time) (domain.Assignment, []domain.SignedJoinAuthorisation, error) { if err := keys.validate(); err != nil { return domain.Assignment{}, nil, err } if allocation.State != domain.ServerAllocated || allocation.Endpoint == "" || len(participants) == 0 || now.IsZero() { return domain.Assignment{}, nil, domain.ErrManifestRejected } active := keys.Keys[keys.ActiveKeyID] roster := make([]domain.SignedJoinAuthorisation, 0, len(participants)) for _, participant := range participants { signed, err := domain.SignJoinAuthorisationHMAC(domain.JoinAuthorisation{ MatchID: allocation.MatchID, ServerID: allocation.ServerID, PlayerID: participant.PlayerID, SteamID: participant.SteamID, Slot: participant.Slot, Team: participant.Team, Protocol: strconv.Itoa(allocation.Protocol), // Generation 1 is the first connection lease. Reconnects fence by // advancing the durable generation, not by reissuing this token. Generation: 1, ExpiresAt: now.Add(JoinAuthorisationLifetime).UTC(), KeyID: keys.ActiveKeyID, }, active) if err != nil { return domain.Assignment{}, nil, fmt.Errorf("sign join authorisation for %s: %w", participant.PlayerID, err) } roster = append(roster, signed) } rosterDigest, err := domain.AssignmentRosterDigest(roster) if err != nil { return domain.Assignment{}, nil, err } assignment := domain.Assignment{ Allocation: allocation, Endpoint: allocation.Endpoint, Manifest: domain.AllocationManifest{ AllocationID: allocation.AllocationID, MatchID: allocation.MatchID, ServerID: allocation.ServerID, Region: allocation.Region, Build: allocation.Build, Protocol: allocation.Protocol, Transport: allocation.Transport, RosterDigest: rosterDigest, }, } return assignment, roster, nil }