diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index 4e429b26..604ded59 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -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 diff --git a/Game/scripts/server_boot.gd b/Game/scripts/server_boot.gd index 6cf23e41..41deca3a 100644 --- a/Game/scripts/server_boot.gd +++ b/Game/scripts/server_boot.gd @@ -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 diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index 527f374a..83291f37 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -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") diff --git a/scripts/verify_allocated_compose.sh b/scripts/verify_allocated_compose.sh index c0269baf..0574192f 100755 --- a/scripts/verify_allocated_compose.sh +++ b/scripts/verify_allocated_compose.sh @@ -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 diff --git a/server/domain/join_auth.go b/server/domain/join_auth.go index a749ed28..65c2e1b4 100644 --- a/server/domain/join_auth.go +++ b/server/domain/join_auth.go @@ -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 diff --git a/server/domain/reconnect.go b/server/domain/reconnect.go index 771b8f74..4b4f1566 100644 --- a/server/domain/reconnect.go +++ b/server/domain/reconnect.go @@ -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 {