Add decentralized cryptographic identity

This commit is contained in:
Alexander Sellite 2026-07-29 22:18:14 -04:00
parent 0b5b78a553
commit fa996a6735
27 changed files with 1733 additions and 58 deletions

View file

@ -100,6 +100,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0
@onready var _network_profile: NetworkProfilePreferencesType = ( @onready var _network_profile: NetworkProfilePreferencesType = (
%NetworkProfilePreferences %NetworkProfilePreferences
) )
@onready var _player_identity: PlayerIdentityStore = %PlayerIdentityStore
@onready var _host_identity: HostIdentityStore = %HostIdentityStore
@onready var _known_players: KnownPlayerStore = %KnownPlayerStore
@onready var _server_trust: ServerTrustStore = %ServerTrustStore
@onready var _saved_servers: SavedServerStoreType = %SavedServerStore @onready var _saved_servers: SavedServerStoreType = %SavedServerStore
@onready var _player_spawn_service: PlayerSpawnServiceType = ( @onready var _player_spawn_service: PlayerSpawnServiceType = (
%PlayerSpawnService %PlayerSpawnService
@ -132,6 +136,9 @@ var _quit_in_progress: bool = false
var _join_requested_from_title: bool = false var _join_requested_from_title: bool = false
var _join_requested_from_pause: bool = false var _join_requested_from_pause: bool = false
var _pending_join_endpoint: String = "" var _pending_join_endpoint: String = ""
var _server_trust_dialog: ConfirmationDialog
var _pending_trust_changed: bool = false
var _identity_notice_dialog: AcceptDialog
func _ready() -> void: func _ready() -> void:
@ -145,7 +152,11 @@ func _ready() -> void:
_network_session.setup( _network_session.setup(
_network_profile, _network_profile,
_saved_servers, _saved_servers,
_player_spawn_service _player_spawn_service,
_player_identity,
_host_identity,
_known_players,
_server_trust,
) )
_network_profile_service.setup( _network_profile_service.setup(
_network_session, _network_session,
@ -162,10 +173,18 @@ func _ready() -> void:
_network_session.connection_error.connect( _network_session.connection_error.connect(
_on_network_connection_error _on_network_connection_error
) )
_network_session.server_trust_required.connect(
_on_server_trust_required
)
_network_session.peer_identity_observed.connect(
_on_peer_identity_observed
)
_network_session.server_lost.connect(_on_network_server_lost) _network_session.server_lost.connect(_on_network_server_lost)
_network_session.remote_recovery_requested.connect( _network_session.remote_recovery_requested.connect(
_on_remote_recovery_requested _on_remote_recovery_requested
) )
_player.fish_sale_service.setup( _player.fish_sale_service.setup(
_player.inventory, _player.inventory,
_player.wallet _player.wallet
@ -332,7 +351,8 @@ func _ready() -> void:
_save_manager, _save_manager,
_settings_manager, _settings_manager,
_network_session, _network_session,
_saved_servers _saved_servers,
_server_trust,
) )
var pause_menu: PauseMenuType = _game_ui.get_pause_menu() var pause_menu: PauseMenuType = _game_ui.get_pause_menu()
pause_menu.setup( pause_menu.setup(
@ -341,7 +361,8 @@ func _ready() -> void:
_settings_manager, _settings_manager,
_fishing_spot, _fishing_spot,
_network_session, _network_session,
_saved_servers _saved_servers,
_server_trust,
) )
title_screen.join_game_requested.connect(_on_title_join_game_requested) title_screen.join_game_requested.connect(_on_title_join_game_requested)
pause_menu.join_game_requested.connect(_on_pause_join_game_requested) pause_menu.join_game_requested.connect(_on_pause_join_game_requested)
@ -375,6 +396,73 @@ func _ready() -> void:
_show_title_music(true) _show_title_music(true)
func _on_server_trust_required(
endpoint: String,
expected_fingerprint: String,
received_fingerprint: String,
is_changed: bool,
) -> void:
if _server_trust_dialog == null:
_server_trust_dialog = ConfirmationDialog.new()
_server_trust_dialog.title = "Server identity"
_server_trust_dialog.ok_button_text = "Trust & Connect"
_server_trust_dialog.cancel_button_text = "Cancel"
_server_trust_dialog.confirmed.connect(_confirm_server_trust)
_server_trust_dialog.canceled.connect(
_network_session.resolve_server_trust.bind(false)
)
_game_ui.add_child(_server_trust_dialog)
_pending_trust_changed = is_changed
if is_changed:
_server_trust_dialog.dialog_text = (
"Server identity changed.\n\n"
+ "This may mean the server was reinstalled, moved, or is being "
+ "impersonated.\n\nExpected:\n%s\n\nReceived:\n%s"
% [
NetworkIdentityCrypto.format_fingerprint(expected_fingerprint),
NetworkIdentityCrypto.format_fingerprint(received_fingerprint),
]
)
_server_trust_dialog.ok_button_text = "Trust New Identity"
else:
_server_trust_dialog.dialog_text = (
"First time connecting to this server.\n\n%s\n\nHost identity:\n%s"
% [
endpoint,
NetworkIdentityCrypto.format_fingerprint(received_fingerprint),
]
)
_server_trust_dialog.ok_button_text = "Trust & Connect"
_server_trust_dialog.popup_centered(Vector2i(560, 360))
func _confirm_server_trust() -> void:
if _pending_trust_changed:
_pending_trust_changed = false
_server_trust_dialog.dialog_text = (
"Replace the saved server identity pin?\n\n"
+ "Only continue if you expected this server identity to change."
)
_server_trust_dialog.ok_button_text = "Replace Pin"
_server_trust_dialog.popup_centered(Vector2i(520, 260))
return
_network_session.resolve_server_trust(true)
func _on_peer_identity_observed(_peer_id: int, status: String) -> void:
if status != "New identity using a familiar name":
return
if _identity_notice_dialog == null:
_identity_notice_dialog = AcceptDialog.new()
_identity_notice_dialog.title = "Player identity"
_game_ui.add_child(_identity_notice_dialog)
_identity_notice_dialog.dialog_text = (
"New identity using a familiar name.\n\n"
+ "This may be a different player using the same display name."
)
_identity_notice_dialog.popup_centered(Vector2i(480, 240))
func _input(event: InputEvent) -> void: func _input(event: InputEvent) -> void:
if ( if (
not event.is_action_pressed("ui_cancel") not event.is_action_pressed("ui_cancel")

View file

@ -1,4 +1,4 @@
[gd_scene load_steps=30 format=3] [gd_scene load_steps=34 format=3]
[ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"] [ext_resource type="PackedScene" path="res://world/test_world.tscn" id="1_world"]
[ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"] [ext_resource type="PackedScene" path="res://player/player.tscn" id="2_player"]
@ -29,6 +29,10 @@
[ext_resource type="Script" path="res://progression/player_asset_reservation_service.gd" id="27_reservations"] [ext_resource type="Script" path="res://progression/player_asset_reservation_service.gd" id="27_reservations"]
[ext_resource type="Script" path="res://progression/player_appearance_store.gd" id="28_appearance"] [ext_resource type="Script" path="res://progression/player_appearance_store.gd" id="28_appearance"]
[ext_resource type="Script" path="res://network/network_profile_service.gd" id="29_profile_service"] [ext_resource type="Script" path="res://network/network_profile_service.gd" id="29_profile_service"]
[ext_resource type="Script" path="res://network/player_identity_store.gd" id="30_player_identity"]
[ext_resource type="Script" path="res://network/host_identity_store.gd" id="31_host_identity"]
[ext_resource type="Script" path="res://network/known_player_store.gd" id="32_known_players"]
[ext_resource type="Script" path="res://network/server_trust_store.gd" id="33_server_trust"]
[node name="Main" type="Node3D"] [node name="Main" type="Node3D"]
script = ExtResource("3_main") script = ExtResource("3_main")
@ -45,6 +49,22 @@ script = ExtResource("17_network_session")
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("18_network_profile") script = ExtResource("18_network_profile")
[node name="PlayerIdentityStore" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("30_player_identity")
[node name="HostIdentityStore" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("31_host_identity")
[node name="KnownPlayerStore" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("32_known_players")
[node name="ServerTrustStore" type="Node" parent="."]
unique_name_in_owner = true
script = ExtResource("33_server_trust")
[node name="PlayerAppearanceStore" type="Node" parent="."] [node name="PlayerAppearanceStore" type="Node" parent="."]
unique_name_in_owner = true unique_name_in_owner = true
script = ExtResource("28_appearance") script = ExtResource("28_appearance")

View file

@ -0,0 +1,6 @@
class_name HostIdentityStore
extends "res://network/local_signing_identity_store.gd"
func _ready() -> void:
configure("host_identity")

View file

@ -0,0 +1 @@
uid://dtyoh0v77x52f

View file

@ -0,0 +1,119 @@
class_name KnownPlayerStore
extends Node
const FORMAT_VERSION: int = 1
const STORE_PATH: String = "user://known_players.json"
const TEMP_PATH: String = STORE_PATH + ".tmp"
const MAX_RECORDS: int = 500
var _records: Dictionary = {}
var _loaded: bool = false
var _write_blocked: bool = false
func observe(fingerprint: String, display_name: String) -> String:
_ensure_loaded()
if (
not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
or not NetworkProfilePreferences.is_valid_display_name(display_name)
):
return ""
var now := int(Time.get_unix_time_from_system())
var status := "New identity"
var record: Dictionary = _records.get(fingerprint, {})
if not record.is_empty():
status = "Known player"
else:
for value: Dictionary in _records.values():
if str(value.get("last_known_display_name", "")).nocasecmp_to(display_name) == 0:
status = "New identity using a familiar name"
break
record = {
"fingerprint": fingerprint,
"first_seen_unix": now,
"locally_verified": false,
}
record["last_known_display_name"] = display_name
record["last_seen_unix"] = now
_records[fingerprint] = record
_bound_records()
_save()
return status
func get_record(fingerprint: String) -> Dictionary:
_ensure_loaded()
return Dictionary(_records.get(fingerprint, {})).duplicate(true)
func identity_status(fingerprint: String, display_name: String) -> String:
_ensure_loaded()
var record: Dictionary = _records.get(fingerprint, {})
if not record.is_empty():
return "Known player"
for value: Dictionary in _records.values():
if str(value.get("last_known_display_name", "")).nocasecmp_to(display_name) == 0:
return "New identity using a familiar name"
return "New identity"
func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
if data.get("format_version") != FORMAT_VERSION:
_write_blocked = true
return
if typeof(data.get("records")) != TYPE_ARRAY:
return
for value: Variant in data["records"]:
if typeof(value) != TYPE_DICTIONARY:
continue
var record: Dictionary = value
var fingerprint := str(record.get("fingerprint", ""))
if NetworkIdentityCrypto.valid_fingerprint(fingerprint):
_records[fingerprint] = record.duplicate(true)
func _save() -> bool:
if _write_blocked:
return false
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK
func _bound_records() -> void:
while _records.size() > MAX_RECORDS:
var oldest_key := ""
var oldest_time := 9223372036854775807
for key: String in _records:
var seen := int(_records[key].get("last_seen_unix", 0))
if seen < oldest_time:
oldest_time = seen
oldest_key = key
_records.erase(oldest_key)

View file

@ -0,0 +1 @@
uid://dossvvvh4yuid

View file

@ -0,0 +1,177 @@
class_name LocalSigningIdentityStore
extends Node
const FORMAT_VERSION: int = 1
var fingerprint: String = ""
var public_pem: String = ""
var error_message: String = ""
var _private_key: CryptoKey
var _prefix: String = ""
var _allow_generation: bool = true
func configure(prefix: String, allow_generation: bool = true) -> void:
_prefix = prefix
_allow_generation = allow_generation
func load_or_create() -> bool:
if _prefix.is_empty():
error_message = "Identity storage is not configured."
return false
var key_path := _path(".key")
var public_path := _path(".pub")
var metadata_path := _path(".json")
var any_exists := (
FileAccess.file_exists(key_path)
or FileAccess.file_exists(public_path)
or FileAccess.file_exists(metadata_path)
)
if any_exists:
return _load_existing()
if not _allow_generation:
return false
return _generate_new()
func is_ready() -> bool:
return _private_key != null and NetworkIdentityCrypto.valid_fingerprint(fingerprint)
func sign(domain: String, fields: Array) -> PackedByteArray:
return NetworkIdentityCrypto.sign_fields(_private_key, domain, fields)
func verify(domain: String, fields: Array, signature: PackedByteArray) -> bool:
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
domain,
fields,
signature,
)
func _generate_new() -> bool:
var key := Crypto.new().generate_rsa(3072)
if key == null:
error_message = "A signing identity could not be generated."
return false
var private_text := key.save_to_string()
var public_text := NetworkIdentityCrypto.normalize_public_pem(
key.save_to_string(true)
)
var derived := NetworkIdentityCrypto.fingerprint_public_pem(public_text)
var probe := NetworkIdentityCrypto.sign_fields(key, "identity_self_test", [derived])
var public_key := NetworkIdentityCrypto.load_public_key(public_text)
if (
not NetworkIdentityCrypto.valid_fingerprint(derived)
or not NetworkIdentityCrypto.verify_fields(
public_key, "identity_self_test", [derived], probe
)
):
error_message = "The generated signing identity failed verification."
return false
var metadata := {
"format_version": FORMAT_VERSION,
"algorithm": NetworkIdentityCrypto.ALGORITHM,
"fingerprint": derived,
"created_at_unix": int(Time.get_unix_time_from_system()),
}
if (
not _write_atomic(_path(".key"), private_text)
or not _write_atomic(_path(".pub"), public_text)
or not _write_atomic(_path(".json"), JSON.stringify(metadata, "\t"))
):
error_message = "The signing identity could not be stored."
return false
FileAccess.set_unix_permissions(_path(".key"), 384)
return _load_existing()
func _load_existing() -> bool:
var required := [_path(".key"), _path(".pub"), _path(".json")]
for path: String in required:
if not FileAccess.file_exists(path):
error_message = "Identity recovery is required. An identity file is missing."
return false
var private_file := FileAccess.open(_path(".key"), FileAccess.READ)
var public_file := FileAccess.open(_path(".pub"), FileAccess.READ)
var metadata_file := FileAccess.open(_path(".json"), FileAccess.READ)
if private_file == null or public_file == null or metadata_file == null:
error_message = "Identity recovery is required. Identity files could not be read."
return false
var private_text := private_file.get_as_text()
var loaded_public := NetworkIdentityCrypto.normalize_public_pem(public_file.get_as_text())
var metadata_text := metadata_file.get_as_text()
var json := JSON.new()
if json.parse(metadata_text) != OK or typeof(json.data) != TYPE_DICTIONARY:
error_message = "Identity recovery is required. Identity metadata is damaged."
return false
var metadata: Dictionary = json.data
var derived := NetworkIdentityCrypto.fingerprint_public_pem(loaded_public)
if (
metadata.get("format_version") != FORMAT_VERSION
or metadata.get("algorithm") != NetworkIdentityCrypto.ALGORITHM
or metadata.get("fingerprint") != derived
):
error_message = "Identity recovery is required. Identity files do not match."
return false
var key := CryptoKey.new()
if key.load_from_string(private_text) != OK:
error_message = "Identity recovery is required. The private key is damaged."
return false
var probe := NetworkIdentityCrypto.sign_fields(key, "identity_self_test", [derived])
if not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(loaded_public),
"identity_self_test",
[derived],
probe,
):
error_message = "Identity recovery is required. The key pair does not match."
return false
_private_key = key
public_pem = loaded_public
fingerprint = derived
error_message = ""
return true
func _write_atomic(path: String, content: String) -> bool:
var temporary := path + ".tmp"
var file := FileAccess.open(temporary, FileAccess.WRITE)
if file == null:
return false
file.store_string(content)
file.flush()
var write_error := file.get_error()
file.close()
if write_error != OK:
return false
if FileAccess.file_exists(path):
var backup := path + ".backup"
_remove_if_present(backup)
if not _rename(path, backup):
return false
if not _rename(temporary, path):
_rename(backup, path)
return false
_remove_if_present(backup)
return true
return _rename(temporary, path)
func _path(extension: String) -> String:
return "user://%s%s" % [_prefix, extension]
func _rename(from_path: String, to_path: String) -> bool:
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(from_path),
ProjectSettings.globalize_path(to_path),
) == OK
func _remove_if_present(path: String) -> void:
if FileAccess.file_exists(path):
DirAccess.remove_absolute(ProjectSettings.globalize_path(path))

View file

@ -0,0 +1 @@
uid://1h1kh0jlhc87

View file

@ -34,6 +34,12 @@ static func validate_request(data: Variant) -> bool:
and _valid_id(data.get("request_id")) and _valid_id(data.get("request_id"))
and _valid_id(data.get("session_id")) and _valid_id(data.get("session_id"))
and not sanitize_body(data.get("body")).is_empty() and not sanitize_body(data.get("body")).is_empty()
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
)
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
and PackedByteArray(data["sender_signature"]).size()
<= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
) )
@ -50,7 +56,20 @@ static func validate_message(data: Variant) -> bool:
and typeof(data.get("sender_display_name")) == TYPE_STRING and typeof(data.get("sender_display_name")) == TYPE_STRING
and str(data["sender_display_name"]).length() <= 24 and str(data["sender_display_name"]).length() <= 24
and not sanitize_body(data.get("body")).is_empty() and not sanitize_body(data.get("body")).is_empty()
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
) )
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
)
static func signature_fields(data: Dictionary) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("sender_fingerprint", "")),
sanitize_body(data.get("body")),
]
static func _valid_id(value: Variant) -> bool: static func _valid_id(value: Variant) -> bool:

