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.
This commit is contained in:
Josh Creek
2026-09-05 10:36:06 +01:00
parent 5453e19761
commit b8bcc1f3c1
6 changed files with 135 additions and 18 deletions
+31 -7
View File
@@ -67,7 +67,11 @@ var _allowed_join_authorisations: Dictionary = {}
var _active_join_peers: Dictionary = {} # opaque authorisation -> peer_id
var _join_history: Dictionary = {} # token -> {generation, lost_at}
var _join_authorisation_context: Dictionary = {}
var _join_signing_key := PackedByteArray()
# Key ID -> raw HMAC key. A set rather than a single key so a signing-key
# rotation does not invalidate authorisations already issued for in-flight
# matches: the allocator signs with the new key while servers still accept
# both, and the old key is dropped once no live match can reference it.
var _join_signing_keys := {}
var _connection_lease_claim := Callable()
var _connection_lease_disconnect := Callable()
var _result_submit := Callable()
@@ -109,7 +113,7 @@ func _on_shutting_down() -> void:
_active_join_peers.clear()
_join_history.clear()
_join_authorisation_context.clear()
_join_signing_key = PackedByteArray()
_join_signing_keys = {}
_connection_lease_claim = Callable()
_connection_lease_disconnect = Callable()
_result_submit = Callable()
@@ -117,7 +121,9 @@ func _on_shutting_down() -> void:
admissions_open = true
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_key: PackedByteArray = PackedByteArray()) -> bool:
# signing_keys maps key ID to raw key bytes. An empty dictionary disables
# signature verification, which is only valid for local/direct-hosted play.
func configure_join_authorisations(tokens: Array, context: Dictionary, signing_keys: Dictionary = {}) -> bool:
var allowed := {}
for token in tokens:
if not token is String or String(token).is_empty():
@@ -127,7 +133,12 @@ func configure_join_authorisations(tokens: Array, context: Dictionary, signing_k
return false
_allowed_join_authorisations = allowed
_join_authorisation_context = context.duplicate(true)
_join_signing_key = signing_key.duplicate()
_join_signing_keys = {}
for key_id in signing_keys:
var raw = signing_keys[key_id]
if not raw is PackedByteArray or PackedByteArray(raw).is_empty():
return false
_join_signing_keys[str(key_id)] = PackedByteArray(raw).duplicate()
require_join_authorisation = true
return true
@@ -372,24 +383,37 @@ func _valid_join_authorisation(token: String) -> bool:
if not AssignmentState.is_valid_expiry_timestamp(expires_at):
return false
var expiry := Time.get_unix_time_from_datetime_string(expires_at)
if not _join_signing_key.is_empty():
if not _join_signing_keys.is_empty():
var signature_token := str(envelope["Signature"])
var signature := Marshalls.base64_to_raw(signature_token)
if signature.size() != 32:
return false
# The key ID selects which of the currently-valid keys signed this
# authorisation, so the allocator can rotate without invalidating
# authorisations already issued for in-flight matches. It is part of
# the signed bytes below, so pointing it at a different key simply
# fails verification rather than choosing a weaker key.
var key_id := str(claims.get("KeyID", ""))
if not _join_signing_keys.has(key_id):
return false
var signing_key: PackedByteArray = _join_signing_keys[key_id]
if signing_key.is_empty():
return false
var canonical := PackedByteArray()
# Must stay byte-identical to server/domain/join_auth.go's
# JoinAuthorisationBytes; the two change together or every join fails.
var fields := [
str(claims.get("MatchID", "")), str(claims.get("ServerID", "")),
str(claims.get("PlayerID", "")), str(claims.get("SteamID", "")),
str(int(claims.get("Slot", -1))), str(int(claims.get("Team", -1))), protocol,
str(int(claims.get("Generation", 0))), expires_at,
str(int(claims.get("Generation", 0))), expires_at, key_id,
]
for index in fields.size():
canonical.append_array(String(fields[index]).to_utf8_buffer())
if index < fields.size() - 1:
canonical.append(0)
var hmac := HMACContext.new()
hmac.start(HashingContext.HASH_SHA256, _join_signing_key)
hmac.start(HashingContext.HASH_SHA256, signing_key)
hmac.update(canonical)
if hmac.finish() != signature:
return false
+30 -3
View File
@@ -77,14 +77,14 @@ func _ready() -> void:
var roster_file := String(config.get_value("join-authorisations-file"))
var key_file := String(config.get_value("join-authorisations-key-file"))
var roster_json := FileAccess.get_file_as_string(roster_file)
var signing_key := FileAccess.get_file_as_bytes(key_file)
var signing_keys := _load_join_signing_keys(key_file)
var roster_tokens = JSON.parse_string(roster_json)
if not roster_tokens is Array or roster_tokens.is_empty() or signing_key.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
if not roster_tokens is Array or roster_tokens.is_empty() or signing_keys.is_empty() or not MatchNet.configure_join_authorisations(roster_tokens, {
"match_id": String(config.get_value("match-id")),
"server_id": String(config.get_value("server-id")),
"protocol": str(NetCodec.PROTOCOL_VERSION),
"protocol_version": NetCodec.PROTOCOL_VERSION,
}, signing_key) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
}, signing_keys) or MatchNet.assigned_player_slots().size() != roster_tokens.size():
printerr("cosmic-clash-server: refusing to start with invalid join-authorisations-file")
get_tree().quit(1)
return
@@ -253,3 +253,30 @@ static func required_min_players(allocated: bool, roster_size: int, configured:
if allocated and roster_size > 0:
return roster_size
return configured
# The join-signing key file maps key ID -> base64 raw key, so the allocator can
# rotate the signing key without invalidating authorisations already issued for
# in-flight matches: a rotation publishes the new key alongside the old, and the
# old one is dropped only once no live match can still reference it.
#
# A file containing raw key bytes (no JSON object) is accepted as a single key
# under the empty ID, which is what an unrotated deployment and the local smoke
# fixtures use.
static func _load_join_signing_keys(key_file: String) -> Dictionary:
var raw := FileAccess.get_file_as_bytes(key_file)
if raw.is_empty():
return {}
var parsed = JSON.parse_string(raw.get_string_from_utf8())
if not parsed is Dictionary or (parsed as Dictionary).is_empty():
return {"": raw}
var keys := {}
for key_id in parsed:
var encoded = parsed[key_id]
if not encoded is String or String(encoded).is_empty():
return {}
var decoded := Marshalls.base64_to_raw(String(encoded))
if decoded.is_empty():
return {}
keys[str(key_id)] = decoded
return keys
+52 -3
View File
@@ -168,13 +168,62 @@ func test_allocated_join_authorisation_verifies_canonical_hmac() -> void:
# This envelope is generated from server/domain.JoinAuthorisationBytes with
# HMAC-SHA256(test-key), proving the Godot verifier agrees with the Go
# canonical representation rather than merely checking token membership.
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIn0sIlNpZ25hdHVyZSI6Ijk0QkFOWjJpMkJUWHNWOVdaSWQ1dnE1Q3FqUXF4eGFXNnB4c2U0SFRXSDg9In0="
# Regenerate it whenever JoinAuthorisationBytes changes; a stale token here
# is exactly how a silent cross-language format drift would be caught.
var token := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, "test-key".to_utf8_buffer()), "HMAC roster configures")
assert_true(match_net.configure_join_authorisations([token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "HMAC roster configures")
assert_true(match_net._valid_join_authorisation(token), "Go-compatible canonical HMAC is accepted")
var tampered_payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(token).get_string_from_utf8())
tampered_payload["Signature"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
var tampered_token := Marshalls.raw_to_base64(JSON.stringify(tampered_payload).to_utf8_buffer())
var tampered_match_net := MatchNet.new()
assert_true(tampered_match_net.configure_join_authorisations([tampered_token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, "test-key".to_utf8_buffer()), "tampered roster fixture configures")
assert_true(tampered_match_net.configure_join_authorisations([tampered_token], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}, {"key-2026-09": "test-key".to_utf8_buffer()}), "tampered roster fixture configures")
assert_true(not tampered_match_net._valid_join_authorisation(tampered_token), "allowlisted but forged signature is rejected")
# Rotation contract: the allocator signs with one key while allocated servers
# accept the set of currently-valid keys, so rotating does not invalidate
# authorisations already issued for in-flight matches. All three envelopes are
# generated from server/domain.JoinAuthorisationBytes.
const ROTATION_CONTEXT := {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}
const TOKEN_SIGNED_WITH_OLD_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOCJ9LCJTaWduYXR1cmUiOiI5TW42eldERGNwR1pmblY2NXdreXNCYTduUnk3OG1QQkZPT29JN2F1UkdJPSJ9"
const TOKEN_SIGNED_WITH_NEW_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNi0wOSJ9LCJTaWduYXR1cmUiOiJ4bWw1a09qdjltVURab256bHVFcitCM2wyQ0c4THBOUisxd0tpV1VkMjFrPSJ9"
const TOKEN_SIGNED_WITH_RETIRED_KEY := "eyJBdXRob3Jpc2F0aW9uIjp7Ik1hdGNoSUQiOiJtYXRjaC0xIiwiU2VydmVySUQiOiJzZXJ2ZXItMSIsIlBsYXllcklEIjoicGxheWVyLTEiLCJTdGVhbUlEIjoic3RlYW0tMSIsIlNsb3QiOjUsIlRlYW0iOjEsIlByb3RvY29sIjoiMSIsIkdlbmVyYXRpb24iOjEsIkV4cGlyZXNBdCI6IjIwOTktMDgtMzFUMTI6MDA6MDBaIiwiS2V5SUQiOiJrZXktMjAyNy0wMSJ9LCJTaWduYXR1cmUiOiJRemFYLzB5T0pObE1oRXRPZ1BBcUpRNGJueHZRb1BVU09CR0p2Mm9nQVdnPSJ9"
func test_join_authorisation_accepts_every_key_in_the_rotation_set() -> void:
# Mid-rotation: both keys are published, so authorisations issued before
# and after the switch must both still admit their player.
var keys := {
"key-2026-08": "old-key".to_utf8_buffer(),
"key-2026-09": "test-key".to_utf8_buffer(),
}
for token in [TOKEN_SIGNED_WITH_OLD_KEY, TOKEN_SIGNED_WITH_NEW_KEY]:
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([token], ROTATION_CONTEXT, keys), "rotation fixture configures")
assert_true(match_net._valid_join_authorisation(token), "a token signed by any currently-valid key is accepted")
func test_join_authorisation_rejects_a_key_id_outside_the_set() -> void:
# Rotation completed: the retired key is dropped, so anything still signed
# with it must stop being admitted.
var keys := {"key-2026-09": "test-key".to_utf8_buffer()}
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([TOKEN_SIGNED_WITH_RETIRED_KEY], ROTATION_CONTEXT, keys), "retired-key fixture configures")
assert_true(not match_net._valid_join_authorisation(TOKEN_SIGNED_WITH_RETIRED_KEY), "a token naming a key outside the set is rejected")
func test_join_authorisation_key_id_cannot_be_repointed_at_another_key() -> void:
# KeyID is inside the signed bytes, so swapping it to name a key the server
# does hold must fail verification rather than selecting that key.
var payload: Dictionary = JSON.parse_string(Marshalls.base64_to_raw(TOKEN_SIGNED_WITH_OLD_KEY).get_string_from_utf8())
payload["Authorisation"]["KeyID"] = "key-2026-09"
var repointed := Marshalls.raw_to_base64(JSON.stringify(payload).to_utf8_buffer())
var keys := {
"key-2026-08": "old-key".to_utf8_buffer(),
"key-2026-09": "test-key".to_utf8_buffer(),
}
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([repointed], ROTATION_CONTEXT, keys), "repointed fixture configures")
assert_true(not match_net._valid_join_authorisation(repointed), "the key ID is covered by the signature")
+7 -3
View File
@@ -31,12 +31,16 @@ import base64, hashlib, hmac, json, pathlib, sys, time
directory = pathlib.Path(sys.argv[1])
key = b"compose-join-signing-key"
key_id = "compose-key-1"
expires = "2099-12-31T00:00:00Z"
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires]
# Field order and the trailing key ID must match
# server/domain.JoinAuthorisationBytes and Game/scripts/match_net.gd.
fields = ["compose-match-0001", "compose-server-0001", "compose-player", "compose-steam", "0", "0", "v1", "1", expires, key_id]
canonical = b"\0".join(field.encode() for field in fields)
signature = base64.urlsafe_b64encode(hmac.new(key, canonical, hashlib.sha256).digest()).rstrip(b"=").decode()
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires}, "Signature": signature}
(directory / "join-signing-key").write_bytes(key)
envelope = {"Authorisation": {"MatchID": fields[0], "ServerID": fields[1], "PlayerID": fields[2], "SteamID": fields[3], "Slot": 0, "Team": 0, "Protocol": fields[6], "Generation": 1, "ExpiresAt": expires, "KeyID": key_id}, "Signature": signature}
# The key file maps key ID -> base64 key so a rotation can publish several.
(directory / "join-signing-key").write_text(json.dumps({key_id: base64.b64encode(key).decode()}) + "\n")
(directory / "join-roster.json").write_text(json.dumps([base64.urlsafe_b64encode(json.dumps(envelope, separators=(",", ":")).encode()).rstrip(b"=").decode()]) + "\n")
PY
+9 -2
View File
@@ -15,9 +15,14 @@ type SignedJoinAuthorisation struct {
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",
auth.MatchID, auth.ServerID, auth.PlayerID, auth.SteamID, auth.Slot, auth.Team, auth.Protocol, auth.Generation, auth.ExpiresAt.UTC().Format(time.RFC3339Nano)))
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) {
@@ -34,6 +39,8 @@ func SignJoinAuthorisation(auth JoinAuthorisation, sign func([]byte) ([]byte, er
// 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
+6
View File
@@ -35,6 +35,12 @@ type JoinAuthorisation struct {
Protocol string
Generation uint64
ExpiresAt time.Time
// KeyID names the signing key so the allocator can rotate without
// invalidating authorisations already issued for in-flight matches: the
// game server holds a set of currently-valid keys and selects by this ID.
// It is part of the signed bytes, so it cannot be swapped to point at a
// different key than the one that actually signed.
KeyID string
}
type rankedConnection struct {