Files
Josh Creek b8bcc1f3c1 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.
2026-09-05 10:36:06 +01:00

230 lines
16 KiB
GDScript

extends "res://tests/test_case.gd"
const MatchNet = preload("res://scripts/match_net.gd")
# Adversarial-review regression: _hello's player_name used to be broadcast
# to every peer completely unvalidated — a multi-MB name head-of-line-
# blocked the reliable control channel hard enough that a concurrently-
# joining client's own _welcome never arrived. _sanitize_player_name() is
# the fix; these are pure-function tests for it, independent of the live
# two-process rejection test in tests/match_net_smoke.gd (--role=client-longname).
func test_normal_name_unchanged() -> void:
assert_eq(MatchNet._sanitize_player_name("Alice"), "Alice", "a normal name passes through unchanged")
func test_strips_control_characters() -> void:
var bell := String.chr(7) # a control char with no named GDScript escape
var raw := "Bad\nName\twith\rcontrol" + bell + "chars"
var clean := MatchNet._sanitize_player_name(raw)
assert_true(not clean.contains("\n"), "no newline")
assert_true(not clean.contains("\t"), "no tab")
assert_true(not clean.contains("\r"), "no carriage return")
assert_true(not clean.contains(bell), "no bell/control char")
func test_clamps_to_max_display_length() -> void:
var raw := "X".repeat(1000)
var clean := MatchNet._sanitize_player_name(raw)
assert_eq(clean.length(), MatchNet.MAX_PLAYER_NAME_LENGTH, "clamped to MAX_PLAYER_NAME_LENGTH")
func test_empty_or_whitespace_only_falls_back_to_default() -> void:
assert_eq(MatchNet._sanitize_player_name(""), "Player", "empty string falls back")
assert_eq(MatchNet._sanitize_player_name(" "), "Player", "whitespace-only falls back")
assert_eq(MatchNet._sanitize_player_name("\n\t\r"), "Player", "control-characters-only falls back")
assert_eq(MatchNet._sanitize_shutdown_reason("\n maintenance \t"), "maintenance", "shutdown reason strips controls")
assert_eq(MatchNet._sanitize_shutdown_reason(""), "server_shutdown", "empty shutdown reason gets a safe fallback")
func test_leading_trailing_whitespace_trimmed() -> void:
assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed")
func test_server_shutdown_message_is_bounded_and_emitted() -> void:
var instance = MatchNet.new()
var received := [""]
var callback := func(reason: String) -> void: received[0] = reason
instance.server_shutdown.connect(callback)
instance._server_shutdown(" planned maintenance " + "x".repeat(200))
instance.server_shutdown.disconnect(callback)
assert_eq(received[0].length(), 96, "shutdown reason is bounded before presentation")
assert_eq(instance.last_server_shutdown_reason.length(), 96, "bounded shutdown reason is retained for UI")
func test_drain_fences_new_hello_admissions() -> void:
assert_eq(MatchNet.admission_rejection(true), "", "an active server accepts new hello requests")
assert_eq(MatchNet.admission_rejection(false), "server is draining", "a draining server rejects new hello requests")
func test_draining_disconnect_still_releases_roster_and_join_token() -> void:
var match_net := MatchNet.new()
var token := "opaque-join-token"
match_net.admissions_open = false
match_net.roster[42] = MatchNet.PlayerInfo.new(42, "Alice", 0, false, "player-1")
match_net._active_join_peers[token] = 42
match_net._join_history[token] = {"generation": 1}
match_net._cleanup_disconnected_peer(42)
assert_true(not match_net.roster.has(42), "drain does not retain a disconnected roster entry")
assert_true(not match_net._active_join_peers.has(token), "drain releases the disconnected peer's join token")
assert_true(float(match_net._join_history[token].get("lost_at", 0.0)) > 0.0, "disconnect records the reclaim boundary during drain")
func test_signed_assignment_locks_team_and_spawn_slot_together() -> void:
var match_net := MatchNet.new()
var info := MatchNet.PlayerInfo.new(42, "Alice", 0, true, "player-1")
info.spawn_index = 2
match_net.roster[42] = info
match_net.require_join_authorisation = true
assert_true(not match_net._apply_team_change(42, 1), "allocated clients cannot override their signed team")
assert_eq(info.team, 0, "signed team is unchanged")
assert_eq(info.spawn_index, 2, "signed spawn index remains paired with its team")
assert_true(info.ready, "rejected mutation does not alter readiness")
match_net.require_join_authorisation = false
assert_true(match_net._apply_team_change(42, 1), "direct lobbies retain team switching")
assert_eq(info.team, 1, "direct team switch applies")
assert_true(not info.ready, "direct team switch still clears readiness")
func test_reservation_reclaim_requires_stable_identity() -> void:
assert_true(MatchNet.reservation_identity_matches("player-a", "player-a", "Alice", "Impostor"), "the verified identity can reclaim despite a changed display name")
assert_true(not MatchNet.reservation_identity_matches("player-a", "player-b", "Alice", "Alice"), "a same-name peer cannot reclaim another identity's slot")
assert_true(not MatchNet.reservation_identity_matches("player-a", "", "Alice", "Alice"), "an unauthenticated peer cannot reclaim an allocated slot")
assert_true(MatchNet.reservation_identity_matches("", "", "Alice", "Alice"), "direct servers retain the legacy display-name fallback")
func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 5, "Team": 1, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
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}), "valid roster configures")
var assigned := match_net.assigned_player_slots()
assert_eq(assigned.size(), 1, "configured roster exposes one assigned player")
assert_eq(assigned[0]["player_identity"], "player-1", "assigned roster preserves player identity")
assert_eq(assigned[0]["team"], 1, "assigned roster preserves team")
assert_eq(assigned[0]["slot"], 5, "assigned roster preserves slot")
assert_true(match_net._valid_join_authorisation(token), "allowlisted matching token is accepted")
var malformed_claims := claims.duplicate()
malformed_claims["ExpiresAt"] = "tomorrow"
var malformed_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": malformed_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(malformed_token), "malformed expiry claim is rejected before admission")
var string_slot_claims := claims.duplicate()
string_slot_claims["Slot"] = "5"
var string_slot_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": string_slot_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(string_slot_token), "string slot claim is rejected instead of coerced")
assert_true(not match_net._valid_join_authorisation(token + "tampered"), "token mutation is rejected")
var wrong_claims := claims.duplicate()
wrong_claims["ServerID"] = "other-server"
var wrong_token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": wrong_claims, "Signature": "trusted-signature"}).to_utf8_buffer())
assert_true(not match_net._valid_join_authorisation(wrong_token), "wrong server claim is rejected")
assert_eq(match_net._reserve_join_authorisation(token, 42), 1, "first admission receives generation one")
assert_true(match_net.is_join_authorisation_active(token), "admitted token is active")
assert_eq(match_net._reserve_join_authorisation(token, 43), -1, "active token cannot be admitted concurrently")
match_net._remove_player(42)
assert_true(not match_net.is_join_authorisation_active(token), "disconnect releases active token")
assert_eq(match_net._reserve_join_authorisation(token, 43), 2, "reclaim receives the next server-owned generation")
match_net._remove_player(43)
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() - MatchNet.RECONNECT_GRACE_SECONDS - 1.0
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "reclaim after the grace window is fenced")
match_net._join_history[token]["lost_at"] = Time.get_unix_time_from_system() + 60.0
assert_eq(match_net._reserve_join_authorisation(token, 44), -1, "clock-reversed reclaim is fenced")
var malformed_context := {"match_id": 123, "server_id": "server-1", "protocol": "1", "protocol_version": 1}
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "numeric context identity is rejected")
malformed_context = {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1.5}
assert_true(not match_net.configure_join_authorisations([token], malformed_context), "fractional context protocol is rejected")
func test_allocated_join_authorisation_rejects_inconsistent_team_and_slot() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 3, "Team": 0, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var token := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "trusted-signature"}).to_utf8_buffer())
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}), "fixture configures")
assert_true(not match_net._valid_join_authorisation(token), "a slot assigned to team 1 cannot claim team 0")
func test_assigned_roster_rejects_duplicate_identity_or_slot_shape() -> void:
var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
"SteamID": "steam-1", "Slot": 0, "Team": 0, "Protocol": "1",
"Generation": 1, "ExpiresAt": "2099-08-31T12:00:00Z",
}
var first := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "one"}).to_utf8_buffer())
var duplicate := Marshalls.raw_to_base64(JSON.stringify({"Authorisation": claims, "Signature": "two"}).to_utf8_buffer())
var match_net := MatchNet.new()
assert_true(match_net.configure_join_authorisations([first, duplicate], {"match_id": "match-1", "server_id": "server-1", "protocol": "1", "protocol_version": 1}), "duplicate fixture configures for structural inspection")
assert_eq(match_net.assigned_player_slots().size(), 0, "duplicate identity/slot roster fails closed")
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.
# 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}, {"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}, {"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")