diff --git a/Game/scripts/match_net.gd b/Game/scripts/match_net.gd index 2f50430f..825940f3 100644 --- a/Game/scripts/match_net.gd +++ b/Game/scripts/match_net.gd @@ -37,12 +37,14 @@ const MAX_PLAYER_NAME_LENGTH := 24 class PlayerInfo: var peer_id: int var player_name: String + var player_identity: String var team: int = 0 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 player_name = p_player_name + player_identity = p_player_identity team = p_team ready = p_ready @@ -117,6 +119,20 @@ func configure_join_authorisations(tokens: Array, context: Dictionary, signing_k 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 # proper hello just needs its (possibly absent) roster entry cleaned up. # 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") return 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 # 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) 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: # _reserve_join_authorisation already owns the active peer reservation; # 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"] if not claims is Dictionary: return false + if str(claims.get("PlayerID", "")).is_empty(): + return false var protocol := str(claims.get("Protocol", "")) var expires_at := str(claims.get("ExpiresAt", "")) 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() +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: return not token.is_empty() and _active_join_peers.has(token) diff --git a/Game/scripts/networked_match.gd b/Game/scripts/networked_match.gd index 43b58ca6..5ab5a0a6 100644 --- a/Game/scripts/networked_match.gd +++ b/Game/scripts/networked_match.gd @@ -124,7 +124,8 @@ class SlotInfo: # §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 # 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 reserved_until_tick := -1 # server only: slot held for this player until here var interpolator := NetInterpolator.new() # client only @@ -461,6 +462,7 @@ func _start_server() -> void: slot.team = info.team slot.spawn_index = spawn_index slot.player_name = info.player_name + slot.player_identity = MatchNet.player_identity(peer_id) slot.controller = RLShipController.new() slot.ship = spawn_ship(info.team, spawn_index, slot.controller) _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 # 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(): return false var now := Engine.get_physics_frames() 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 if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick: 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: if not multiplayer.is_server() or _slots.is_empty(): return - if _try_reclaim_slot(peer_id, player_name): + if _try_reclaim_slot(peer_id, MatchNet.player_identity(peer_id), player_name): return if _max_spectators >= 0 and _spectator_count() > _max_spectators: 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 # front, so waiting is first-come-first-served rather than whichever slot # 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]) @@ -1302,6 +1304,7 @@ func _promote_late_joiners() -> void: var joiner_peer := int(joiner["peer_id"]) slot.peer_id = joiner_peer slot.player_name = String(joiner["player_name"]) + slot.player_identity = String(joiner.get("player_identity", "")) slot.disconnected = false slot.reserved_until_tick = -1 # Same reasoning as the reclaim path: the arriving client numbers its diff --git a/Game/tests/cases/test_match_net.gd b/Game/tests/cases/test_match_net.gd index ecd89bf0..68ec3b56 100644 --- a/Game/tests/cases/test_match_net.gd +++ b/Game/tests/cases/test_match_net.gd @@ -39,6 +39,13 @@ func test_leading_trailing_whitespace_trimmed() -> void: 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: var claims := { "MatchID": "match-1", "ServerID": "server-1", "PlayerID": "player-1", diff --git a/multiplayer-next.md b/multiplayer-next.md index f954c953..5e340502 100644 --- a/multiplayer-next.md +++ b/multiplayer-next.md @@ -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 child’s `--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. + +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.