View file

@ -50,7 +50,11 @@ func send_local_message(body: String) -> bool:
"request_id": _new_id("chat_request"), "request_id": _new_id("chat_request"),
"session_id": _session.get_session_id(), "session_id": _session.get_session_id(),
"body": clean, "body": clean,
"sender_fingerprint": _session.get_local_identity_fingerprint(),
} }
request["sender_signature"] = _session.sign_local_action(
"chat_send", NetworkChatProtocol.signature_fields(request)
)
if _session.is_host(): if _session.is_host():
_handle_request(_session.get_local_peer_id(), request) _handle_request(_session.get_local_peer_id(), request)
else: else:
@ -76,6 +80,19 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
): ):
_send_rejection(peer_id, "Message could not be sent.") _send_rejection(peer_id, "Message could not be sent.")
return return
var record := _session.get_peer_record(peer_id)
if (
record == null
or record.identity_fingerprint != str(data["sender_fingerprint"])
or not _session.verify_peer_action(
peer_id,
"chat_send",
NetworkChatProtocol.signature_fields(data),
data["sender_signature"],
)
):
_send_rejection(peer_id, "Message identity could not be verified.")
return
var request_id: String = data["request_id"] var request_id: String = data["request_id"]
var ledger: Dictionary = _request_ledgers.get(peer_id, {}) var ledger: Dictionary = _request_ledgers.get(peer_id, {})
if ledger.has(request_id): if ledger.has(request_id):
@ -84,15 +101,15 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
if not _consume_rate(peer_id): if not _consume_rate(peer_id):
_send_rejection(peer_id, "Slow down.") _send_rejection(peer_id, "Slow down.")
return return
var record := _session.get_peer_record(peer_id)
if record == null:
return
var message := _make_message( var message := _make_message(
NetworkChatProtocol.Kind.PLAYER, NetworkChatProtocol.Kind.PLAYER,
peer_id, peer_id,
record.display_name, record.display_name,
NetworkChatProtocol.sanitize_body(data["body"]) NetworkChatProtocol.sanitize_body(data["body"])
) )
message["request_id"] = request_id
message["sender_fingerprint"] = data["sender_fingerprint"]
message["sender_signature"] = data["sender_signature"]
ledger[request_id] = message.duplicate(true) ledger[request_id] = message.duplicate(true)
while ledger.size() > 64: while ledger.size() > 64:
ledger.erase(ledger.keys().front()) ledger.erase(ledger.keys().front())
@ -107,15 +124,22 @@ func _make_message(
body: String, body: String,
) -> Dictionary: ) -> Dictionary:
_sequence += 1 _sequence += 1
return { var message := {
"message_id": _new_id("chat"), "message_id": _new_id("chat"),
"request_id": _new_id("chat_system"),
"session_id": _session.get_session_id(), "session_id": _session.get_session_id(),
"sequence": _sequence, "sequence": _sequence,
"kind": kind, "kind": kind,
"sender_peer_id": peer_id, "sender_peer_id": peer_id,
"sender_display_name": display_name.left(24), "sender_display_name": display_name.left(24),
"body": body, "body": body,
"sender_fingerprint": _session.get_host_identity_fingerprint(),
} }
if kind == NetworkChatProtocol.Kind.SYSTEM:
message["sender_signature"] = _session.sign_host_action(
"chat_system", NetworkChatProtocol.signature_fields(message)
)
return message
func _broadcast(message: Dictionary) -> void: func _broadcast(message: Dictionary) -> void:
@ -142,6 +166,29 @@ func _apply_message(data: Dictionary) -> void:
or _seen_messages.has(str(data["message_id"])) or _seen_messages.has(str(data["message_id"]))
): ):
return return
var kind := int(data["kind"])
var valid_signature := false
if kind == NetworkChatProtocol.Kind.SYSTEM:
valid_signature = _session.verify_host_action(
"chat_system",
NetworkChatProtocol.signature_fields(data),
data["sender_signature"],
)
else:
var sender_id := int(data["sender_peer_id"])
var record := _session.get_peer_record(sender_id)
valid_signature = (
record != null
and record.identity_fingerprint == str(data["sender_fingerprint"])
and _session.verify_peer_action(
sender_id,
"chat_send",
NetworkChatProtocol.signature_fields(data),
data["sender_signature"],
)
)
if not valid_signature:
return
_seen_messages[data["message_id"]] = true _seen_messages[data["message_id"]] = true
_history.append(data.duplicate(true)) _history.append(data.duplicate(true))
while _history.size() > NetworkChatProtocol.MAX_HISTORY: while _history.size() > NetworkChatProtocol.MAX_HISTORY:

View file

