mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-11 08:23:45 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user