OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / addons / keet_social / keet_peer_identity.gd

197 lines · gd ref:
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 {}