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:
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)