From fa996a673545ada9605ddb66fecf7fe9255521e5 Mon Sep 17 00:00:00 2001 From: Voyager Date: Wed, 29 Jul 2026 22:18:14 -0400 Subject: [PATCH] Add decentralized cryptographic identity --- main/main.gd | 94 +++- main/main.tscn | 22 +- network/host_identity_store.gd | 6 + network/host_identity_store.gd.uid | 1 + network/known_player_store.gd | 119 +++++ network/known_player_store.gd.uid | 1 + network/local_signing_identity_store.gd | 177 +++++++ network/local_signing_identity_store.gd.uid | 1 + network/network_chat_protocol.gd | 19 + network/network_chat_service.gd | 55 +- network/network_identity_crypto.gd | 144 +++++ network/network_identity_crypto.gd.uid | 1 + network/network_mail_protocol.gd | 67 +++ network/network_mail_service.gd | 166 +++++- network/network_profile_protocol.gd | 22 + network/network_profile_service.gd | 65 ++- network/network_protocol.gd | 78 ++- network/network_session.gd | 551 ++++++++++++++++++-- network/peer_registry.gd | 27 +- network/player_identity_store.gd | 6 + network/player_identity_store.gd.uid | 1 + network/server_trust_store.gd | 127 +++++ network/server_trust_store.gd.uid | 1 + ui/network/join_game_page.gd | 14 + ui/pause_menu.gd | 3 +- ui/profile_page.gd | 20 + ui/title_screen.gd | 3 +- 27 files changed, 1733 insertions(+), 58 deletions(-) create mode 100644 network/host_identity_store.gd create mode 100644 network/host_identity_store.gd.uid create mode 100644 network/known_player_store.gd create mode 100644 network/known_player_store.gd.uid create mode 100644 network/local_signing_identity_store.gd create mode 100644 network/local_signing_identity_store.gd.uid create mode 100644 network/network_identity_crypto.gd create mode 100644 network/network_identity_crypto.gd.uid create mode 100644 network/player_identity_store.gd create mode 100644 network/player_identity_store.gd.uid create mode 100644 network/server_trust_store.gd create mode 100644 network/server_trust_store.gd.uid diff --git a/main/main.gd b/main/main.gd index 1e4b1aa..a525d3c 100644 --- a/main/main.gd +++ b/main/main.gd @@ -100,6 +100,10 @@ const TITLE_MUSIC_SILENCE_DB: float = -80.0 @onready var _network_profile: NetworkProfilePreferencesType = ( %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 _player_spawn_service: PlayerSpawnServiceType = ( %PlayerSpawnService @@ -132,6 +136,9 @@ var _quit_in_progress: bool = false var _join_requested_from_title: bool = false var _join_requested_from_pause: bool = false var _pending_join_endpoint: String = "" +var _server_trust_dialog: ConfirmationDialog +var _pending_trust_changed: bool = false +var _identity_notice_dialog: AcceptDialog func _ready() -> void: @@ -145,7 +152,11 @@ func _ready() -> void: _network_session.setup( _network_profile, _saved_servers, - _player_spawn_service + _player_spawn_service, + _player_identity, + _host_identity, + _known_players, + _server_trust, ) _network_profile_service.setup( _network_session, @@ -162,10 +173,18 @@ func _ready() -> void: _network_session.connection_error.connect( _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.remote_recovery_requested.connect( _on_remote_recovery_requested ) + + _player.fish_sale_service.setup( _player.inventory, _player.wallet @@ -332,7 +351,8 @@ func _ready() -> void: _save_manager, _settings_manager, _network_session, - _saved_servers + _saved_servers, + _server_trust, ) var pause_menu: PauseMenuType = _game_ui.get_pause_menu() pause_menu.setup( @@ -341,7 +361,8 @@ func _ready() -> void: _settings_manager, _fishing_spot, _network_session, - _saved_servers + _saved_servers, + _server_trust, ) title_screen.join_game_requested.connect(_on_title_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) +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: if ( not event.is_action_pressed("ui_cancel") diff --git a/main/main.tscn b/main/main.tscn index c2ef599..0645ab7 100644 --- a/main/main.tscn +++ b/main/main.tscn @@ -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://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_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/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"] script = ExtResource("3_main") @@ -45,6 +49,22 @@ script = ExtResource("17_network_session") unique_name_in_owner = true 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="."] unique_name_in_owner = true script = ExtResource("28_appearance") diff --git a/network/host_identity_store.gd b/network/host_identity_store.gd new file mode 100644 index 0000000..a45dd1c --- /dev/null +++ b/network/host_identity_store.gd @@ -0,0 +1,6 @@ +class_name HostIdentityStore +extends "res://network/local_signing_identity_store.gd" + + +func _ready() -> void: + configure("host_identity") diff --git a/network/host_identity_store.gd.uid b/network/host_identity_store.gd.uid new file mode 100644 index 0000000..12d7557 --- /dev/null +++ b/network/host_identity_store.gd.uid @@ -0,0 +1 @@ +uid://dtyoh0v77x52f diff --git a/network/known_player_store.gd b/network/known_player_store.gd new file mode 100644 index 0000000..cc36f33 --- /dev/null +++ b/network/known_player_store.gd @@ -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) diff --git a/network/known_player_store.gd.uid b/network/known_player_store.gd.uid new file mode 100644 index 0000000..85c44ec --- /dev/null +++ b/network/known_player_store.gd.uid @@ -0,0 +1 @@ +uid://dossvvvh4yuid diff --git a/network/local_signing_identity_store.gd b/network/local_signing_identity_store.gd new file mode 100644 index 0000000..4cd41b4 --- /dev/null +++ b/network/local_signing_identity_store.gd @@ -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)) diff --git a/network/local_signing_identity_store.gd.uid b/network/local_signing_identity_store.gd.uid new file mode 100644 index 0000000..44c6907 --- /dev/null +++ b/network/local_signing_identity_store.gd.uid @@ -0,0 +1 @@ +uid://1h1kh0jlhc87 diff --git a/network/network_chat_protocol.gd b/network/network_chat_protocol.gd index 329ef20..da1af63 100644 --- a/network/network_chat_protocol.gd +++ b/network/network_chat_protocol.gd @@ -34,6 +34,12 @@ static func validate_request(data: Variant) -> bool: and _valid_id(data.get("request_id")) and _valid_id(data.get("session_id")) 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,9 +56,22 @@ static func validate_message(data: Variant) -> bool: and typeof(data.get("sender_display_name")) == TYPE_STRING and str(data["sender_display_name"]).length() <= 24 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: return ( typeof(value) == TYPE_STRING diff --git a/network/network_chat_service.gd b/network/network_chat_service.gd index 9e82f42..6c00e2f 100644 --- a/network/network_chat_service.gd +++ b/network/network_chat_service.gd @@ -50,7 +50,11 @@ func send_local_message(body: String) -> bool: "request_id": _new_id("chat_request"), "session_id": _session.get_session_id(), "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(): _handle_request(_session.get_local_peer_id(), request) else: @@ -76,6 +80,19 @@ func _handle_request(peer_id: int, data: Dictionary) -> void: ): _send_rejection(peer_id, "Message could not be sent.") 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 ledger: Dictionary = _request_ledgers.get(peer_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): _send_rejection(peer_id, "Slow down.") return - var record := _session.get_peer_record(peer_id) - if record == null: - return var message := _make_message( NetworkChatProtocol.Kind.PLAYER, peer_id, record.display_name, 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) while ledger.size() > 64: ledger.erase(ledger.keys().front()) @@ -107,15 +124,22 @@ func _make_message( body: String, ) -> Dictionary: _sequence += 1 - return { + var message := { "message_id": _new_id("chat"), + "request_id": _new_id("chat_system"), "session_id": _session.get_session_id(), "sequence": _sequence, "kind": kind, "sender_peer_id": peer_id, "sender_display_name": display_name.left(24), "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: @@ -142,6 +166,29 @@ func _apply_message(data: Dictionary) -> void: or _seen_messages.has(str(data["message_id"])) ): 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 _history.append(data.duplicate(true)) while _history.size() > NetworkChatProtocol.MAX_HISTORY: diff --git a/network/network_identity_crypto.gd b/network/network_identity_crypto.gd new file mode 100644 index 0000000..4886cca --- /dev/null +++ b/network/network_identity_crypto.gd @@ -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) diff --git a/network/network_identity_crypto.gd.uid b/network/network_identity_crypto.gd.uid new file mode 100644 index 0000000..e11459e --- /dev/null +++ b/network/network_identity_crypto.gd.uid @@ -0,0 +1 @@ +uid://bjb5n6ayqkb3q diff --git a/network/network_mail_protocol.gd b/network/network_mail_protocol.gd index 7a1c132..a3faeb2 100644 --- a/network/network_mail_protocol.gd +++ b/network/network_mail_protocol.gd @@ -71,6 +71,13 @@ static func validate_send_request(data: Variant) -> bool: and str(value["reservation_id"]).length() <= MAX_ID_LENGTH and typeof(value.get("attachment")) == TYPE_DICTIONARY 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 int(value["state"]) >= 0 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, + ] diff --git a/network/network_mail_service.gd b/network/network_mail_service.gd index e89f0d3..e248a4c 100644 --- a/network/network_mail_service.gd +++ b/network/network_mail_service.gd @@ -112,16 +112,25 @@ func get_recipient_choices() -> Array[Dictionary]: continue var record := _session.get_peer_record(peer_id) 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: 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: var name: String = choice["name"] - seen[name] = int(seen.get(name, 0)) + 1 - if seen[name] > 1: - choice["label"] = "%s (%d)" % [name, seen[name]] + if int(counts.get(name.to_lower(), 0)) > 1: + choice["label"] = "%s · %s" % [ + name, + NetworkIdentityCrypto.compact_suffix(choice["fingerprint"]), + ] else: choice["label"] = name return choices @@ -164,7 +173,14 @@ func send_letter( "salutation_id": salutation_id, "reservation_id": reservation_id, "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 reservation_id.is_empty(): _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) ): 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: error = "The session mailbox is full." var recipient_count := 0 @@ -224,14 +257,12 @@ func _handle_send(sender: int, data: Dictionary) -> void: } if error.is_empty(): _sequence += 1 - var record := _session.get_peer_record(sender) - var recipient_record := _session.get_peer_record(recipient) var letter := { "mail_id": _new_id("mail"), "session_id": _session.get_session_id(), "sequence": _sequence, "sender_peer_id": sender, - "sender_display_name": record.display_name, + "sender_display_name": sender_record.display_name, "recipient_peer_id": recipient, "recipient_display_name": recipient_record.display_name, "greeting_id": str(data["greeting_id"]), @@ -240,6 +271,11 @@ func _handle_send(sender: int, data: Dictionary) -> void: "reservation_id": str(data["reservation_id"]), "attachment": attachment.duplicate(true), "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 result["mail"] = letter @@ -272,6 +308,21 @@ func _receive_letter(letter: Dictionary) -> void: ] ): 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) _emit_mailbox() @@ -344,6 +395,15 @@ func accept_gift(mail_id: String) -> void: var request := _action_request(mail_id) if request.is_empty(): 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(): _handle_accept(_session.get_local_peer_id(), request) 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"])) ): 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 var transfer_id := _new_id("mail_transfer") letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING + letter["transfer_id"] = transfer_id _pending_transfers[transfer_id] = { "letter": letter, "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: + 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(): - _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: - 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) func acknowledge_transfer_phase( - phase: String, transfer_id: String, applied: bool + phase: String, + transfer_id: String, + applied: bool, + fingerprint: String, + signature: PackedByteArray, ) -> void: var sender := multiplayer.get_remote_sender_id() 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( - 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: var transfer: Dictionary = _pending_transfers.get(transfer_id, {}) if transfer.is_empty(): return 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 peer_id != int(letter["recipient_peer_id"]): return @@ -514,6 +630,30 @@ func _handle_phase_ack( _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: if peer_id == _session.get_local_peer_id(): _rollback_sender(transfer_id) diff --git a/network/network_profile_protocol.gd b/network/network_profile_protocol.gd index e9e5b45..ac4dec5 100644 --- a/network/network_profile_protocol.gd +++ b/network/network_profile_protocol.gd @@ -34,4 +34,26 @@ static func valid_apply_request(data: Variant) -> bool: and typeof(data.get("appearance")) == TYPE_DICTIONARY and valid_snapshot(data["appearance"]) 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)), + ] diff --git a/network/network_profile_service.gd b/network/network_profile_service.gd index bf5729c..40b15f4 100644 --- a/network/network_profile_service.gd +++ b/network/network_profile_service.gd @@ -50,6 +50,13 @@ func get_persisted_appearance() -> Dictionary: 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: var request_id := _new_id() _latest_check_id = request_id @@ -95,7 +102,11 @@ func apply_profile( "display_name": clean_name, "appearance": appearance.duplicate(true), "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 if _session == null or not _session.is_gameplay_session_active(): _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() ): 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) @@ -163,6 +186,13 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void: "peer_id": peer_id, "display_name": name, "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, { "request_id": data["request_id"], @@ -187,8 +217,16 @@ func confirm_profile_saved(request_id: String) -> void: var name := str(pending["display_name"]) var appearance := Dictionary(pending["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) - 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) @@ -243,10 +281,16 @@ func _apply_local_result( _preferences.display_name, _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( _session.get_local_peer_id(), _preferences.display_name, _appearance_store.get_snapshot(), + request, ) elif _session.supports_server_capability(&"profile_v1"): confirm_profile_saved.rpc_id(1, request_id) @@ -264,12 +308,30 @@ func broadcast_profile_snapshot( peer_id: int, display_name: String, appearance: Dictionary, + authorization: Dictionary = {}, ) -> void: if ( not NetworkProfilePreferences.is_valid_display_name(display_name) or not CharacterCustomizationCatalog.validate_snapshot(appearance) ): 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) _apply_to_avatar(peer_id, appearance) 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, record.display_name, record.appearance_snapshot, + record.profile_authorization, ) diff --git a/network/network_protocol.gd b/network/network_protocol.gd index 8b07cc1..0df56aa 100644 --- a/network/network_protocol.gd +++ b/network/network_protocol.gd @@ -1,11 +1,13 @@ class_name NetworkProtocol extends RefCounted -const PROTOCOL_VERSION: int = 2 +const PROTOCOL_VERSION: int = 3 const GAME_BUILD: String = "prealpha" const MAX_DISPLAY_NAME_LENGTH: int = 24 const MAX_PROFILE_ID_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 # snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales, # 6 reliable shop transactions, 7 reliable item/equipment lifecycle, @@ -23,17 +25,56 @@ enum RejectionCode { PROTOCOL_MISMATCH, SERVER_FULL, DUPLICATE_PROFILE, + DUPLICATE_IDENTITY, + INVALID_IDENTITY_PROOF, AUTHENTICATION_TIMEOUT, SERVER_SHUTTING_DOWN, 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( profile_id: String, display_name: String, client_nonce: String, cosmetic_snapshot: Dictionary = {}, + identity_fingerprint: String = "", + identity_signature: PackedByteArray = PackedByteArray(), ) -> Dictionary: return { "protocol_version": PROTOCOL_VERSION, @@ -43,6 +84,8 @@ static func make_client_hello( "client_nonce": client_nonce, "capability_flags": PackedStringArray(), "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", "capability_flags", "cosmetic_snapshot", + "identity_fingerprint", + "identity_signature", ]: if not payload.has(key): return "Handshake is missing %s." % key @@ -78,6 +123,13 @@ static func validate_client_hello(data: Variant) -> String: return "Capabilities are invalid." if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY: 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 display_name: String = payload["display_name"] var nonce: String = payload["client_nonce"] @@ -93,6 +145,23 @@ static func validate_client_hello(data: Variant) -> String: 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( accepted: bool, rejection_code: RejectionCode, @@ -120,6 +189,7 @@ static func make_server_hello( "chat_v1", "mail_v1", "profile_v1", + "identity_v1", ]), } @@ -131,7 +201,11 @@ static func rejection_text(code: int) -> String: RejectionCode.SERVER_FULL: return "The server is full." 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: return "The server did not finish authentication." RejectionCode.SERVER_SHUTTING_DOWN: diff --git a/network/network_session.gd b/network/network_session.gd index 31e3d0f..0d67c06 100644 --- a/network/network_session.gd +++ b/network/network_session.gd @@ -5,7 +5,7 @@ const DEFAULT_PORT: int = 7777 const DEFAULT_SESSION_MAX_PLAYERS: int = 8 const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31 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 SNAPSHOT_INTERVAL: float = 1.0 / 15.0 @@ -23,6 +23,13 @@ signal peer_profile_changed( appearance: Dictionary, ) 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 remote_recovery_requested(peer_id: int, entry_position: Vector3) @@ -33,6 +40,7 @@ enum State { OPEN_HOST, CONNECTING, AUTHENTICATING, + VERIFYING_SERVER_IDENTITY, JOINED_CLIENT, DISCONNECTING, CONNECTION_FAILED, @@ -67,6 +75,17 @@ var _last_server_display_name: String = "" var _last_server_protocol_version: int = 0 var _server_capabilities: PackedStringArray = PackedStringArray() 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 = ( CharacterCustomizationCatalog.default_snapshot() ) @@ -84,12 +103,22 @@ func setup( profile: NetworkProfilePreferences, saved_servers: SavedServerStore, spawn_service: PlayerSpawnService, + player_identity: PlayerIdentityStore, + host_identity: HostIdentityStore, + known_players: KnownPlayerStore, + server_trust: ServerTrustStore, ) -> void: _profile = profile _saved_servers = saved_servers _spawn_service = spawn_service + _player_identity = player_identity + _host_identity = host_identity + _known_players = known_players + _server_trust = server_trust if _profile != null: _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: @@ -98,8 +127,12 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: or not _profile_ready or _profile == null or _spawn_service == null + or _host_identity == null ): return false + if not _host_identity.load_or_create(): + _fail(_host_identity.error_message) + return false if port < 1 or port > 65535: _fail("The hosting port must be from 1 to 65535.") return false @@ -122,9 +155,30 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool: 1, _profile.profile_id, _profile.display_name, - NetworkProtocol.PROTOCOL_VERSION + NetworkProtocol.PROTOCOL_VERSION, + _player_identity.fingerprint, + _player_identity.public_pem, ) _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.register_local_player(1) var host_avatar := _spawn_service.get_avatar(1) @@ -172,7 +226,11 @@ func join_direct(endpoint_text: String) -> bool: 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 _operation_generation += 1 _teardown_peer() @@ -291,6 +349,7 @@ func supports_server_capability(capability: StringName) -> bool: "item_use_v1", "equipment_v1", "chat_v1", "mail_v1", "profile_v1", + "identity_v1", ]) return str(capability) in _server_capabilities @@ -303,6 +362,77 @@ func get_authenticated_peer_ids() -> Array[int]: 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: if _profile == null or not _profile.set_display_name(value): return false @@ -372,7 +502,11 @@ func get_local_peer_id() -> int: func _process(delta: float) -> void: var now: float = Time.get_ticks_msec() / 1000.0 if ( - state in [State.CONNECTING, State.AUTHENTICATING] + state in [ + State.CONNECTING, + State.AUTHENTICATING, + State.VERIFYING_SERVER_IDENTITY, + ] and _connection_deadline > 0.0 and now >= _connection_deadline ): @@ -434,18 +568,22 @@ func _on_connected_to_server() -> void: if state != State.CONNECTING: return _set_state(State.AUTHENTICATING, "Authenticating...") - _client_nonce = Crypto.new().generate_random_bytes(16).hex_encode() - var hello: Dictionary = NetworkProtocol.make_client_hello( - _profile.profile_id, - _profile.display_name, + _client_nonce = NetworkIdentityCrypto.secure_id(32) + _client_identity_attempt = NetworkProtocol.make_identity_hello( + _player_identity.public_pem, + _player_identity.fingerprint, _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: - if state not in [State.CONNECTING, State.AUTHENTICATING]: + if state not in [ + State.CONNECTING, + State.AUTHENTICATING, + State.VERIFYING_SERVER_IDENTITY, + ]: return _teardown_peer() _fail("Could not connect. The server may be private, unavailable, or unreachable.") @@ -456,6 +594,13 @@ func _on_server_disconnected() -> void: return _operation_generation += 1 _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.") server_lost.emit() 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: _pending_authentication.erase(peer_id) + _pending_identity_challenges.erase(peer_id) + _authenticated_identity_cache.erase(peer_id) if _registry.has_peer(peer_id): _registry.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() +@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) func submit_client_hello(data: Dictionary) -> void: var sender_id: int = multiplayer.get_remote_sender_id() @@ -479,27 +810,27 @@ func submit_client_hello(data: Dictionary) -> void: return var validation_error: String = NetworkProtocol.validate_client_hello(data) if not validation_error.is_empty(): - _reject_peer( - sender_id, - NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE - ) + _reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE) return - if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION: - _reject_peer( - sender_id, - NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH + var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {}) + if identity.is_empty(): + _reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF) + 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 if _registry.size() >= session_max_players: _reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL) return 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"] if not NetworkProfilePreferences.is_valid_display_name(display_name): _reject_peer( @@ -511,7 +842,9 @@ func submit_client_hello(data: Dictionary) -> void: sender_id, profile_id, display_name, - NetworkProtocol.PROTOCOL_VERSION + NetworkProtocol.PROTOCOL_VERSION, + identity["fingerprint"], + identity["public_key"], ): _reject_peer( sender_id, @@ -522,7 +855,20 @@ func submit_client_hello(data: Dictionary) -> void: data["cosmetic_snapshot"] ) _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) + _authenticated_identity_cache.erase(sender_id) var spawn_index: int = _registry.get_peer_ids().find(sender_id) var spawn_transform: Transform3D = ( _spawn_service.get_spawn_transform_for_index(spawn_index) @@ -625,9 +971,14 @@ func receive_server_hello(data: Dictionary) -> void: local_peer_id, _profile.profile_id, _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) + 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.register_local_player(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] ): return + if not _verify_spawn_identity(entry): + return var peer_id: int = entry["peer_id"] if peer_id == multiplayer.get_unique_id(): var own_position: Array = entry["position"] @@ -699,8 +1052,18 @@ func _apply_spawn_entry(entry: Dictionary) -> void: peer_id, entry["profile_id"], 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: var appearance := CharacterCustomizationCatalog.sanitized_snapshot( entry["appearance"] @@ -757,9 +1120,121 @@ func _make_spawn_entry( if record != null 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: var peer_id: int = multiplayer.get_unique_id() var avatar: Player = _spawn_service.get_avatar(peer_id) @@ -944,12 +1419,19 @@ func _is_transition_allowed(from_state: State, to_state: State) -> bool: State.DISCONNECTING, State.INACTIVE, ], - State.AUTHENTICATING: [ - State.JOINED_CLIENT, + State.AUTHENTICATING: [ + State.VERIFYING_SERVER_IDENTITY, + State.JOINED_CLIENT, State.CONNECTION_FAILED, State.DISCONNECTING, State.INACTIVE, - ], + ], + State.VERIFYING_SERVER_IDENTITY: [ + State.AUTHENTICATING, + State.CONNECTION_FAILED, + State.DISCONNECTING, + State.INACTIVE, + ], State.JOINED_CLIENT: [ State.DISCONNECTING, State.SERVER_LOST, @@ -979,6 +1461,10 @@ func _fail(message: String) -> void: func _teardown_peer() -> void: _pending_authentication.clear() + _pending_identity_challenges.clear() + _authenticated_identity_cache.clear() + _client_identity_attempt.clear() + _pending_server_proof.clear() _registry.clear() if _spawn_service != null: _spawn_service.clear_remote_players() @@ -989,3 +1475,6 @@ func _teardown_peer() -> void: _input_accumulator = 0.0 _snapshot_accumulator = 0.0 _server_capabilities = PackedStringArray() + _server_identity_fingerprint = "" + _server_identity_public_key = "" + _session_identity_keys.clear() diff --git a/network/peer_registry.gd b/network/peer_registry.gd index f68f71c..c3881be 100644 --- a/network/peer_registry.gd +++ b/network/peer_registry.gd @@ -12,6 +12,10 @@ class PeerRecord: var appearance_snapshot: Dictionary = ( 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] = {} @@ -22,8 +26,17 @@ func add_peer( profile_id: String, display_name: String, protocol_version: int, + identity_fingerprint: String = "", + identity_public_key: String = "", ) -> 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 var record := PeerRecord.new() record.peer_id = peer_id @@ -31,6 +44,11 @@ func add_peer( record.display_name = display_name record.protocol_version = protocol_version 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 return true @@ -72,6 +90,13 @@ func has_profile(profile_id: String) -> bool: 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]: var result: Array[int] = [] for peer_id: int in _records: diff --git a/network/player_identity_store.gd b/network/player_identity_store.gd new file mode 100644 index 0000000..80d8aa3 --- /dev/null +++ b/network/player_identity_store.gd @@ -0,0 +1,6 @@ +class_name PlayerIdentityStore +extends "res://network/local_signing_identity_store.gd" + + +func _ready() -> void: + configure("player_identity") diff --git a/network/player_identity_store.gd.uid b/network/player_identity_store.gd.uid new file mode 100644 index 0000000..ce591b3 --- /dev/null +++ b/network/player_identity_store.gd.uid @@ -0,0 +1 @@ +uid://cqlgolkbx2riq diff --git a/network/server_trust_store.gd b/network/server_trust_store.gd new file mode 100644 index 0000000..1238d6b --- /dev/null +++ b/network/server_trust_store.gd @@ -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 diff --git a/network/server_trust_store.gd.uid b/network/server_trust_store.gd.uid new file mode 100644 index 0000000..71ce1df --- /dev/null +++ b/network/server_trust_store.gd.uid @@ -0,0 +1 @@ +uid://cp2kam2btjkb0 diff --git a/ui/network/join_game_page.gd b/ui/network/join_game_page.gd index a34da52..b05e6f6 100644 --- a/ui/network/join_game_page.gd +++ b/ui/network/join_game_page.gd @@ -45,6 +45,7 @@ const DIRECT_WORKFLOW_HELP: String = ( var _network_session: NetworkSession var _saved_servers: SavedServerStore +var _server_trust: ServerTrustStore var _gameplay_context: bool = false var _mode: Mode = Mode.DIRECT var _visible_entries: Array[SavedServerEntry] = [] @@ -89,10 +90,12 @@ func setup( network_session: NetworkSession, saved_servers: SavedServerStore, gameplay_context: bool, + server_trust: ServerTrustStore = null, ) -> void: _network_session = network_session _saved_servers = saved_servers _gameplay_context = gameplay_context + _server_trust = server_trust if not _network_session.state_changed.is_connected(_on_state_changed): _network_session.state_changed.connect(_on_state_changed) if not _network_session.status_message_changed.is_connected( @@ -447,6 +450,17 @@ func _format_entry_details(entry: SavedServerEntry) -> String: if _mode == Mode.SAVED: parts.append("Name: %s" % entry.display_name) 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 ( not entry.last_observed_server_name.is_empty() and ( diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index f743503..6f34d7e 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -121,6 +121,7 @@ func setup( fishing_spot: FishingSpotType, network_session: NetworkSessionType, saved_servers: SavedServerStoreType, + server_trust: ServerTrustStore, ) -> void: _player = player _save_manager = save_manager @@ -128,7 +129,7 @@ func setup( _fishing_spot = fishing_spot _network_session = network_session _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): _fishing_spot.bite_activated.connect(_on_bite_activated) diff --git a/ui/profile_page.gd b/ui/profile_page.gd index bd94937..f7ac069 100644 --- a/ui/profile_page.gd +++ b/ui/profile_page.gd @@ -43,6 +43,13 @@ func setup(service: NetworkProfileService) -> void: _service.conflict_result.connect(_on_conflict_result) _service.apply_finished.connect(_on_apply_finished) _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: @@ -162,6 +169,19 @@ func _build_ui() -> void: _suggestions.add_theme_constant_override("separation", 8) 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() body.size_flags_vertical = Control.SIZE_EXPAND_FILL body.add_theme_constant_override("separation", 18) diff --git a/ui/title_screen.gd b/ui/title_screen.gd index ee1062e..e18f7dc 100644 --- a/ui/title_screen.gd +++ b/ui/title_screen.gd @@ -231,10 +231,11 @@ func setup( settings_manager: SettingsManagerType, network_session: NetworkSessionType, saved_servers: SavedServerStoreType, + server_trust: ServerTrustStore, ) -> void: _save_manager = save_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.back_requested.connect(_close_join_game) Input.mouse_mode = Input.MOUSE_MODE_VISIBLE