extends Node
## Which keet member is behind each mesh peer — PROVEN, not claimed (hyper-games
## #89 §3.3). Owned by KeetService as /root/KeetService/PeerIdentity, the same path
## on every peer, so the claim RPC below lands on its counterpart.
##
## Names used to be self-asserted: `_set_player_name` was an any_peer RPC (anyone
## could relabel anyone), and the realtime `player:name:` copy was sent unreliable
## and never replayed, so a late joiner saw everyone as "Player".
##
## Now, per mesh connection:
## 1. each peer attests ITS OWN DHT public key with its keet device credentials
## (daemon identity.attest → {memberId, proof}) and sends {memberId, proof} to
## every peer it connects to — late joiners included, because it is sent per
## connection, not once;
## 2. the receiver verifies the proof against the key the Noise handshake
## authenticated for that sender (identity.verify). Only then is
## peer_id → memberId bound. A proof for another key, or another member's
## memberId, does not verify (keet-daemon test/identity-attest);
## 3. the label is that member's name from the keet ROOM ROSTER — never a string
## the peer sent. No name, or no verified claim: anon-<4>.
##
## Renames: the renamer re-sends its claim (identity reload), and a claim makes the
## receiver refresh the roster, where keet has put the new name.
##
## No class_name: loaded via preload (as keet_names.gd), so it works without
## refreshing Godot's global class cache. Injectable for the gate: `client`
## (anything with call_rpc), `send_claim` (default: the RPC), `roster`.
## Any peer's display name may have changed (claim verified/dropped, roster, peer left).
signal names_changed
## A verified member is not in the cached roster: KeetService should re-list members.
signal roster_requested
const KeetNames := preload("res://addons/keet_social/keet_names.gd")
var client: Object = null
## Called as send_claim.call(peer_id, member_id, proof). Default sends the RPC.
var send_claim: Callable = _rpc_claim
## The current room's members as keet-daemon `members` returns them.
var roster: Array = []
## Our own display name (for a bot bound to the local peer). Set by KeetService.
var my_name: String = ""
var _my_dht_pk: String = ""
var _my_claim: Dictionary = {} # {memberId, proof} for _my_dht_pk
var _peer_keys: Dictionary = {} # peer_id -> DHT pk hex, from the authenticated swarm handshake
var _pending: Dictionary = {} # peer_id -> {memberId, proof}: claim that beat its key here
var _bound: Dictionary = {} # peer_id -> memberId, VERIFIED
func _ready() -> void:
multiplayer.peer_connected.connect(_on_peer_connected)
multiplayer.peer_disconnected.connect(forget_peer)
# ---- our own claim -----------------------------------------------------------
## The mesh key we joined with (menu_main._start_mesh). Attest it once per key.
func set_my_dht_key(pk_hex: String) -> void:
if pk_hex == _my_dht_pk and not _my_claim.is_empty():
return
_my_dht_pk = pk_hex
_my_claim = {}
_attest()
## Identity (re)loaded: a new identity needs a new proof; the same one re-sends its
## claim so peers refresh their roster (that is how a rename reaches them).
func on_identity_loaded(ident: Dictionary) -> void:
var member := KeetNames.clean(ident.get("memberId"))
if member == "" or _my_dht_pk == "":
return
if member != String(_my_claim.get("memberId", "")):
_my_claim = {}
_attest()
else:
_broadcast_claim()
func _attest() -> void:
if client == null or _my_dht_pk == "":
return
client.call_rpc("identity.attest", {"data": _my_dht_pk}, _on_attested.bind(_my_dht_pk))
func _on_attested(result: Variant, err: Variant, pk_hex: String) -> void:
if pk_hex != _my_dht_pk:
return # the mesh key changed while this was in flight
if err != null or not (result is Dictionary):
push_warning("[PeerIdentity] identity.attest failed: %s" % str(err))
return
_my_claim = {"memberId": String(result.get("memberId", "")), "proof": String(result.get("proof", ""))}
_broadcast_claim()
func _broadcast_claim() -> void:
if _my_claim.is_empty() or multiplayer.multiplayer_peer == null:
return
for id in multiplayer.get_peers():
send_claim.call(id, _my_claim.memberId, _my_claim.proof)
func _on_peer_connected(id: int) -> void:
if not _my_claim.is_empty():
send_claim.call(id, _my_claim.memberId, _my_claim.proof)
func _rpc_claim(id: int, member_id: String, proof: String) -> void:
_claim.rpc_id(id, member_id, proof)
# ---- their claims ------------------------------------------------------------
@rpc("any_peer", "call_remote", "reliable")
func _claim(member_id: String, proof: String) -> void:
receive_claim(multiplayer.get_remote_sender_id(), member_id, proof)
## The DHT key the swarm handshake authenticated for this peer (menu_main
## _on_swarm_connection). A claim that arrived first is verified now.
func set_peer_key(peer_id: int, pk_hex: String) -> void:
_peer_keys[peer_id] = pk_hex
if _pending.has(peer_id):
var c: Dictionary = _pending[peer_id]
_pending.erase(peer_id)
_verify(peer_id, c.memberId, c.proof)
names_changed.emit()
func receive_claim(sender: int, member_id: String, proof: String) -> void:
if not _peer_keys.has(sender):
_pending[sender] = {"memberId": member_id, "proof": proof}
return
_verify(sender, member_id, proof)
func _verify(sender: int, member_id: String, proof: String) -> void:
if client == null:
return
var pk: String = _peer_keys[sender]
client.call_rpc("identity.verify", {"proof": proof, "data": pk, "memberId": member_id},
_on_verified.bind(sender, member_id, pk))
func _on_verified(result: Variant, err: Variant, sender: int, member_id: String, pk_hex: String) -> void:
if String(_peer_keys.get(sender, "")) != pk_hex:
return # the peer left or reconnected with another key meanwhile
var ok: bool = err == null and result is Dictionary and bool(result.get("ok", false))
if ok:
_bound[sender] = member_id
# Always: a new member may be missing from the roster, and a RE-SENT claim
# is how a rename announces itself.
roster_requested.emit()
else:
_bound.erase(sender)
push_warning("[PeerIdentity] peer %d claimed member %s with a proof that does not verify for its mesh key — not trusted" % [sender, member_id.left(8)])
names_changed.emit()
func forget_peer(peer_id: int) -> void:
_peer_keys.erase(peer_id)
_pending.erase(peer_id)
_bound.erase(peer_id)
names_changed.emit()
## The room roster changed (KeetService members_loaded).
func set_roster(members: Array) -> void:
roster = members
names_changed.emit()
# ---- lookups -----------------------------------------------------------------
## The VERIFIED keet memberId behind a mesh peer, or "".
func member_of(peer_id: int) -> String:
return String(_bound.get(peer_id, ""))
## What to show for a mesh peer: the roster name of its verified member, else
## anon-<4>. Never a string the peer itself sent.
func name_for_peer(peer_id: int) -> String:
if multiplayer.multiplayer_peer != null and peer_id == multiplayer.get_unique_id() and my_name != "":
return my_name
var member := member_of(peer_id)
if member != "":
return KeetNames.display(_roster_entry(member).get("name"), member)
var pk: String = String(_peer_keys.get(peer_id, ""))
return KeetNames.ANON_PREFIX + (pk.left(4) if pk != "" else "p%d" % peer_id)
func _roster_entry(member_id: String) -> Dictionary:
for m: Variant in roster:
if m is Dictionary and String(m.get("memberId", "")) == member_id:
return m
return {}