Add decentralized cryptographic identity
This commit is contained in:
parent
0b5b78a553
commit
fa996a6735
27 changed files with 1733 additions and 58 deletions
94
main/main.gd
94
main/main.gd
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
6
network/host_identity_store.gd
Normal file
6
network/host_identity_store.gd
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
class_name HostIdentityStore
|
||||||
|
extends "res://network/local_signing_identity_store.gd"
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
configure("host_identity")
|
||||||
1
network/host_identity_store.gd.uid
Normal file
1
network/host_identity_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dtyoh0v77x52f
|
||||||
119
network/known_player_store.gd
Normal file
119
network/known_player_store.gd
Normal 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)
|
||||||
1
network/known_player_store.gd.uid
Normal file
1
network/known_player_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://dossvvvh4yuid
|
||||||
177
network/local_signing_identity_store.gd
Normal file
177
network/local_signing_identity_store.gd
Normal 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))
|
||||||
1
network/local_signing_identity_store.gd.uid
Normal file
1
network/local_signing_identity_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://1h1kh0jlhc87
|
||||||
|
|
@ -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,9 +56,22 @@ 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:
|
||||||
return (
|
return (
|
||||||
typeof(value) == TYPE_STRING
|
typeof(value) == TYPE_STRING
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
144
network/network_identity_crypto.gd
Normal file
144
network/network_identity_crypto.gd
Normal 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)
|
||||||
1
network/network_identity_crypto.gd.uid
Normal file
1
network/network_identity_crypto.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://bjb5n6ayqkb3q
|
||||||
|
|
@ -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,
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)),
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -944,12 +1419,19 @@ func _is_transition_allowed(from_state: State, to_state: State) -> bool:
|
||||||
State.DISCONNECTING,
|
State.DISCONNECTING,
|
||||||
State.INACTIVE,
|
State.INACTIVE,
|
||||||
],
|
],
|
||||||
State.AUTHENTICATING: [
|
State.AUTHENTICATING: [
|
||||||
State.JOINED_CLIENT,
|
State.VERIFYING_SERVER_IDENTITY,
|
||||||
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
6
network/player_identity_store.gd
Normal file
6
network/player_identity_store.gd
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
class_name PlayerIdentityStore
|
||||||
|
extends "res://network/local_signing_identity_store.gd"
|
||||||
|
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
configure("player_identity")
|
||||||
1
network/player_identity_store.gd.uid
Normal file
1
network/player_identity_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cqlgolkbx2riq
|
||||||
127
network/server_trust_store.gd
Normal file
127
network/server_trust_store.gd
Normal 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
|
||||||
1
network/server_trust_store.gd.uid
Normal file
1
network/server_trust_store.gd.uid
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
uid://cp2kam2btjkb0
|
||||||
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue