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
+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