@ -0,0 +1,144 @@
class_name NetworkIdentityCrypto
extends RefCounted
const ALGORITHM: String = "RSA-3072-SHA256"
const FINGERPRINT_LENGTH: int = 64
const MAX_PUBLIC_KEY_BYTES: int = 8192
const MAX_SIGNATURE_BYTES: int = 1024
const DOMAIN_PREFIX: String = "NETFISHING"
const IDENTITY_VERSION: String = "identity_v1"
static func normalize_public_pem(value: String) -> String:
var normalized := value.replace("\r\n", "\n").replace("\r", "\n").strip_edges()
return normalized + "\n" if not normalized.is_empty() else ""
static func fingerprint_public_pem(value: String) -> String:
var normalized := normalize_public_pem(value)
return normalized.sha256_text() if not normalized.is_empty() else ""
static func valid_fingerprint(value: Variant) -> bool:
if typeof(value) != TYPE_STRING:
return false
var text := str(value)
if text.length() != FINGERPRINT_LENGTH:
return false
for character: String in text:
if character not in "0123456789abcdef":
return false
return true
static func format_fingerprint(value: String, groups: int = 5) -> String:
if not valid_fingerprint(value):
return "Unavailable"
var visible := value.left(clampi(groups, 1, 8) * 4).to_upper()
var parts := PackedStringArray()
for offset: int in range(0, visible.length(), 4):
parts.append(visible.substr(offset, 4))
return "-".join(parts)
static func compact_suffix(value: String) -> String:
return value.right(6).to_upper() if valid_fingerprint(value) else "??????"
static func secure_id(byte_count: int = 16) -> String:
return Crypto.new().generate_random_bytes(byte_count).hex_encode()
static func canonical_bytes(domain: String, fields: Array) -> PackedByteArray:
var output := PackedByteArray()
_append_string(output, DOMAIN_PREFIX)
_append_string(output, IDENTITY_VERSION)
_append_string(output, domain)
for value: Variant in fields:
match typeof(value):
TYPE_STRING, TYPE_STRING_NAME:
output.append(1)
_append_string(output, str(value))
TYPE_INT:
output.append(2)
_append_i64(output, int(value))
TYPE_BOOL:
output.append(3)
output.append(1 if bool(value) else 0)
TYPE_PACKED_BYTE_ARRAY:
output.append(4)
_append_bytes(output, value)
_:
return PackedByteArray()
return output
static func digest(domain: String, fields: Array) -> PackedByteArray:
var bytes := canonical_bytes(domain, fields)
if bytes.is_empty():
return PackedByteArray()
var context := HashingContext.new()
if context.start(HashingContext.HASH_SHA256) != OK:
return PackedByteArray()
context.update(bytes)
return context.finish()
static func sign_fields(
private_key: CryptoKey, domain: String, fields: Array
) -> PackedByteArray:
if private_key == null:
return PackedByteArray()
var value := digest(domain, fields)
return (
Crypto.new().sign(HashingContext.HASH_SHA256, value, private_key)
if not value.is_empty() else PackedByteArray()
)
static func verify_fields(
public_key: CryptoKey,
domain: String,
fields: Array,
signature: PackedByteArray,
) -> bool:
if (
public_key == null
or signature.is_empty()
or signature.size() > MAX_SIGNATURE_BYTES
):
return false
var value := digest(domain, fields)
return (
not value.is_empty()
and Crypto.new().verify(
HashingContext.HASH_SHA256, value, signature, public_key
)
)
static func load_public_key(public_pem: String) -> CryptoKey:
var normalized := normalize_public_pem(public_pem)
if normalized.is_empty() or normalized.to_utf8_buffer().size() > MAX_PUBLIC_KEY_BYTES:
return null
var key := CryptoKey.new()
return key if key.load_from_string(normalized, true) == OK else null
static func _append_string(output: PackedByteArray, value: String) -> void:
_append_bytes(output, value.to_utf8_buffer())
static func _append_bytes(output: PackedByteArray, value: PackedByteArray) -> void:
_append_u32(output, value.size())
output.append_array(value)
static func _append_u32(output: PackedByteArray, value: int) -> void:
for shift: int in [24, 16, 8, 0]:
output.append((value >> shift) & 0xff)
static func _append_i64(output: PackedByteArray, value: int) -> void:
for shift: int in [56, 48, 40, 32, 24, 16, 8, 0]:
output.append((value >> shift) & 0xff)

View file

@ -0,0 +1 @@
uid://bjb5n6ayqkb3q

View file

@ -71,6 +71,13 @@ static func validate_send_request(data: Variant) -> bool:
and str(value["reservation_id"]).length() <= MAX_ID_LENGTH and str(value["reservation_id"]).length() <= MAX_ID_LENGTH
and typeof(value.get("attachment")) == TYPE_DICTIONARY and typeof(value.get("attachment")) == TYPE_DICTIONARY
and Dictionary(value["attachment"]).size() <= 3 and Dictionary(value["attachment"]).size() <= 3
and NetworkIdentityCrypto.valid_fingerprint(
value.get("sender_fingerprint")
)
and NetworkIdentityCrypto.valid_fingerprint(
value.get("recipient_fingerprint")
)
and typeof(value.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
) )
@ -94,4 +101,64 @@ static func validate_mail(data: Variant) -> bool:
and typeof(value.get("state")) == TYPE_INT and typeof(value.get("state")) == TYPE_INT
and int(value["state"]) >= 0 and int(value["state"]) >= 0
and int(value["state"]) < State.size() and int(value["state"]) < State.size()
and NetworkIdentityCrypto.valid_fingerprint(
value.get("sender_fingerprint")
) )
and NetworkIdentityCrypto.valid_fingerprint(
value.get("recipient_fingerprint")
)
and typeof(value.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
and typeof(value.get("sender_public_key")) == TYPE_STRING
and str(value["sender_public_key"]).to_utf8_buffer().size()
<= NetworkIdentityCrypto.MAX_PUBLIC_KEY_BYTES
)
static func mail_signature_fields(data: Dictionary) -> Array:
var result: Array = [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("sender_fingerprint", "")),
str(data.get("recipient_fingerprint", "")),
str(data.get("greeting_id", "")),
sanitize_body(data.get("body")),
str(data.get("salutation_id", "")),
str(data.get("reservation_id", "")),
]
result.append_array(attachment_signature_fields(data.get("attachment", {})))
return result
static func attachment_signature_fields(value: Variant) -> Array:
if typeof(value) != TYPE_DICTIONARY:
return [0]
var attachment: Dictionary = value
var type := int(attachment.get("type", 0))
var result: Array = [type]
match type:
1:
result.append(int(attachment.get("amount", 0)))
2:
var fish: Dictionary = attachment.get("catch", {})
result.append(str(attachment.get("catch_id", "")))
result.append(str(fish.get("fish_id", "")))
result.append(str(fish.get("weight_lb", "")))
result.append(str(fish.get("display_scale", "")))
result.append(int(fish.get("sale_value", 0)))
result.append(bool(fish.get("is_favorited", false)))
3:
result.append(str(attachment.get("item_id", "")))
result.append(int(attachment.get("quantity", 0)))
return result
static func acceptance_signature_fields(
data: Dictionary, sender_fingerprint: String, recipient_fingerprint: String
) -> Array:
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("mail_id", "")),
sender_fingerprint,
recipient_fingerprint,
]

View file

