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