mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
5765532409
The root blocker (issue #14). The worker bound the provider allocation and stopped. Service.PublishRoster and store.SaveVerifiedAssignmentRoster both existed, fully tested, with zero non-test callers, and the production allocator configured neither a roster store nor a signing key. Nothing ever wrote the assignments table. The allocated supervisor fetches a non-empty roster before it launches the game child, so every real allocation failed at that fetch: no match could reach ASSIGNMENT_READY or accept a player. Existing tests seeded assignments directly, which is exactly why the missing hand-off went unnoticed. The worker now builds one join authorisation per durable participant, signs each with the active key, and publishes them. Participants are read through the same query SaveVerifiedAssignmentRoster re-validates against, so the allocator cannot construct a roster the persistence boundary would reject. The manifest commits to a digest over the whole roster, so a server cannot be handed a truncated roster whose surviving entries are each individually valid. Persist the provider endpoint on the allocation: it arrived on the provider response and was never stored, so a worker crashing between allocating and publishing had no endpoint to recover and would have stranded the match permanently. Republishing is idempotent, so that crash now simply retries. cmd/allocator refuses to start without key material rather than running an allocator that binds allocations and silently strands every match. The k8s allocator Deployment mounts the same key set the Fleet does, and both now take the JSON key map so a rotation can publish several. New integration test drives the real worker through to the supervisor's own roster read path without seeding the assignments table. Verified it fails with "assignments = 0, want 2" when the publish step is removed.
113 lines
4.3 KiB
Go
113 lines
4.3 KiB
Go
package domain
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// SignedJoinAuthorisation is the transport envelope. The signing primitive is
|
|
// supplied by the backend signer so this policy stays independent of key
|
|
// storage and cryptographic algorithm choice.
|
|
type SignedJoinAuthorisation struct {
|
|
Authorisation JoinAuthorisation
|
|
Signature []byte
|
|
}
|
|
|
|
// JoinAuthorisationBytes is the canonical claim encoding. KeyID is appended
|
|
// last and is covered by the signature, so an attacker cannot redirect an
|
|
// authorisation at a different key than the one that signed it. Game/scripts/
|
|
// match_net.gd builds the identical byte sequence; the two must change
|
|
// together.
|
|
func JoinAuthorisationBytes(auth JoinAuthorisation) []byte {
|
|
return []byte(fmt.Sprintf("%s\x00%s\x00%s\x00%s\x00%d\x00%d\x00%s\x00%d\x00%s\x00%s",
|
|
auth.MatchID, auth.ServerID, auth.PlayerID, auth.SteamID, auth.Slot, auth.Team, auth.Protocol, auth.Generation, auth.ExpiresAt.UTC().Format(time.RFC3339Nano), auth.KeyID))
|
|
}
|
|
|
|
func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, error)) (SignedJoinAuthorisation, error) {
|
|
if sign == nil {
|
|
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
|
|
}
|
|
signature, err := sign(JoinAuthorisationBytes(auth))
|
|
if err != nil || len(signature) == 0 {
|
|
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
|
|
}
|
|
return SignedJoinAuthorisation{Authorisation: auth, Signature: append([]byte(nil), signature...)}, nil
|
|
}
|
|
|
|
// SignJoinAuthorisationHMAC is the interoperable production profile used by
|
|
// the Godot allocated server. The key is mounted out-of-band; the signed
|
|
// bytes remain the same canonical claim bytes used by the generic signer.
|
|
// The caller must have set auth.KeyID to the ID of this key, so the verifier
|
|
// can pick the right one out of its key set.
|
|
func SignJoinAuthorisationHMAC(auth JoinAuthorisation, key []byte) (SignedJoinAuthorisation, error) {
|
|
if len(key) == 0 {
|
|
return SignedJoinAuthorisation{}, ErrJoinAuthorisation
|
|
}
|
|
mac := hmac.New(sha256.New, key)
|
|
_, _ = mac.Write(JoinAuthorisationBytes(auth))
|
|
return SignedJoinAuthorisation{Authorisation: auth, Signature: mac.Sum(nil)}, nil
|
|
}
|
|
|
|
func (r *RankedConnections) AdmitSigned(signed SignedJoinAuthorisation, verify func([]byte, []byte) bool, now time.Time) (uint64, error) {
|
|
if len(signed.Signature) == 0 || verify == nil || !verify(JoinAuthorisationBytes(signed.Authorisation), signed.Signature) {
|
|
return 0, ErrJoinAuthorisation
|
|
}
|
|
return r.Admit(signed.Authorisation, now)
|
|
}
|
|
|
|
// AssignmentRosterDigest binds a manifest to the exact roster it was issued
|
|
// with. Signing each authorisation individually proves each claim, but the
|
|
// manifest also has to commit to the set, so a server cannot be handed a
|
|
// truncated roster whose entries are each individually valid.
|
|
//
|
|
// Entries are hashed in slot order so the digest is independent of the order
|
|
// the caller happened to build them in.
|
|
func AssignmentRosterDigest(roster []SignedJoinAuthorisation) (string, error) {
|
|
if len(roster) == 0 {
|
|
return "", ErrJoinAuthorisation
|
|
}
|
|
ordered := make([]SignedJoinAuthorisation, len(roster))
|
|
copy(ordered, roster)
|
|
sort.Slice(ordered, func(i, j int) bool {
|
|
return ordered[i].Authorisation.Slot < ordered[j].Authorisation.Slot
|
|
})
|
|
digest := sha256.New()
|
|
for _, signed := range ordered {
|
|
if signed.Authorisation.PlayerID == "" {
|
|
return "", ErrJoinAuthorisation
|
|
}
|
|
digest.Write(JoinAuthorisationBytes(signed.Authorisation))
|
|
digest.Write([]byte{0})
|
|
}
|
|
return hex.EncodeToString(digest.Sum(nil)), nil
|
|
}
|
|
|
|
// VerifyJoinAuthorisationHMAC builds the verifier the persistence boundary
|
|
// re-checks each signature with, selecting the key named by the claim. Keys is
|
|
// key ID to raw key; an unknown ID verifies as false rather than falling back
|
|
// to any other key.
|
|
func VerifyJoinAuthorisationHMAC(keys map[string][]byte) func([]byte, []byte) bool {
|
|
return func(claims, signature []byte) bool {
|
|
if len(keys) == 0 || len(claims) == 0 || len(signature) == 0 {
|
|
return false
|
|
}
|
|
// The key ID is the last NUL-separated field of the canonical bytes.
|
|
separator := bytes.LastIndexByte(claims, 0)
|
|
if separator < 0 {
|
|
return false
|
|
}
|
|
key, known := keys[string(claims[separator+1:])]
|
|
if !known || len(key) == 0 {
|
|
return false
|
|
}
|
|
mac := hmac.New(sha256.New, key)
|
|
mac.Write(claims)
|
|
return hmac.Equal(mac.Sum(nil), signature)
|
|
}
|
|
}
|