@ -112,16 +112,25 @@ func get_recipient_choices() -> Array[Dictionary]:
continue continue
var record := _session.get_peer_record(peer_id) var record := _session.get_peer_record(peer_id)
if record != null: if record != null:
choices.append({"peer_id": peer_id, "name": record.display_name}) choices.append({
"peer_id": peer_id,
"name": record.display_name,
"fingerprint": record.identity_fingerprint,
})
choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0 return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0
) )
var seen: Dictionary[String, int] = {} var counts: Dictionary[String, int] = {}
for choice: Dictionary in choices:
var normalized := str(choice["name"]).to_lower()
counts[normalized] = int(counts.get(normalized, 0)) + 1
for choice: Dictionary in choices: for choice: Dictionary in choices:
var name: String = choice["name"] var name: String = choice["name"]
seen[name] = int(seen.get(name, 0)) + 1 if int(counts.get(name.to_lower(), 0)) > 1:
if seen[name] > 1: choice["label"] = "%s · %s" % [
choice["label"] = "%s (%d)" % [name, seen[name]] name,
NetworkIdentityCrypto.compact_suffix(choice["fingerprint"]),
]
else: else:
choice["label"] = name choice["label"] = name
return choices return choices
@ -164,7 +173,14 @@ func send_letter(
"salutation_id": salutation_id, "salutation_id": salutation_id,
"reservation_id": reservation_id, "reservation_id": reservation_id,
"attachment": attachment.duplicate(true), "attachment": attachment.duplicate(true),
"sender_fingerprint": _session.get_local_identity_fingerprint(),
"recipient_fingerprint": (
_session.get_peer_record(recipient_peer_id).identity_fingerprint
),
} }
request["sender_signature"] = _session.sign_local_action(
"mail_send", NetworkMailProtocol.mail_signature_fields(request)
)
if not NetworkMailProtocol.validate_send_request(request): if not NetworkMailProtocol.validate_send_request(request):
if not reservation_id.is_empty(): if not reservation_id.is_empty():
_reservations.release(reservation_id) _reservations.release(reservation_id)
@ -202,6 +218,23 @@ func _handle_send(sender: int, data: Dictionary) -> void:
recipient == sender or not _session.is_authenticated_peer(recipient) recipient == sender or not _session.is_authenticated_peer(recipient)
): ):
error = "That player is no longer connected." error = "That player is no longer connected."
var sender_record := _session.get_peer_record(sender)
var recipient_record := _session.get_peer_record(recipient)
if error.is_empty() and (
sender_record == null
or recipient_record == null
or sender_record.identity_fingerprint
!= str(data.get("sender_fingerprint", ""))
or recipient_record.identity_fingerprint
!= str(data.get("recipient_fingerprint", ""))
or not _session.verify_peer_action(
sender,
"mail_send",
NetworkMailProtocol.mail_signature_fields(data),
data.get("sender_signature", PackedByteArray()),
)
):
error = "Letter identity could not be verified."
if error.is_empty() and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS: if error.is_empty() and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS:
error = "The session mailbox is full." error = "The session mailbox is full."
var recipient_count := 0 var recipient_count := 0
@ -224,14 +257,12 @@ func _handle_send(sender: int, data: Dictionary) -> void:
} }
if error.is_empty(): if error.is_empty():
_sequence += 1 _sequence += 1
var record := _session.get_peer_record(sender)
var recipient_record := _session.get_peer_record(recipient)
var letter := { var letter := {
"mail_id": _new_id("mail"), "mail_id": _new_id("mail"),
"session_id": _session.get_session_id(), "session_id": _session.get_session_id(),
"sequence": _sequence, "sequence": _sequence,
"sender_peer_id": sender, "sender_peer_id": sender,
"sender_display_name": record.display_name, "sender_display_name": sender_record.display_name,
"recipient_peer_id": recipient, "recipient_peer_id": recipient,
"recipient_display_name": recipient_record.display_name, "recipient_display_name": recipient_record.display_name,
"greeting_id": str(data["greeting_id"]), "greeting_id": str(data["greeting_id"]),
@ -240,6 +271,11 @@ func _handle_send(sender: int, data: Dictionary) -> void:
"reservation_id": str(data["reservation_id"]), "reservation_id": str(data["reservation_id"]),
"attachment": attachment.duplicate(true), "attachment": attachment.duplicate(true),
"state": NetworkMailProtocol.State.SENT_UNREAD, "state": NetworkMailProtocol.State.SENT_UNREAD,
"request_id": request_id,
"sender_fingerprint": data["sender_fingerprint"],
"recipient_fingerprint": data["recipient_fingerprint"],
"sender_signature": data["sender_signature"],
"sender_public_key": sender_record.identity_public_key,
} }
_letters[letter["mail_id"]] = letter _letters[letter["mail_id"]] = letter
result["mail"] = letter result["mail"] = letter
@ -272,6 +308,21 @@ func _receive_letter(letter: Dictionary) -> void:
] ]
): ):
return return
var sender_public := NetworkIdentityCrypto.normalize_public_pem(
str(letter.get("sender_public_key", ""))
)
if (
not _session.matches_authenticated_session_identity(
str(letter["sender_fingerprint"]), sender_public
)
or not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(sender_public),
"mail_send",
NetworkMailProtocol.mail_signature_fields(letter),
letter["sender_signature"],
)
):
return
_local_letters[letter["mail_id"]] = letter.duplicate(true) _local_letters[letter["mail_id"]] = letter.duplicate(true)
_emit_mailbox() _emit_mailbox()
@ -344,6 +395,15 @@ func accept_gift(mail_id: String) -> void:
var request := _action_request(mail_id) var request := _action_request(mail_id)
if request.is_empty(): if request.is_empty():
return return
var letter: Dictionary = _local_letters.get(mail_id, {})
request["recipient_signature"] = _session.sign_local_action(
"mail_accept",
NetworkMailProtocol.acceptance_signature_fields(
request,
str(letter.get("sender_fingerprint", "")),
str(letter.get("recipient_fingerprint", "")),
),
)
if _session.is_host(): if _session.is_host():
_handle_accept(_session.get_local_peer_id(), request) _handle_accept(_session.get_local_peer_id(), request)
else: else:
@ -373,9 +433,27 @@ func _handle_accept(peer_id: int, data: Dictionary) -> void:
or not _session.is_authenticated_peer(int(letter["sender_peer_id"])) or not _session.is_authenticated_peer(int(letter["sender_peer_id"]))
): ):
return return
var recipient_record := _session.get_peer_record(peer_id)
if (
recipient_record == null
or recipient_record.identity_fingerprint
!= str(letter.get("recipient_fingerprint", ""))
or not _session.verify_peer_action(
peer_id,
"mail_accept",
NetworkMailProtocol.acceptance_signature_fields(
data,
str(letter.get("sender_fingerprint", "")),
str(letter.get("recipient_fingerprint", "")),
),
data.get("recipient_signature", PackedByteArray()),
)
):
return
_action_ledger[action_id] = true _action_ledger[action_id] = true
var transfer_id := _new_id("mail_transfer") var transfer_id := _new_id("mail_transfer")
letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING
letter["transfer_id"] = transfer_id
_pending_transfers[transfer_id] = { _pending_transfers[transfer_id] = {
"letter": letter, "letter": letter,
"recipient_prepared": false, "recipient_prepared": false,
@ -462,28 +540,66 @@ func _award_recipient(transfer_id: String, letter: Dictionary) -> void:
func _send_phase_ack(phase: String, transfer_id: String, applied: bool) -> void: func _send_phase_ack(phase: String, transfer_id: String, applied: bool) -> void:
var letter := _find_local_transfer_letter(transfer_id)
var fields := _gift_phase_fields(phase, transfer_id, applied, letter)
var fingerprint := _session.get_local_identity_fingerprint()
var signature := _session.sign_local_action(
"gift_%s" % phase, fields
)
if _session.is_host(): if _session.is_host():
_handle_phase_ack(_session.get_local_peer_id(), phase, transfer_id, applied) _handle_phase_ack(
_session.get_local_peer_id(),
phase,
transfer_id,
applied,
fingerprint,
signature,
)
else: else:
acknowledge_transfer_phase.rpc_id(1, phase, transfer_id, applied) acknowledge_transfer_phase.rpc_id(
1, phase, transfer_id, applied, fingerprint, signature
)
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL) @rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
func acknowledge_transfer_phase( func acknowledge_transfer_phase(
phase: String, transfer_id: String, applied: bool phase: String,
transfer_id: String,
applied: bool,
fingerprint: String,
signature: PackedByteArray,
) -> void: ) -> void:
var sender := multiplayer.get_remote_sender_id() var sender := multiplayer.get_remote_sender_id()
if _session.is_host() and _session.is_authenticated_peer(sender): if _session.is_host() and _session.is_authenticated_peer(sender):
_handle_phase_ack(sender, phase, transfer_id, applied) _handle_phase_ack(
sender, phase, transfer_id, applied, fingerprint, signature
)
func _handle_phase_ack( func _handle_phase_ack(
peer_id: int, phase: String, transfer_id: String, applied: bool peer_id: int,
phase: String,
transfer_id: String,
applied: bool,
fingerprint: String,
signature: PackedByteArray,
) -> void: ) -> void:
var transfer: Dictionary = _pending_transfers.get(transfer_id, {}) var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
if transfer.is_empty(): if transfer.is_empty():
return return
var letter: Dictionary = transfer["letter"] var letter: Dictionary = transfer["letter"]
var record := _session.get_peer_record(peer_id)
if (
record == null
or record.identity_fingerprint != fingerprint
or not _session.verify_peer_action(
peer_id,
"gift_%s" % phase,
_gift_phase_fields(phase, transfer_id, applied, letter),
signature,
)
):
return
if phase == "recipient_prepared": if phase == "recipient_prepared":
if peer_id != int(letter["recipient_peer_id"]): if peer_id != int(letter["recipient_peer_id"]):
return return
@ -514,6 +630,30 @@ func _handle_phase_ack(
_finish_transfer(transfer_id, false, "Gift could not be transferred.") _finish_transfer(transfer_id, false, "Gift could not be transferred.")
func _find_local_transfer_letter(transfer_id: String) -> Dictionary:
for value: Dictionary in _local_letters.values():
if str(value.get("transfer_id", "")) == transfer_id:
return value
return {}
func _gift_phase_fields(
phase: String,
transfer_id: String,
applied: bool,
letter: Dictionary,
) -> Array:
return [
_session.get_session_id(),
transfer_id,
str(letter.get("mail_id", "")),
str(letter.get("sender_fingerprint", "")),
str(letter.get("recipient_fingerprint", "")),
phase,
applied,
]
func _request_sender_rollback(peer_id: int, transfer_id: String) -> void: func _request_sender_rollback(peer_id: int, transfer_id: String) -> void:
if peer_id == _session.get_local_peer_id(): if peer_id == _session.get_local_peer_id():
_rollback_sender(transfer_id) _rollback_sender(transfer_id)

View file

@ -34,4 +34,26 @@ static func valid_apply_request(data: Variant) -> bool:
and typeof(data.get("appearance")) == TYPE_DICTIONARY and typeof(data.get("appearance")) == TYPE_DICTIONARY
and valid_snapshot(data["appearance"]) and valid_snapshot(data["appearance"])
and typeof(data.get("use_anyway")) == TYPE_BOOL and typeof(data.get("use_anyway")) == TYPE_BOOL
and NetworkIdentityCrypto.valid_fingerprint(
data.get("sender_fingerprint")
) )
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
)
static func signature_fields(data: Dictionary) -> Array:
var appearance: Dictionary = data.get("appearance", {})
return [
str(data.get("session_id", "")),
str(data.get("request_id", "")),
str(data.get("sender_fingerprint", "")),
str(data.get("display_name", "")),
str(appearance.get("species", "")),
str(appearance.get("fur_pattern", "")),
str(appearance.get("ears", "")),
str(appearance.get("eyes", "")),
str(appearance.get("nose", "")),
str(appearance.get("mouth", "")),
str(appearance.get("tail", "")),
bool(data.get("use_anyway", false)),
]

View file

@ -50,6 +50,13 @@ func get_persisted_appearance() -> Dictionary:
return _appearance_store.get_snapshot() return _appearance_store.get_snapshot()
func get_identity_fingerprint() -> String:
return (
_session.get_local_identity_fingerprint()
if _session != null else ""
)
func request_name_check(display_name: String) -> String: func request_name_check(display_name: String) -> String:
var request_id := _new_id() var request_id := _new_id()
_latest_check_id = request_id _latest_check_id = request_id
@ -95,7 +102,11 @@ func apply_profile(
"display_name": clean_name, "display_name": clean_name,
"appearance": appearance.duplicate(true), "appearance": appearance.duplicate(true),
"use_anyway": use_anyway, "use_anyway": use_anyway,
"sender_fingerprint": _session.get_local_identity_fingerprint(),
} }
request["sender_signature"] = _session.sign_local_action(
"profile_update", NetworkProfileProtocol.signature_fields(request)
)
_pending_apply[request_id] = request _pending_apply[request_id] = request
if _session == null or not _session.is_gameplay_session_active(): if _session == null or not _session.is_gameplay_session_active():
_apply_local_result(request_id, true, "", false, PackedStringArray()) _apply_local_result(request_id, true, "", false, PackedStringArray())
@ -145,6 +156,18 @@ func submit_profile_apply(data: Dictionary) -> void:
or str(data["session_id"]) != _session.get_session_id() or str(data["session_id"]) != _session.get_session_id()
): ):
return return
var record := _session.get_peer_record(sender_id)
if (
record == null
or record.identity_fingerprint != str(data["sender_fingerprint"])
or not _session.verify_peer_action(
sender_id,
"profile_update",
NetworkProfileProtocol.signature_fields(data),
data["sender_signature"],
)
):
return
_process_apply_request(sender_id, data) _process_apply_request(sender_id, data)
@ -163,6 +186,13 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void:
"peer_id": peer_id, "peer_id": peer_id,
"display_name": name, "display_name": name,
"appearance": Dictionary(data["appearance"]).duplicate(true), "appearance": Dictionary(data["appearance"]).duplicate(true),
"authorization": {
"request_id": data["request_id"],
"session_id": data["session_id"],
"sender_fingerprint": data["sender_fingerprint"],
"sender_signature": data["sender_signature"],
"use_anyway": data["use_anyway"],
},
} }
receive_profile_result.rpc_id(peer_id, { receive_profile_result.rpc_id(peer_id, {
"request_id": data["request_id"], "request_id": data["request_id"],
@ -187,8 +217,16 @@ func confirm_profile_saved(request_id: String) -> void:
var name := str(pending["display_name"]) var name := str(pending["display_name"])
var appearance := Dictionary(pending["appearance"]) var appearance := Dictionary(pending["appearance"])
_session.apply_canonical_profile(sender_id, name, appearance) _session.apply_canonical_profile(sender_id, name, appearance)
var record := _session.get_peer_record(sender_id)
if record != null:
record.profile_authorization = pending.get("authorization", {}).duplicate(true)
_apply_to_avatar(sender_id, appearance) _apply_to_avatar(sender_id, appearance)
broadcast_profile_snapshot.rpc(sender_id, name, appearance) broadcast_profile_snapshot.rpc(
sender_id,
name,
appearance,
pending.get("authorization", {}),
)
@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL) @rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
@ -243,10 +281,16 @@ func _apply_local_result(
_preferences.display_name, _preferences.display_name,
_appearance_store.get_snapshot(), _appearance_store.get_snapshot(),
) )
var record := _session.get_peer_record(
_session.get_local_peer_id()
)
if record != null:
record.profile_authorization = request.duplicate(true)
broadcast_profile_snapshot.rpc( broadcast_profile_snapshot.rpc(
_session.get_local_peer_id(), _session.get_local_peer_id(),
_preferences.display_name, _preferences.display_name,
_appearance_store.get_snapshot(), _appearance_store.get_snapshot(),
request,
) )
elif _session.supports_server_capability(&"profile_v1"): elif _session.supports_server_capability(&"profile_v1"):
confirm_profile_saved.rpc_id(1, request_id) confirm_profile_saved.rpc_id(1, request_id)
@ -264,12 +308,30 @@ func broadcast_profile_snapshot(
peer_id: int, peer_id: int,
display_name: String, display_name: String,
appearance: Dictionary, appearance: Dictionary,
authorization: Dictionary = {},
) -> void: ) -> void:
if ( if (
not NetworkProfilePreferences.is_valid_display_name(display_name) not NetworkProfilePreferences.is_valid_display_name(display_name)
or not CharacterCustomizationCatalog.validate_snapshot(appearance) or not CharacterCustomizationCatalog.validate_snapshot(appearance)
): ):
return return
if not authorization.is_empty():
var signed := authorization.duplicate(true)
signed["display_name"] = display_name
signed["appearance"] = appearance
var record := _session.get_peer_record(peer_id)
if (
record == null
or record.identity_fingerprint
!= str(signed.get("sender_fingerprint", ""))
or not _session.verify_peer_action(
peer_id,
"profile_update",
NetworkProfileProtocol.signature_fields(signed),
signed.get("sender_signature", PackedByteArray()),
)
):
return
_session.apply_canonical_profile(peer_id, display_name, appearance) _session.apply_canonical_profile(peer_id, display_name, appearance)
_apply_to_avatar(peer_id, appearance) _apply_to_avatar(peer_id, appearance)
profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true)) profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true))
@ -287,6 +349,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
existing_id, existing_id,
record.display_name, record.display_name,
record.appearance_snapshot, record.appearance_snapshot,
record.profile_authorization,
) )

