Files
CosmicClash/server/domain/join_auth.go
T
Josh Creek b8bcc1f3c1 feat(join-auth): add key-ID rotation to signed join authorisations
Prerequisite for wiring the allocator to publish rosters. The signing
key is a shared HMAC secret mounted into both the allocator and the
allocated game server; without a key ID, rotating it would invalidate
every authorisation already issued for an in-flight match, because a
server holding only the new key cannot verify a token signed with the
old one.

Add KeyID to JoinAuthorisation and append it to the canonical claim
bytes, so it is covered by the signature and cannot be repointed at a
different key than the one that actually signed. Allocated servers now
hold a set of currently-valid keys and select by ID: a rotation
publishes the new key alongside the old, and the old is dropped once no
live match can still reference it.

The key file becomes a JSON map of key ID to base64 key. A file of raw
key bytes is still accepted as a single key under the empty ID, which is
what an unrotated deployment and the kind fixture use.

Game/scripts/match_net.gd builds the canonical bytes independently, so
it changes in lockstep; the cross-language golden token in
test_match_net.gd is regenerated from the Go implementation and now
carries a key ID. Added tests cover accepting either key mid-rotation,
rejecting a retired key ID, and rejecting a token whose key ID was
swapped to name a key the server does hold.

Go suite and 223 Godot tests pass.
2026-09-05 10:36:06 +01:00

59 lines
2.5 KiB
Go

package domain
import (
"crypto/hmac"
"crypto/sha256"
"fmt"
"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)
}