fix(multiplayer): reclaim slots by signed identity

This commit is contained in:
Josh Creek
2026-09-01 16:08:01 +01:00
parent bdb3c8f4a7
commit b4ea50d76a
4 changed files with 56 additions and 7 deletions
+39 -2
View File
@@ -37,12 +37,14 @@ const MAX_PLAYER_NAME_LENGTH := 24
class PlayerInfo: class PlayerInfo:
var peer_id: int var peer_id: int
var player_name: String var player_name: String
var player_identity: String
var team: int = 0 var team: int = 0
var ready: bool = false var ready: bool = false
func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false) -> void: func _init(p_peer_id: int, p_player_name: String, p_team: int = 0, p_ready: bool = false, p_player_identity: String = "") -> void:
peer_id = p_peer_id peer_id = p_peer_id
player_name = p_player_name player_name = p_player_name
player_identity = p_player_identity
team = p_team team = p_team
ready = p_ready ready = p_ready
@@ -117,6 +119,20 @@ func configure_join_authorisations(tokens: Array, context: Dictionary, signing_k
return true return true
func player_identity(peer_id: int) -> String:
if not roster.has(peer_id):
return ""
return String((roster[peer_id] as PlayerInfo).player_identity)
static func reservation_identity_matches(slot_identity: String, incoming_identity: String, slot_name: String, incoming_name: String) -> bool:
# Authenticated allocations must never fall back to a client-chosen display
# name. The name fallback exists only for direct, unauthenticated servers.
if not slot_identity.is_empty() or not incoming_identity.is_empty():
return not slot_identity.is_empty() and slot_identity == incoming_identity
return slot_name == incoming_name
# Server only: a raw ENet disconnect (crash, timeout) that never sent a # Server only: a raw ENet disconnect (crash, timeout) that never sent a
# proper hello just needs its (possibly absent) roster entry cleaned up. # proper hello just needs its (possibly absent) roster entry cleaned up.
# The normal leave path also goes through here after the server erases it, # The normal leave path also goes through here after the server erases it,
@@ -219,6 +235,10 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_j
await _reject(peer_id, "player name too long") await _reject(peer_id, "player name too long")
return return
var clean_name := _sanitize_player_name(player_name) var clean_name := _sanitize_player_name(player_name)
var identity := _join_identity(supplied_join_authorisation) if require_join_authorisation else clean_name
if identity.is_empty():
await _reject(peer_id, "join authorisation rejected")
return
# Tell the new peer about everyone already here before anyone is told # Tell the new peer about everyone already here before anyone is told
# about them, so no client ever observes an unknown peer_id in a # about them, so no client ever observes an unknown peer_id in a
@@ -228,7 +248,7 @@ func _hello(protocol_version: int, tick_hz: int, player_name: String, supplied_j
_player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready) _player_joined.rpc_id(peer_id, existing_id, existing.player_name, existing.team, existing.ready)
var team := _pick_balanced_team() var team := _pick_balanced_team()
roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false) roster[peer_id] = PlayerInfo.new(peer_id, clean_name, team, false, identity)
if require_join_authorisation: if require_join_authorisation:
# _reserve_join_authorisation already owns the active peer reservation; # _reserve_join_authorisation already owns the active peer reservation;
# keeping the generation in the history makes fencing auditable without # keeping the generation in the history makes fencing auditable without
@@ -254,6 +274,8 @@ func _valid_join_authorisation(token: String) -> bool:
var claims = envelope["Authorisation"] var claims = envelope["Authorisation"]
if not claims is Dictionary: if not claims is Dictionary:
return false return false
if str(claims.get("PlayerID", "")).is_empty():
return false
var protocol := str(claims.get("Protocol", "")) var protocol := str(claims.get("Protocol", ""))
var expires_at := str(claims.get("ExpiresAt", "")) var expires_at := str(claims.get("ExpiresAt", ""))
var expiry := Time.get_unix_time_from_datetime_string(expires_at) var expiry := Time.get_unix_time_from_datetime_string(expires_at)
@@ -285,6 +307,21 @@ func _valid_join_authorisation(token: String) -> bool:
and expiry > Time.get_unix_time_from_system() and expiry > Time.get_unix_time_from_system()
func _join_identity(token: String) -> String:
if token.is_empty():
return ""
var standard_token := token.replace("-", "+").replace("_", "/")
while standard_token.length() % 4 != 0:
standard_token += "="
var decoded := Marshalls.base64_to_raw(standard_token)
if decoded.is_empty():
return ""
var envelope = JSON.parse_string(decoded.get_string_from_utf8())
if not envelope is Dictionary or not envelope.has("Authorisation") or not envelope["Authorisation"] is Dictionary:
return ""
return str(envelope["Authorisation"].get("PlayerID", ""))
func is_join_authorisation_active(token: String) -> bool: func is_join_authorisation_active(token: String) -> bool:
return not token.is_empty() and _active_join_peers.has(token) return not token.is_empty() and _active_join_peers.has(token)
+8 -5
View File
@@ -124,7 +124,8 @@ class SlotInfo:
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot # §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
# keeps its ship and swaps the controller, so body order (and therefore # keeps its ship and swaps the controller, so body order (and therefore
# every snapshot index) stays stable for the whole match. # every snapshot index) stays stable for the whole match.
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect var player_name := "" # display name only; never authoritative for allocated reclaim
var player_identity := "" # signed allocation identity; peer_id changes across a reconnect
var disconnected := false var disconnected := false
var reserved_until_tick := -1 # server only: slot held for this player until here var reserved_until_tick := -1 # server only: slot held for this player until here
var interpolator := NetInterpolator.new() # client only var interpolator := NetInterpolator.new() # client only
@@ -461,6 +462,7 @@ func _start_server() -> void:
slot.team = info.team slot.team = info.team
slot.spawn_index = spawn_index slot.spawn_index = spawn_index
slot.player_name = info.player_name slot.player_name = info.player_name
slot.player_identity = MatchNet.player_identity(peer_id)
slot.controller = RLShipController.new() slot.controller = RLShipController.new()
slot.ship = spawn_ship(info.team, spawn_index, slot.controller) slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
_slots.append(slot) _slots.append(slot)
@@ -1206,12 +1208,12 @@ func _build_takeover_controller() -> ShipController:
# Called when a peer joins while this match is already running. Returns true if # Called when a peer joins while this match is already running. Returns true if
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation). # it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool: func _try_reclaim_slot(peer_id: int, player_identity: String, player_name: String) -> bool:
if not multiplayer.is_server(): if not multiplayer.is_server():
return false return false
var now := Engine.get_physics_frames() var now := Engine.get_physics_frames()
for slot in _slots: for slot in _slots:
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name: if not slot.disconnected or slot.player_name == "" or not MatchNet.reservation_identity_matches(slot.player_identity, player_identity, slot.player_name, player_name):
continue continue
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick: if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
continue # reservation lapsed; this is a fresh joiner, not a return continue # reservation lapsed; this is a fresh joiner, not a return
@@ -1246,7 +1248,7 @@ func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void: func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
if not multiplayer.is_server() or _slots.is_empty(): if not multiplayer.is_server() or _slots.is_empty():
return return
if _try_reclaim_slot(peer_id, player_name): if _try_reclaim_slot(peer_id, MatchNet.player_identity(peer_id), player_name):
return return
if _max_spectators >= 0 and _spectator_count() > _max_spectators: if _max_spectators >= 0 and _spectator_count() > _max_spectators:
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id]) print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
@@ -1262,7 +1264,7 @@ func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
# in _promote_late_joiners(). Queued in arrival order and consumed from the # in _promote_late_joiners(). Queued in arrival order and consumed from the
# front, so waiting is first-come-first-served rather than whichever slot # front, so waiting is first-come-first-served rather than whichever slot
# index happens to free up first. # index happens to free up first.
_late_joiners.append({"peer_id": peer_id, "player_name": player_name}) _late_joiners.append({"peer_id": peer_id, "player_name": player_name, "player_identity": MatchNet.player_identity(peer_id)})
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name]) print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
@@ -1302,6 +1304,7 @@ func _promote_late_joiners() -> void:
var joiner_peer := int(joiner["peer_id"]) var joiner_peer := int(joiner["peer_id"])
slot.peer_id = joiner_peer slot.peer_id = joiner_peer
slot.player_name = String(joiner["player_name"]) slot.player_name = String(joiner["player_name"])
slot.player_identity = String(joiner.get("player_identity", ""))
slot.disconnected = false slot.disconnected = false
slot.reserved_until_tick = -1 slot.reserved_until_tick = -1
# Same reasoning as the reclaim path: the arriving client numbers its # Same reasoning as the reclaim path: the arriving client numbers its
+7
View File
@@ -39,6 +39,13 @@ func test_leading_trailing_whitespace_trimmed() -> void:
assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed") assert_eq(MatchNet._sanitize_player_name(" Bob "), "Bob", "surrounding whitespace trimmed")
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: func test_allocated_join_authorisation_is_allowlisted_and_bound_to_server() -> void:
var claims := { var claims := {
"MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1", "MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1",
+2
View File
@@ -1398,3 +1398,5 @@ The current working implementation now wires `deploy/k8s/base/fleet.yaml` to the
The NA overlay now also patches the allocated childs `--region=NA` argument, keeping it aligned with the NA Fleet label; rendered EU and NA overlays and the adversarial manifest test verify that regional assignment validation cannot silently remain EU in the NA deployment. The NA overlay now also patches the allocated childs `--region=NA` argument, keeping it aligned with the NA Fleet label; rendered EU and NA overlays and the adversarial manifest test verify that regional assignment validation cannot silently remain EU in the NA deployment.
Allocated Godot startup now derives its `min-players` floor from the verified signed roster size, preventing the direct-server default of one player from starting a partially admitted allocated match. A focused regression test covers six-player, casual two-player, and direct-server behavior; the full Godot harness is currently unavailable because Godot cannot open its shared `user://` log and crashes in the macOS renderer before test execution. Allocated Godot startup now derives its `min-players` floor from the verified signed roster size, preventing the direct-server default of one player from starting a partially admitted allocated match. A focused regression test covers six-player, casual two-player, and direct-server behavior; the full Godot harness is currently unavailable because Godot cannot open its shared `user://` log and crashes in the macOS renderer before test execution.
The former display-name reclaim weakness (flagged item C) is now closed for allocated matches: the signed `PlayerID` is retained in the server roster and slot, and both reconnect reclaim and late-join promotion carry that stable identity across peer-id changes. Display-name matching remains only as a legacy fallback for unauthenticated direct servers. A focused adversarial unit test covers changed names, same-name impostors, missing identities, and the direct-server fallback.