View file

@ -1,11 +1,13 @@
class_name NetworkProtocol class_name NetworkProtocol
extends RefCounted extends RefCounted
const PROTOCOL_VERSION: int = 2 const PROTOCOL_VERSION: int = 3
const GAME_BUILD: String = "prealpha" const GAME_BUILD: String = "prealpha"
const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_DISPLAY_NAME_LENGTH: int = 24
const MAX_PROFILE_ID_LENGTH: int = 96 const MAX_PROFILE_ID_LENGTH: int = 96
const MAX_NONCE_LENGTH: int = 96 const MAX_NONCE_LENGTH: int = 96
const MAX_PUBLIC_KEY_LENGTH: int = 8192
const MAX_SIGNATURE_LENGTH: int = 2048
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement # ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
# 6 reliable shop transactions, 7 reliable item/equipment lifecycle, # 6 reliable shop transactions, 7 reliable item/equipment lifecycle,
@ -23,17 +25,56 @@ enum RejectionCode {
PROTOCOL_MISMATCH, PROTOCOL_MISMATCH,
SERVER_FULL, SERVER_FULL,
DUPLICATE_PROFILE, DUPLICATE_PROFILE,
DUPLICATE_IDENTITY,
INVALID_IDENTITY_PROOF,
AUTHENTICATION_TIMEOUT, AUTHENTICATION_TIMEOUT,
SERVER_SHUTTING_DOWN, SERVER_SHUTTING_DOWN,
UNSUPPORTED_CLIENT, UNSUPPORTED_CLIENT,
} }
static func make_identity_hello(
public_key: String,
fingerprint: String,
client_nonce: String,
attempt_id: String,
) -> Dictionary:
return {
"protocol_version": PROTOCOL_VERSION,
"public_key": public_key,
"fingerprint": fingerprint,
"client_nonce": client_nonce,
"attempt_id": attempt_id,
"capability_flags": PackedStringArray(["identity_v1"]),
}
static func validate_identity_hello(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var value: Dictionary = data
return (
typeof(value.get("protocol_version")) == TYPE_INT
and typeof(value.get("public_key")) == TYPE_STRING
and str(value["public_key"]).to_utf8_buffer().size() <= MAX_PUBLIC_KEY_LENGTH
and NetworkIdentityCrypto.valid_fingerprint(value.get("fingerprint"))
and typeof(value.get("client_nonce")) == TYPE_STRING
and str(value["client_nonce"]).length() == 64
and typeof(value.get("attempt_id")) == TYPE_STRING
and str(value["attempt_id"]).length() == 32
and typeof(value.get("capability_flags")) in [
TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
]
)
static func make_client_hello( static func make_client_hello(
profile_id: String, profile_id: String,
display_name: String, display_name: String,
client_nonce: String, client_nonce: String,
cosmetic_snapshot: Dictionary = {}, cosmetic_snapshot: Dictionary = {},
identity_fingerprint: String = "",
identity_signature: PackedByteArray = PackedByteArray(),
) -> Dictionary: ) -> Dictionary:
return { return {
"protocol_version": PROTOCOL_VERSION, "protocol_version": PROTOCOL_VERSION,
@ -43,6 +84,8 @@ static func make_client_hello(
"client_nonce": client_nonce, "client_nonce": client_nonce,
"capability_flags": PackedStringArray(), "capability_flags": PackedStringArray(),
"cosmetic_snapshot": cosmetic_snapshot, "cosmetic_snapshot": cosmetic_snapshot,
"identity_fingerprint": identity_fingerprint,
"identity_signature": identity_signature,
} }
@ -58,6 +101,8 @@ static func validate_client_hello(data: Variant) -> String:
"client_nonce", "client_nonce",
"capability_flags", "capability_flags",
"cosmetic_snapshot", "cosmetic_snapshot",
"identity_fingerprint",
"identity_signature",
]: ]:
if not payload.has(key): if not payload.has(key):
return "Handshake is missing %s." % key return "Handshake is missing %s." % key
@ -78,6 +123,13 @@ static func validate_client_hello(data: Variant) -> String:
return "Capabilities are invalid." return "Capabilities are invalid."
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY: if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
return "Cosmetic snapshot is invalid." return "Cosmetic snapshot is invalid."
if (
not NetworkIdentityCrypto.valid_fingerprint(
payload["identity_fingerprint"]
)
or typeof(payload["identity_signature"]) != TYPE_PACKED_BYTE_ARRAY
):
return "Profile identity proof is invalid."
var profile_id: String = payload["local_profile_id"] var profile_id: String = payload["local_profile_id"]
var display_name: String = payload["display_name"] var display_name: String = payload["display_name"]
var nonce: String = payload["client_nonce"] var nonce: String = payload["client_nonce"]
@ -93,6 +145,23 @@ static func validate_client_hello(data: Variant) -> String:
return "" return ""
static func client_profile_fields(data: Dictionary) -> Array:
var appearance: Dictionary = data.get("cosmetic_snapshot", {})
return [
str(data.get("client_nonce", "")),
str(data.get("identity_fingerprint", "")),
str(data.get("local_profile_id", "")),
str(data.get("display_name", "")),
str(appearance.get("species", "")),
str(appearance.get("fur_pattern", "")),
str(appearance.get("ears", "")),
str(appearance.get("eyes", "")),
str(appearance.get("nose", "")),
str(appearance.get("mouth", "")),
str(appearance.get("tail", "")),
]
static func make_server_hello( static func make_server_hello(
accepted: bool, accepted: bool,
rejection_code: RejectionCode, rejection_code: RejectionCode,
@ -120,6 +189,7 @@ static func make_server_hello(
"chat_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",
"identity_v1",
]), ]),
} }
@ -131,7 +201,11 @@ static func rejection_text(code: int) -> String:
RejectionCode.SERVER_FULL: RejectionCode.SERVER_FULL:
return "The server is full." return "The server is full."
RejectionCode.DUPLICATE_PROFILE: RejectionCode.DUPLICATE_PROFILE:
return "This local profile is already connected." return "This legacy local profile is already connected."
RejectionCode.DUPLICATE_IDENTITY:
return "This player identity is already connected."
RejectionCode.INVALID_IDENTITY_PROOF:
return "Player identity authentication failed."
RejectionCode.AUTHENTICATION_TIMEOUT: RejectionCode.AUTHENTICATION_TIMEOUT:
return "The server did not finish authentication." return "The server did not finish authentication."
RejectionCode.SERVER_SHUTTING_DOWN: RejectionCode.SERVER_SHUTTING_DOWN:

View file

@ -5,7 +5,7 @@ const DEFAULT_PORT: int = 7777
const DEFAULT_SESSION_MAX_PLAYERS: int = 8 const DEFAULT_SESSION_MAX_PLAYERS: int = 8
const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31 const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
const CONNECTION_TIMEOUT_SECONDS: float = 10.0 const CONNECTION_TIMEOUT_SECONDS: float = 10.0
const AUTHENTICATION_TIMEOUT_SECONDS: float = 8.0 const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
const INPUT_INTERVAL: float = 1.0 / 25.0 const INPUT_INTERVAL: float = 1.0 / 25.0
const SNAPSHOT_INTERVAL: float = 1.0 / 15.0 const SNAPSHOT_INTERVAL: float = 1.0 / 15.0
@ -23,6 +23,13 @@ signal peer_profile_changed(
appearance: Dictionary, appearance: Dictionary,
) )
signal join_authenticated signal join_authenticated
signal server_trust_required(
endpoint: String,
expected_fingerprint: String,
received_fingerprint: String,
is_changed: bool,
)
signal peer_identity_observed(peer_id: int, status: String)
signal server_lost signal server_lost
signal remote_recovery_requested(peer_id: int, entry_position: Vector3) signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
@ -33,6 +40,7 @@ enum State {
OPEN_HOST, OPEN_HOST,
CONNECTING, CONNECTING,
AUTHENTICATING, AUTHENTICATING,
VERIFYING_SERVER_IDENTITY,
JOINED_CLIENT, JOINED_CLIENT,
DISCONNECTING, DISCONNECTING,
CONNECTION_FAILED, CONNECTION_FAILED,
@ -67,6 +75,17 @@ var _last_server_display_name: String = ""
var _last_server_protocol_version: int = 0 var _last_server_protocol_version: int = 0
var _server_capabilities: PackedStringArray = PackedStringArray() var _server_capabilities: PackedStringArray = PackedStringArray()
var _profile_ready: bool = false var _profile_ready: bool = false
var _player_identity: PlayerIdentityStore
var _host_identity: HostIdentityStore
var _known_players: KnownPlayerStore
var _server_trust: ServerTrustStore
var _pending_identity_challenges: Dictionary[int, Dictionary] = {}
var _authenticated_identity_cache: Dictionary[int, Dictionary] = {}
var _client_identity_attempt: Dictionary = {}
var _pending_server_proof: Dictionary = {}
var _server_identity_fingerprint: String = ""
var _server_identity_public_key: String = ""
var _session_identity_keys: Dictionary[String, String] = {}
var _local_appearance_snapshot: Dictionary = ( var _local_appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot() CharacterCustomizationCatalog.default_snapshot()
) )
@ -84,12 +103,22 @@ func setup(
profile: NetworkProfilePreferences, profile: NetworkProfilePreferences,
saved_servers: SavedServerStore, saved_servers: SavedServerStore,
spawn_service: PlayerSpawnService, spawn_service: PlayerSpawnService,
player_identity: PlayerIdentityStore,
host_identity: HostIdentityStore,
known_players: KnownPlayerStore,
server_trust: ServerTrustStore,
) -> void: ) -> void:
_profile = profile _profile = profile
_saved_servers = saved_servers _saved_servers = saved_servers
_spawn_service = spawn_service _spawn_service = spawn_service
_player_identity = player_identity
_host_identity = host_identity
_known_players = known_players
_server_trust = server_trust
if _profile != null: if _profile != null:
_profile_ready = _profile.load_or_create() _profile_ready = _profile.load_or_create()
if _player_identity != null:
_profile_ready = _profile_ready and _player_identity.load_or_create()
func start_private_host(port: int = DEFAULT_PORT) -> bool: func start_private_host(port: int = DEFAULT_PORT) -> bool:
@ -98,8 +127,12 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool:
or not _profile_ready or not _profile_ready
or _profile == null or _profile == null
or _spawn_service == null or _spawn_service == null
or _host_identity == null
): ):
return false return false
if not _host_identity.load_or_create():
_fail(_host_identity.error_message)
return false
if port < 1 or port > 65535: if port < 1 or port > 65535:
_fail("The hosting port must be from 1 to 65535.") _fail("The hosting port must be from 1 to 65535.")
return false return false
@ -122,9 +155,30 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool:
1, 1,
_profile.profile_id, _profile.profile_id,
_profile.display_name, _profile.display_name,
NetworkProtocol.PROTOCOL_VERSION NetworkProtocol.PROTOCOL_VERSION,
_player_identity.fingerprint,
_player_identity.public_pem,
) )
_registry.update_appearance(1, _local_appearance_snapshot) _registry.update_appearance(1, _local_appearance_snapshot)
var host_profile_hello := NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
NetworkIdentityCrypto.secure_id(32),
_local_appearance_snapshot,
_player_identity.fingerprint,
)
host_profile_hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
NetworkProtocol.client_profile_fields(host_profile_hello),
)
var host_record := _registry.get_peer(1)
if host_record != null:
host_record.profile_authorization = {
"domain": "handshake_client_profile",
"signature": host_profile_hello["identity_signature"],
"client_nonce": host_profile_hello["client_nonce"],
}
_archive_authenticated_identity(host_record)
_spawn_service.clear_remote_players() _spawn_service.clear_remote_players()
_spawn_service.register_local_player(1) _spawn_service.register_local_player(1)
var host_avatar := _spawn_service.get_avatar(1) var host_avatar := _spawn_service.get_avatar(1)
@ -172,7 +226,11 @@ func join_direct(endpoint_text: String) -> bool:
func cancel_connection() -> void: func cancel_connection() -> void:
if state not in [State.CONNECTING, State.AUTHENTICATING]: if state not in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
return return
_operation_generation += 1 _operation_generation += 1
_teardown_peer() _teardown_peer()
@ -291,6 +349,7 @@ func supports_server_capability(capability: StringName) -> bool:
"item_use_v1", "equipment_v1", "chat_v1", "item_use_v1", "equipment_v1", "chat_v1",
"mail_v1", "mail_v1",
"profile_v1", "profile_v1",
"identity_v1",
]) ])
return str(capability) in _server_capabilities return str(capability) in _server_capabilities
@ -303,6 +362,77 @@ func get_authenticated_peer_ids() -> Array[int]:
return _registry.get_peer_ids() return _registry.get_peer_ids()
func get_local_identity_fingerprint() -> String:
return _player_identity.fingerprint if _player_identity != null else ""
func sign_local_action(domain: String, fields: Array) -> PackedByteArray:
return (
_player_identity.sign(domain, fields)
if _player_identity != null else PackedByteArray()
)
func verify_peer_action(
peer_id: int,
domain: String,
fields: Array,
signature: PackedByteArray,
) -> bool:
var record := _registry.get_peer(peer_id)
if record == null or not record.identity_authenticated:
return false
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(record.identity_public_key),
domain,
fields,
signature,
)
func matches_authenticated_session_identity(
fingerprint: String, public_pem: String
) -> bool:
var normalized := NetworkIdentityCrypto.normalize_public_pem(public_pem)
return (
NetworkIdentityCrypto.valid_fingerprint(fingerprint)
and NetworkIdentityCrypto.fingerprint_public_pem(normalized)
== fingerprint
and _session_identity_keys.get(fingerprint, "") == normalized
)
func sign_host_action(domain: String, fields: Array) -> PackedByteArray:
return (
_host_identity.sign(domain, fields)
if is_host() and _host_identity != null else PackedByteArray()
)
func get_host_identity_fingerprint() -> String:
return (
_host_identity.fingerprint
if is_host() and _host_identity != null
else _server_identity_fingerprint
)
func verify_host_action(
domain: String, fields: Array, signature: PackedByteArray
) -> bool:
var public_pem: String = (
_host_identity.public_pem
if is_host() and _host_identity != null
else _server_identity_public_key
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
domain,
fields,
signature,
)
func update_local_display_name(value: String) -> bool: func update_local_display_name(value: String) -> bool:
if _profile == null or not _profile.set_display_name(value): if _profile == null or not _profile.set_display_name(value):
return false return false
@ -372,7 +502,11 @@ func get_local_peer_id() -> int:
func _process(delta: float) -> void: func _process(delta: float) -> void:
var now: float = Time.get_ticks_msec() / 1000.0 var now: float = Time.get_ticks_msec() / 1000.0
if ( if (
state in [State.CONNECTING, State.AUTHENTICATING] state in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]
and _connection_deadline > 0.0 and _connection_deadline > 0.0
and now >= _connection_deadline and now >= _connection_deadline
): ):
@ -434,18 +568,22 @@ func _on_connected_to_server() -> void:
if state != State.CONNECTING: if state != State.CONNECTING:
return return
_set_state(State.AUTHENTICATING, "Authenticating...") _set_state(State.AUTHENTICATING, "Authenticating...")
_client_nonce = Crypto.new().generate_random_bytes(16).hex_encode() _client_nonce = NetworkIdentityCrypto.secure_id(32)
var hello: Dictionary = NetworkProtocol.make_client_hello( _client_identity_attempt = NetworkProtocol.make_identity_hello(
_profile.profile_id, _player_identity.public_pem,
_profile.display_name, _player_identity.fingerprint,
_client_nonce, _client_nonce,
_local_appearance_snapshot, NetworkIdentityCrypto.secure_id(16),
) )
submit_client_hello.rpc_id(1, hello) submit_identity_hello.rpc_id(1, _client_identity_attempt)
func _on_connection_failed() -> void: func _on_connection_failed() -> void:
if state not in [State.CONNECTING, State.AUTHENTICATING]: if state not in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
return return
_teardown_peer() _teardown_peer()
_fail("Could not connect. The server may be private, unavailable, or unreachable.") _fail("Could not connect. The server may be private, unavailable, or unreachable.")
@ -456,6 +594,13 @@ func _on_server_disconnected() -> void:
return return
_operation_generation += 1 _operation_generation += 1
_teardown_peer() _teardown_peer()
if state in [
State.CONNECTING,
State.AUTHENTICATING,
State.VERIFYING_SERVER_IDENTITY,
]:
_fail("The server rejected or ended authentication.")
return
_set_state(State.SERVER_LOST, "The server connection was lost.") _set_state(State.SERVER_LOST, "The server connection was lost.")
server_lost.emit() server_lost.emit()
connection_error.emit("The server connection was lost.") connection_error.emit("The server connection was lost.")
@ -463,6 +608,8 @@ func _on_server_disconnected() -> void:
func _on_peer_disconnected(peer_id: int) -> void: func _on_peer_disconnected(peer_id: int) -> void:
_pending_authentication.erase(peer_id) _pending_authentication.erase(peer_id)
_pending_identity_challenges.erase(peer_id)
_authenticated_identity_cache.erase(peer_id)
if _registry.has_peer(peer_id): if _registry.has_peer(peer_id):
_registry.remove_peer(peer_id) _registry.remove_peer(peer_id)
_spawn_service.remove_peer(peer_id) _spawn_service.remove_peer(peer_id)
@ -472,6 +619,190 @@ func _on_peer_disconnected(peer_id: int) -> void:
_emit_peer_count() _emit_peer_count()
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_identity_hello(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id()
if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
return
if (
not NetworkProtocol.validate_identity_hello(data)
or int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
)
return
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["public_key"])
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
if (
fingerprint != str(data["fingerprint"])
or NetworkIdentityCrypto.load_public_key(public_pem) == null
):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF
)
return
if _registry.has_fingerprint(fingerprint):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.DUPLICATE_IDENTITY)
return
var server_nonce := NetworkIdentityCrypto.secure_id(32)
var challenge := {
"peer_id": sender_id,
"attempt_id": str(data["attempt_id"]),
"client_nonce": str(data["client_nonce"]),
"client_fingerprint": fingerprint,
"client_public_key": public_pem,
"server_nonce": server_nonce,
"server_fingerprint": _host_identity.fingerprint,
"session_id": _session_id,
"generation": _operation_generation,
"expires_at_msec": Time.get_ticks_msec() + 60000,
}
var fields := _identity_proof_fields(challenge)
var proof := challenge.duplicate(true)
proof["server_public_key"] = _host_identity.public_pem
proof["server_signature"] = _host_identity.sign(
"handshake_server_proof", fields
)
_pending_identity_challenges[sender_id] = challenge
receive_server_identity_proof.rpc_id(sender_id, proof)
@rpc("authority", "call_remote", "reliable", 0)
func receive_server_identity_proof(data: Dictionary) -> void:
if state != State.AUTHENTICATING or not _valid_server_proof_shape(data):
return
if (
str(data["attempt_id"]) != str(_client_identity_attempt.get("attempt_id", ""))
or str(data["client_nonce"]) != _client_nonce
):
_fail_identity("The server identity proof did not match this connection.")
return
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["server_public_key"])
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
var public_key := NetworkIdentityCrypto.load_public_key(public_pem)
if (
fingerprint != str(data["server_fingerprint"])
or not NetworkIdentityCrypto.verify_fields(
public_key,
"handshake_server_proof",
_identity_proof_fields(data),
data["server_signature"],
)
):
_fail_identity("The server identity proof was invalid.")
return
_pending_server_proof = data.duplicate(true)
_server_identity_fingerprint = fingerprint
_server_identity_public_key = public_pem
var verification := _server_trust.verify(
get_current_endpoint(), fingerprint
)
if verification == ServerTrustStore.Verification.MATCH:
_server_trust.touch(get_current_endpoint())
_send_client_identity_proof()
return
_set_state(
State.VERIFYING_SERVER_IDENTITY,
"Confirm this server identity before continuing.",
)
_connection_deadline = 0.0
var expected := str(
_server_trust.get_record(get_current_endpoint()).get("fingerprint", "")
)
server_trust_required.emit(
get_current_route_display(),
expected,
fingerprint,
verification == ServerTrustStore.Verification.CHANGED,
)
func resolve_server_trust(accepted: bool) -> void:
if state != State.VERIFYING_SERVER_IDENTITY:
return
if not accepted:
cancel_connection()
return
if not _server_trust.trust(
get_current_endpoint(),
str(_pending_server_proof["server_fingerprint"]),
"NETFISHING",
):
_fail_identity("The server identity could not be pinned.")
return
_set_state(State.AUTHENTICATING, "Authenticating identity...")
_connection_deadline = (
Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
)
_send_client_identity_proof()
func _send_client_identity_proof() -> void:
var proof := {
"attempt_id": _pending_server_proof["attempt_id"],
"session_id": _pending_server_proof["session_id"],
"client_fingerprint": _player_identity.fingerprint,
"client_signature": _player_identity.sign(
"handshake_client_proof",
_identity_proof_fields(_pending_server_proof),
),
}
submit_client_identity_proof.rpc_id(1, proof)
@rpc("any_peer", "call_remote", "reliable", 0)
func submit_client_identity_proof(data: Dictionary) -> void:
var sender_id := multiplayer.get_remote_sender_id()
var challenge: Dictionary = _pending_identity_challenges.get(sender_id, {})
if (
not is_host()
or challenge.is_empty()
or Time.get_ticks_msec() > int(challenge["expires_at_msec"])
or data.get("attempt_id") != challenge["attempt_id"]
or data.get("session_id") != _session_id
or data.get("client_fingerprint") != challenge["client_fingerprint"]
or typeof(data.get("client_signature")) != TYPE_PACKED_BYTE_ARRAY
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
var verified := NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(challenge["client_public_key"]),
"handshake_client_proof",
_identity_proof_fields(challenge),
data["client_signature"],
)
_pending_identity_challenges.erase(sender_id)
if not verified:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return
_authenticated_identity_cache[sender_id] = {
"fingerprint": challenge["client_fingerprint"],
"public_key": challenge["client_public_key"],
}
request_client_profile.rpc_id(sender_id)
@rpc("authority", "call_remote", "reliable", 0)
func request_client_profile() -> void:
if state != State.AUTHENTICATING:
return
var hello := NetworkProtocol.make_client_hello(
_profile.profile_id,
_profile.display_name,
_client_nonce,
_local_appearance_snapshot,
_player_identity.fingerprint,
)
hello["identity_signature"] = _player_identity.sign(
"handshake_client_profile",
NetworkProtocol.client_profile_fields(hello),
)
submit_client_hello.rpc_id(1, hello)
@rpc("any_peer", "call_remote", "reliable", 0) @rpc("any_peer", "call_remote", "reliable", 0)
func submit_client_hello(data: Dictionary) -> void: func submit_client_hello(data: Dictionary) -> void:
var sender_id: int = multiplayer.get_remote_sender_id() var sender_id: int = multiplayer.get_remote_sender_id()
@ -479,27 +810,27 @@ func submit_client_hello(data: Dictionary) -> void:
return return
var validation_error: String = NetworkProtocol.validate_client_hello(data) var validation_error: String = NetworkProtocol.validate_client_hello(data)
if not validation_error.is_empty(): if not validation_error.is_empty():
_reject_peer( _reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE)
sender_id,
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
)
return return
if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION: var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
_reject_peer( if identity.is_empty():
sender_id, _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH return
if (
data["identity_fingerprint"] != identity["fingerprint"]
or not NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(identity["public_key"]),
"handshake_client_profile",
NetworkProtocol.client_profile_fields(data),
data["identity_signature"],
) )
):
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
return return
if _registry.size() >= session_max_players: if _registry.size() >= session_max_players:
_reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL) _reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL)
return return
var profile_id: String = data["local_profile_id"] var profile_id: String = data["local_profile_id"]
if _registry.has_profile(profile_id):
_reject_peer(
sender_id,
NetworkProtocol.RejectionCode.DUPLICATE_PROFILE
)
return
var display_name: String = data["display_name"] var display_name: String = data["display_name"]
if not NetworkProfilePreferences.is_valid_display_name(display_name): if not NetworkProfilePreferences.is_valid_display_name(display_name):
_reject_peer( _reject_peer(
@ -511,7 +842,9 @@ func submit_client_hello(data: Dictionary) -> void:
sender_id, sender_id,
profile_id, profile_id,
display_name, display_name,
NetworkProtocol.PROTOCOL_VERSION NetworkProtocol.PROTOCOL_VERSION,
identity["fingerprint"],
identity["public_key"],
): ):
_reject_peer( _reject_peer(
sender_id, sender_id,
@ -522,7 +855,20 @@ func submit_client_hello(data: Dictionary) -> void:
data["cosmetic_snapshot"] data["cosmetic_snapshot"]
) )
_registry.update_appearance(sender_id, submitted_appearance) _registry.update_appearance(sender_id, submitted_appearance)
var peer_record := _registry.get_peer(sender_id)
if peer_record != null:
peer_record.profile_authorization = {
"domain": "handshake_client_profile",
"signature": data["identity_signature"],
"client_nonce": data["client_nonce"],
}
_archive_authenticated_identity(peer_record)
var identity_status := _known_players.observe(
str(identity["fingerprint"]), display_name
)
peer_identity_observed.emit(sender_id, identity_status)
_pending_authentication.erase(sender_id) _pending_authentication.erase(sender_id)
_authenticated_identity_cache.erase(sender_id)
var spawn_index: int = _registry.get_peer_ids().find(sender_id) var spawn_index: int = _registry.get_peer_ids().find(sender_id)
var spawn_transform: Transform3D = ( var spawn_transform: Transform3D = (
_spawn_service.get_spawn_transform_for_index(spawn_index) _spawn_service.get_spawn_transform_for_index(spawn_index)
@ -625,9 +971,14 @@ func receive_server_hello(data: Dictionary) -> void:
local_peer_id, local_peer_id,
_profile.profile_id, _profile.profile_id,
_profile.display_name, _profile.display_name,
NetworkProtocol.PROTOCOL_VERSION NetworkProtocol.PROTOCOL_VERSION,
_player_identity.fingerprint,
_player_identity.public_pem,
) )
_registry.update_appearance(local_peer_id, _local_appearance_snapshot) _registry.update_appearance(local_peer_id, _local_appearance_snapshot)
var local_record := _registry.get_peer(local_peer_id)
if local_record != null:
_archive_authenticated_identity(local_record)
_spawn_service.clear_remote_players() _spawn_service.clear_remote_players()
_spawn_service.register_local_player(local_peer_id) _spawn_service.register_local_player(local_peer_id)
var local_avatar := _spawn_service.get_avatar(local_peer_id) var local_avatar := _spawn_service.get_avatar(local_peer_id)
@ -681,6 +1032,8 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT] or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
): ):
return return
if not _verify_spawn_identity(entry):
return
var peer_id: int = entry["peer_id"] var peer_id: int = entry["peer_id"]
if peer_id == multiplayer.get_unique_id(): if peer_id == multiplayer.get_unique_id():
var own_position: Array = entry["position"] var own_position: Array = entry["position"]
@ -699,8 +1052,18 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
peer_id, peer_id,
entry["profile_id"], entry["profile_id"],
entry["display_name"], entry["display_name"],
NetworkProtocol.PROTOCOL_VERSION NetworkProtocol.PROTOCOL_VERSION,
str(entry.get("identity_fingerprint", "")),
str(entry.get("identity_public_key", "")),
) )
var added_record := _registry.get_peer(peer_id)
if added_record != null and added_record.identity_authenticated:
_archive_authenticated_identity(added_record)
var status := _known_players.observe(
added_record.identity_fingerprint,
added_record.display_name,
)
peer_identity_observed.emit(peer_id, status)
if typeof(entry.get("appearance")) == TYPE_DICTIONARY: if typeof(entry.get("appearance")) == TYPE_DICTIONARY:
var appearance := CharacterCustomizationCatalog.sanitized_snapshot( var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
entry["appearance"] entry["appearance"]
@ -757,9 +1120,121 @@ func _make_spawn_entry(
if record != null if record != null
else CharacterCustomizationCatalog.default_snapshot() else CharacterCustomizationCatalog.default_snapshot()
), ),
"identity_fingerprint": (
record.identity_fingerprint if record != null else ""
),
"identity_public_key": (
record.identity_public_key if record != null else ""
),
"profile_authorization": (
record.profile_authorization.duplicate(true)
if record != null else {}
),
} }
func _verify_spawn_identity(entry: Dictionary) -> bool:
var fingerprint := str(entry.get("identity_fingerprint", ""))
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
str(entry.get("identity_public_key", ""))
)
if (
NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
or typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY
):
return false
var authorization: Dictionary = entry["profile_authorization"]
if authorization.is_empty():
return int(entry.get("peer_id", 0)) == 1
if authorization.get("domain") == "handshake_client_profile":
var hello := NetworkProtocol.make_client_hello(
str(entry.get("profile_id", "")),
str(entry.get("display_name", "")),
str(authorization.get("client_nonce", "")),
Dictionary(entry.get("appearance", {})),
fingerprint,
authorization.get("signature", PackedByteArray()),
)
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"handshake_client_profile",
NetworkProtocol.client_profile_fields(hello),
hello["identity_signature"],
)
if authorization.has("sender_signature"):
var signed := authorization.duplicate(true)
signed["display_name"] = entry.get("display_name", "")
signed["appearance"] = entry.get("appearance", {})
return NetworkIdentityCrypto.verify_fields(
NetworkIdentityCrypto.load_public_key(public_pem),
"profile_update",
NetworkProfileProtocol.signature_fields(signed),
signed["sender_signature"],
)
return false
func _identity_proof_fields(data: Dictionary) -> Array:
return [
NetworkProtocol.PROTOCOL_VERSION,
str(data.get("attempt_id", "")),
str(data.get("client_fingerprint", "")),
str(data.get("client_nonce", "")),
str(data.get("server_fingerprint", "")),
str(data.get("server_nonce", "")),
str(data.get("session_id", "")),
int(data.get("peer_id", 0)),
int(data.get("generation", 0)),
int(data.get("expires_at_msec", 0)),
]
func _archive_authenticated_identity(record: PeerRegistry.PeerRecord) -> void:
if (
record != null
and record.identity_authenticated
and NetworkIdentityCrypto.fingerprint_public_pem(
record.identity_public_key
) == record.identity_fingerprint
):
_session_identity_keys[record.identity_fingerprint] = (
NetworkIdentityCrypto.normalize_public_pem(
record.identity_public_key
)
)
func _valid_server_proof_shape(data: Variant) -> bool:
if typeof(data) != TYPE_DICTIONARY:
return false
var value: Dictionary = data
return (
typeof(value.get("attempt_id")) == TYPE_STRING
and str(value["attempt_id"]).length() == 32
and typeof(value.get("client_nonce")) == TYPE_STRING
and str(value["client_nonce"]).length() == 64
and typeof(value.get("server_nonce")) == TYPE_STRING
and str(value["server_nonce"]).length() == 64
and NetworkIdentityCrypto.valid_fingerprint(value.get("client_fingerprint"))
and NetworkIdentityCrypto.valid_fingerprint(value.get("server_fingerprint"))
and typeof(value.get("server_public_key")) == TYPE_STRING
and str(value["server_public_key"]).to_utf8_buffer().size()
<= NetworkProtocol.MAX_PUBLIC_KEY_LENGTH
and typeof(value.get("server_signature")) == TYPE_PACKED_BYTE_ARRAY
and PackedByteArray(value["server_signature"]).size()
<= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
and typeof(value.get("session_id")) == TYPE_STRING
and typeof(value.get("peer_id")) == TYPE_INT
and typeof(value.get("generation")) == TYPE_INT
and typeof(value.get("expires_at_msec")) == TYPE_INT
)
func _fail_identity(message: String) -> void:
_teardown_peer()
_fail(message)
func _send_local_input() -> void: func _send_local_input() -> void:
var peer_id: int = multiplayer.get_unique_id() var peer_id: int = multiplayer.get_unique_id()
var avatar: Player = _spawn_service.get_avatar(peer_id) var avatar: Player = _spawn_service.get_avatar(peer_id)
@ -945,11 +1420,18 @@ func _is_transition_allowed(from_state: State, to_state: State) -> bool:
State.INACTIVE, State.INACTIVE,
], ],
State.AUTHENTICATING: [ State.AUTHENTICATING: [
State.VERIFYING_SERVER_IDENTITY,
State.JOINED_CLIENT, State.JOINED_CLIENT,
State.CONNECTION_FAILED, State.CONNECTION_FAILED,
State.DISCONNECTING, State.DISCONNECTING,
State.INACTIVE, State.INACTIVE,
], ],
State.VERIFYING_SERVER_IDENTITY: [
State.AUTHENTICATING,
State.CONNECTION_FAILED,
State.DISCONNECTING,
State.INACTIVE,
],
State.JOINED_CLIENT: [ State.JOINED_CLIENT: [
State.DISCONNECTING, State.DISCONNECTING,
State.SERVER_LOST, State.SERVER_LOST,
@ -979,6 +1461,10 @@ func _fail(message: String) -> void:
func _teardown_peer() -> void: func _teardown_peer() -> void:
_pending_authentication.clear() _pending_authentication.clear()
_pending_identity_challenges.clear()
_authenticated_identity_cache.clear()
_client_identity_attempt.clear()
_pending_server_proof.clear()
_registry.clear() _registry.clear()
if _spawn_service != null: if _spawn_service != null:
_spawn_service.clear_remote_players() _spawn_service.clear_remote_players()
@ -989,3 +1475,6 @@ func _teardown_peer() -> void:
_input_accumulator = 0.0 _input_accumulator = 0.0
_snapshot_accumulator = 0.0 _snapshot_accumulator = 0.0
_server_capabilities = PackedStringArray() _server_capabilities = PackedStringArray()
_server_identity_fingerprint = ""
_server_identity_public_key = ""
_session_identity_keys.clear()

View file

@ -12,6 +12,10 @@ class PeerRecord:
var appearance_snapshot: Dictionary = ( var appearance_snapshot: Dictionary = (
CharacterCustomizationCatalog.default_snapshot() CharacterCustomizationCatalog.default_snapshot()
) )
var identity_fingerprint: String = ""
var identity_public_key: String = ""
var identity_authenticated: bool = false
var profile_authorization: Dictionary = {}
var _records: Dictionary[int, PeerRecord] = {} var _records: Dictionary[int, PeerRecord] = {}
@ -22,8 +26,17 @@ func add_peer(
profile_id: String, profile_id: String,
display_name: String, display_name: String,
protocol_version: int, protocol_version: int,
identity_fingerprint: String = "",
identity_public_key: String = "",
) -> bool: ) -> bool:
if peer_id <= 0 or profile_id.is_empty() or has_profile(profile_id): if (
peer_id <= 0
or profile_id.is_empty()
or (
not identity_fingerprint.is_empty()
and has_fingerprint(identity_fingerprint)
)
):
return false return false
var record := PeerRecord.new() var record := PeerRecord.new()
record.peer_id = peer_id record.peer_id = peer_id
@ -31,6 +44,11 @@ func add_peer(
record.display_name = display_name record.display_name = display_name
record.protocol_version = protocol_version record.protocol_version = protocol_version
record.joined_at_unix = int(Time.get_unix_time_from_system()) record.joined_at_unix = int(Time.get_unix_time_from_system())
record.identity_fingerprint = identity_fingerprint
record.identity_public_key = identity_public_key
record.identity_authenticated = NetworkIdentityCrypto.valid_fingerprint(
identity_fingerprint
)
_records[peer_id] = record _records[peer_id] = record
return true return true
@ -72,6 +90,13 @@ func has_profile(profile_id: String) -> bool:
return false return false
func has_fingerprint(fingerprint: String) -> bool:
for record: PeerRecord in _records.values():
if record.identity_fingerprint == fingerprint:
return true
return false
func get_peer_ids() -> Array[int]: func get_peer_ids() -> Array[int]:
var result: Array[int] = [] var result: Array[int] = []
for peer_id: int in _records: for peer_id: int in _records:

View file

@ -0,0 +1,6 @@
class_name PlayerIdentityStore
extends "res://network/local_signing_identity_store.gd"
func _ready() -> void:
configure("player_identity")

View file

@ -0,0 +1 @@
uid://cqlgolkbx2riq

View file

@ -0,0 +1,127 @@
class_name ServerTrustStore
extends Node
const FORMAT_VERSION: int = 1
const STORE_PATH: String = "user://server_trust.json"
const TEMP_PATH: String = STORE_PATH + ".tmp"
enum Verification {
FIRST_SEEN,
MATCH,
CHANGED,
}
var _records: Dictionary = {}
var _loaded: bool = false
var _write_blocked: bool = false
func verify(endpoint: ConnectionEndpoint, fingerprint: String) -> Verification:
_ensure_loaded()
if endpoint == null or not endpoint.is_valid():
return Verification.CHANGED
var record: Dictionary = _records.get(endpoint.normalized_display, {})
if record.is_empty():
return Verification.FIRST_SEEN
return (
Verification.MATCH
if record.get("fingerprint") == fingerprint
else Verification.CHANGED
)
func get_record(endpoint: ConnectionEndpoint) -> Dictionary:
_ensure_loaded()
if endpoint == null:
return {}
return Dictionary(_records.get(endpoint.normalized_display, {})).duplicate(true)
func trust(
endpoint: ConnectionEndpoint,
fingerprint: String,
server_name: String = "",
) -> bool:
_ensure_loaded()
if (
endpoint == null
or not endpoint.is_valid()
or not NetworkIdentityCrypto.valid_fingerprint(fingerprint)
):
return false
var now := int(Time.get_unix_time_from_system())
var previous: Dictionary = _records.get(endpoint.normalized_display, {})
_records[endpoint.normalized_display] = {
"route_kind": "direct",
"normalized_host": endpoint.host,
"port": endpoint.port,
"normalized_endpoint": endpoint.normalized_display,
"fingerprint": fingerprint,
"first_seen_unix": int(previous.get("first_seen_unix", now)),
"last_seen_unix": now,
"last_observed_server_name": server_name.left(80),
"trust_state": "pinned",
}
return _save()
func touch(endpoint: ConnectionEndpoint) -> void:
_ensure_loaded()
if endpoint == null:
return
var record: Dictionary = _records.get(endpoint.normalized_display, {})
if record.is_empty():
return
record["last_seen_unix"] = int(Time.get_unix_time_from_system())
_save()
func _ensure_loaded() -> void:
if _loaded:
return
_loaded = true
if not FileAccess.file_exists(STORE_PATH):
return
var file := FileAccess.open(STORE_PATH, FileAccess.READ)
if file == null:
return
var json := JSON.new()
if json.parse(file.get_as_text()) != OK or typeof(json.data) != TYPE_DICTIONARY:
return
var data: Dictionary = json.data
if data.get("format_version") != FORMAT_VERSION:
_write_blocked = true
return
if typeof(data.get("records")) != TYPE_ARRAY:
return
for value: Variant in data["records"]:
if typeof(value) != TYPE_DICTIONARY:
continue
var record: Dictionary = value
var endpoint := str(record.get("normalized_endpoint", ""))
var fingerprint := str(record.get("fingerprint", ""))
if not endpoint.is_empty() and NetworkIdentityCrypto.valid_fingerprint(fingerprint):
_records[endpoint] = record.duplicate(true)
func _save() -> bool:
if _write_blocked:
return false
var file := FileAccess.open(TEMP_PATH, FileAccess.WRITE)
if file == null:
return false
file.store_string(JSON.stringify({
"format_version": FORMAT_VERSION,
"records": _records.values(),
}, "\t"))
file.flush()
var ok := file.get_error() == OK
file.close()
if not ok:
return false
if FileAccess.file_exists(STORE_PATH):
DirAccess.remove_absolute(ProjectSettings.globalize_path(STORE_PATH))
return DirAccess.rename_absolute(
ProjectSettings.globalize_path(TEMP_PATH),
ProjectSettings.globalize_path(STORE_PATH),
) == OK

View file

@ -0,0 +1 @@
uid://cp2kam2btjkb0

View file

@ -45,6 +45,7 @@ const DIRECT_WORKFLOW_HELP: String = (
var _network_session: NetworkSession var _network_session: NetworkSession
var _saved_servers: SavedServerStore var _saved_servers: SavedServerStore
var _server_trust: ServerTrustStore
var _gameplay_context: bool = false var _gameplay_context: bool = false
var _mode: Mode = Mode.DIRECT var _mode: Mode = Mode.DIRECT
var _visible_entries: Array[SavedServerEntry] = [] var _visible_entries: Array[SavedServerEntry] = []
@ -89,10 +90,12 @@ func setup(
network_session: NetworkSession, network_session: NetworkSession,
saved_servers: SavedServerStore, saved_servers: SavedServerStore,
gameplay_context: bool, gameplay_context: bool,
server_trust: ServerTrustStore = null,
) -> void: ) -> void:
_network_session = network_session _network_session = network_session
_saved_servers = saved_servers _saved_servers = saved_servers
_gameplay_context = gameplay_context _gameplay_context = gameplay_context
_server_trust = server_trust
if not _network_session.state_changed.is_connected(_on_state_changed): if not _network_session.state_changed.is_connected(_on_state_changed):
_network_session.state_changed.connect(_on_state_changed) _network_session.state_changed.connect(_on_state_changed)
if not _network_session.status_message_changed.is_connected( if not _network_session.status_message_changed.is_connected(
@ -447,6 +450,17 @@ func _format_entry_details(entry: SavedServerEntry) -> String:
if _mode == Mode.SAVED: if _mode == Mode.SAVED:
parts.append("Name: %s" % entry.display_name) parts.append("Name: %s" % entry.display_name)
parts.append("Address: %s" % entry.normalized_endpoint) parts.append("Address: %s" % entry.normalized_endpoint)
if _server_trust != null:
var trust := _server_trust.get_record(entry.get_endpoint())
if trust.is_empty():
parts.append("Host identity: Not verified yet")
else:
parts.append(
"Host identity: %s"
% NetworkIdentityCrypto.format_fingerprint(
str(trust.get("fingerprint", ""))
)
)
if ( if (
not entry.last_observed_server_name.is_empty() not entry.last_observed_server_name.is_empty()
and ( and (

View file

@ -121,6 +121,7 @@ func setup(
fishing_spot: FishingSpotType, fishing_spot: FishingSpotType,
network_session: NetworkSessionType, network_session: NetworkSessionType,
saved_servers: SavedServerStoreType, saved_servers: SavedServerStoreType,
server_trust: ServerTrustStore,
) -> void: ) -> void:
_player = player _player = player
_save_manager = save_manager _save_manager = save_manager
@ -128,7 +129,7 @@ func setup(
_fishing_spot = fishing_spot _fishing_spot = fishing_spot
_network_session = network_session _network_session = network_session
_saved_servers = saved_servers _saved_servers = saved_servers
_join_game_page.setup(network_session, saved_servers, true) _join_game_page.setup(network_session, saved_servers, true, server_trust)
if not _fishing_spot.bite_activated.is_connected(_on_bite_activated): if not _fishing_spot.bite_activated.is_connected(_on_bite_activated):
_fishing_spot.bite_activated.connect(_on_bite_activated) _fishing_spot.bite_activated.connect(_on_bite_activated)

View file

@ -43,6 +43,13 @@ func setup(service: NetworkProfileService) -> void:
_service.conflict_result.connect(_on_conflict_result) _service.conflict_result.connect(_on_conflict_result)
_service.apply_finished.connect(_on_apply_finished) _service.apply_finished.connect(_on_apply_finished)
_load_persisted() _load_persisted()
var identity_value := find_child("IdentityFingerprint", true, false) as Label
if identity_value != null:
identity_value.text = "%s • Stored on this device" % (
NetworkIdentityCrypto.format_fingerprint(
_service.get_identity_fingerprint()
)
)
func activate() -> void: func activate() -> void:
@ -162,6 +169,19 @@ func _build_ui() -> void:
_suggestions.add_theme_constant_override("separation", 8) _suggestions.add_theme_constant_override("separation", 8)
layout.add_child(_suggestions) layout.add_child(_suggestions)
var identity_label := Label.new()
identity_label.text = "identity"
identity_label.add_theme_color_override("font_color", Color("302b27"))
layout.add_child(identity_label)
var identity_value := Label.new()
identity_value.name = "IdentityFingerprint"
identity_value.text = "Stored on this device"
identity_value.tooltip_text = (
"This identity helps other players recognize you between sessions."
)
identity_value.add_theme_color_override("font_color", Color("665e52"))
layout.add_child(identity_value)
var body := HBoxContainer.new() var body := HBoxContainer.new()
body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.size_flags_vertical = Control.SIZE_EXPAND_FILL
body.add_theme_constant_override("separation", 18) body.add_theme_constant_override("separation", 18)

View file

@ -231,10 +231,11 @@ func setup(
settings_manager: SettingsManagerType, settings_manager: SettingsManagerType,
network_session: NetworkSessionType, network_session: NetworkSessionType,
saved_servers: SavedServerStoreType, saved_servers: SavedServerStoreType,
server_trust: ServerTrustStore,
) -> void: ) -> void:
_save_manager = save_manager _save_manager = save_manager
_settings_manager = settings_manager _settings_manager = settings_manager
_join_game_page.setup(network_session, saved_servers, false) _join_game_page.setup(network_session, saved_servers, false, server_trust)
_join_game_page.join_requested.connect(join_game_requested.emit) _join_game_page.join_requested.connect(join_game_requested.emit)
_join_game_page.back_requested.connect(_close_join_game) _join_game_page.back_requested.connect(_close_join_game)
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE Input.mouse_mode = Input.MOUSE_MODE_VISIBLE