Add decentralized cryptographic identity
This commit is contained in:
parent
0b5b78a553
commit
fa996a6735
27 changed files with 1733 additions and 58 deletions
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("session_id"))
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
data.get("sender_fingerprint")
|
||||
)
|
||||
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
|
||||
and PackedByteArray(data["sender_signature"]).size()
|
||||
<= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -50,9 +56,22 @@ static func validate_message(data: Variant) -> bool:
|
|||
and typeof(data.get("sender_display_name")) == TYPE_STRING
|
||||
and str(data["sender_display_name"]).length() <= 24
|
||||
and not sanitize_body(data.get("body")).is_empty()
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
data.get("sender_fingerprint")
|
||||
)
|
||||
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
|
||||
)
|
||||
|
||||
|
||||
static func signature_fields(data: Dictionary) -> Array:
|
||||
return [
|
||||
str(data.get("session_id", "")),
|
||||
str(data.get("request_id", "")),
|
||||
str(data.get("sender_fingerprint", "")),
|
||||
sanitize_body(data.get("body")),
|
||||
]
|
||||
|
||||
|
||||
static func _valid_id(value: Variant) -> bool:
|
||||
return (
|
||||
typeof(value) == TYPE_STRING
|
||||
|
|
|
|||
|
|
@ -50,7 +50,11 @@ func send_local_message(body: String) -> bool:
|
|||
"request_id": _new_id("chat_request"),
|
||||
"session_id": _session.get_session_id(),
|
||||
"body": clean,
|
||||
"sender_fingerprint": _session.get_local_identity_fingerprint(),
|
||||
}
|
||||
request["sender_signature"] = _session.sign_local_action(
|
||||
"chat_send", NetworkChatProtocol.signature_fields(request)
|
||||
)
|
||||
if _session.is_host():
|
||||
_handle_request(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
|
|
@ -76,6 +80,19 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
|
|||
):
|
||||
_send_rejection(peer_id, "Message could not be sent.")
|
||||
return
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if (
|
||||
record == null
|
||||
or record.identity_fingerprint != str(data["sender_fingerprint"])
|
||||
or not _session.verify_peer_action(
|
||||
peer_id,
|
||||
"chat_send",
|
||||
NetworkChatProtocol.signature_fields(data),
|
||||
data["sender_signature"],
|
||||
)
|
||||
):
|
||||
_send_rejection(peer_id, "Message identity could not be verified.")
|
||||
return
|
||||
var request_id: String = data["request_id"]
|
||||
var ledger: Dictionary = _request_ledgers.get(peer_id, {})
|
||||
if ledger.has(request_id):
|
||||
|
|
@ -84,15 +101,15 @@ func _handle_request(peer_id: int, data: Dictionary) -> void:
|
|||
if not _consume_rate(peer_id):
|
||||
_send_rejection(peer_id, "Slow down.")
|
||||
return
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if record == null:
|
||||
return
|
||||
var message := _make_message(
|
||||
NetworkChatProtocol.Kind.PLAYER,
|
||||
peer_id,
|
||||
record.display_name,
|
||||
NetworkChatProtocol.sanitize_body(data["body"])
|
||||
)
|
||||
message["request_id"] = request_id
|
||||
message["sender_fingerprint"] = data["sender_fingerprint"]
|
||||
message["sender_signature"] = data["sender_signature"]
|
||||
ledger[request_id] = message.duplicate(true)
|
||||
while ledger.size() > 64:
|
||||
ledger.erase(ledger.keys().front())
|
||||
|
|
@ -107,15 +124,22 @@ func _make_message(
|
|||
body: String,
|
||||
) -> Dictionary:
|
||||
_sequence += 1
|
||||
return {
|
||||
var message := {
|
||||
"message_id": _new_id("chat"),
|
||||
"request_id": _new_id("chat_system"),
|
||||
"session_id": _session.get_session_id(),
|
||||
"sequence": _sequence,
|
||||
"kind": kind,
|
||||
"sender_peer_id": peer_id,
|
||||
"sender_display_name": display_name.left(24),
|
||||
"body": body,
|
||||
"sender_fingerprint": _session.get_host_identity_fingerprint(),
|
||||
}
|
||||
if kind == NetworkChatProtocol.Kind.SYSTEM:
|
||||
message["sender_signature"] = _session.sign_host_action(
|
||||
"chat_system", NetworkChatProtocol.signature_fields(message)
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
func _broadcast(message: Dictionary) -> void:
|
||||
|
|
@ -142,6 +166,29 @@ func _apply_message(data: Dictionary) -> void:
|
|||
or _seen_messages.has(str(data["message_id"]))
|
||||
):
|
||||
return
|
||||
var kind := int(data["kind"])
|
||||
var valid_signature := false
|
||||
if kind == NetworkChatProtocol.Kind.SYSTEM:
|
||||
valid_signature = _session.verify_host_action(
|
||||
"chat_system",
|
||||
NetworkChatProtocol.signature_fields(data),
|
||||
data["sender_signature"],
|
||||
)
|
||||
else:
|
||||
var sender_id := int(data["sender_peer_id"])
|
||||
var record := _session.get_peer_record(sender_id)
|
||||
valid_signature = (
|
||||
record != null
|
||||
and record.identity_fingerprint == str(data["sender_fingerprint"])
|
||||
and _session.verify_peer_action(
|
||||
sender_id,
|
||||
"chat_send",
|
||||
NetworkChatProtocol.signature_fields(data),
|
||||
data["sender_signature"],
|
||||
)
|
||||
)
|
||||
if not valid_signature:
|
||||
return
|
||||
_seen_messages[data["message_id"]] = true
|
||||
_history.append(data.duplicate(true))
|
||||
while _history.size() > NetworkChatProtocol.MAX_HISTORY:
|
||||
|
|
|
|||
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 typeof(value.get("attachment")) == TYPE_DICTIONARY
|
||||
and Dictionary(value["attachment"]).size() <= 3
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
value.get("sender_fingerprint")
|
||||
)
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
value.get("recipient_fingerprint")
|
||||
)
|
||||
and typeof(value.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -94,4 +101,64 @@ static func validate_mail(data: Variant) -> bool:
|
|||
and typeof(value.get("state")) == TYPE_INT
|
||||
and int(value["state"]) >= 0
|
||||
and int(value["state"]) < State.size()
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
value.get("sender_fingerprint")
|
||||
)
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
value.get("recipient_fingerprint")
|
||||
)
|
||||
and typeof(value.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
|
||||
and typeof(value.get("sender_public_key")) == TYPE_STRING
|
||||
and str(value["sender_public_key"]).to_utf8_buffer().size()
|
||||
<= NetworkIdentityCrypto.MAX_PUBLIC_KEY_BYTES
|
||||
)
|
||||
|
||||
|
||||
static func mail_signature_fields(data: Dictionary) -> Array:
|
||||
var result: Array = [
|
||||
str(data.get("session_id", "")),
|
||||
str(data.get("request_id", "")),
|
||||
str(data.get("sender_fingerprint", "")),
|
||||
str(data.get("recipient_fingerprint", "")),
|
||||
str(data.get("greeting_id", "")),
|
||||
sanitize_body(data.get("body")),
|
||||
str(data.get("salutation_id", "")),
|
||||
str(data.get("reservation_id", "")),
|
||||
]
|
||||
result.append_array(attachment_signature_fields(data.get("attachment", {})))
|
||||
return result
|
||||
|
||||
|
||||
static func attachment_signature_fields(value: Variant) -> Array:
|
||||
if typeof(value) != TYPE_DICTIONARY:
|
||||
return [0]
|
||||
var attachment: Dictionary = value
|
||||
var type := int(attachment.get("type", 0))
|
||||
var result: Array = [type]
|
||||
match type:
|
||||
1:
|
||||
result.append(int(attachment.get("amount", 0)))
|
||||
2:
|
||||
var fish: Dictionary = attachment.get("catch", {})
|
||||
result.append(str(attachment.get("catch_id", "")))
|
||||
result.append(str(fish.get("fish_id", "")))
|
||||
result.append(str(fish.get("weight_lb", "")))
|
||||
result.append(str(fish.get("display_scale", "")))
|
||||
result.append(int(fish.get("sale_value", 0)))
|
||||
result.append(bool(fish.get("is_favorited", false)))
|
||||
3:
|
||||
result.append(str(attachment.get("item_id", "")))
|
||||
result.append(int(attachment.get("quantity", 0)))
|
||||
return result
|
||||
|
||||
|
||||
static func acceptance_signature_fields(
|
||||
data: Dictionary, sender_fingerprint: String, recipient_fingerprint: String
|
||||
) -> Array:
|
||||
return [
|
||||
str(data.get("session_id", "")),
|
||||
str(data.get("request_id", "")),
|
||||
str(data.get("mail_id", "")),
|
||||
sender_fingerprint,
|
||||
recipient_fingerprint,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -112,16 +112,25 @@ func get_recipient_choices() -> Array[Dictionary]:
|
|||
continue
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if record != null:
|
||||
choices.append({"peer_id": peer_id, "name": record.display_name})
|
||||
choices.append({
|
||||
"peer_id": peer_id,
|
||||
"name": record.display_name,
|
||||
"fingerprint": record.identity_fingerprint,
|
||||
})
|
||||
choices.sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
|
||||
return str(a["name"]).naturalnocasecmp_to(str(b["name"])) < 0
|
||||
)
|
||||
var seen: Dictionary[String, int] = {}
|
||||
var counts: Dictionary[String, int] = {}
|
||||
for choice: Dictionary in choices:
|
||||
var normalized := str(choice["name"]).to_lower()
|
||||
counts[normalized] = int(counts.get(normalized, 0)) + 1
|
||||
for choice: Dictionary in choices:
|
||||
var name: String = choice["name"]
|
||||
seen[name] = int(seen.get(name, 0)) + 1
|
||||
if seen[name] > 1:
|
||||
choice["label"] = "%s (%d)" % [name, seen[name]]
|
||||
if int(counts.get(name.to_lower(), 0)) > 1:
|
||||
choice["label"] = "%s · %s" % [
|
||||
name,
|
||||
NetworkIdentityCrypto.compact_suffix(choice["fingerprint"]),
|
||||
]
|
||||
else:
|
||||
choice["label"] = name
|
||||
return choices
|
||||
|
|
@ -164,7 +173,14 @@ func send_letter(
|
|||
"salutation_id": salutation_id,
|
||||
"reservation_id": reservation_id,
|
||||
"attachment": attachment.duplicate(true),
|
||||
"sender_fingerprint": _session.get_local_identity_fingerprint(),
|
||||
"recipient_fingerprint": (
|
||||
_session.get_peer_record(recipient_peer_id).identity_fingerprint
|
||||
),
|
||||
}
|
||||
request["sender_signature"] = _session.sign_local_action(
|
||||
"mail_send", NetworkMailProtocol.mail_signature_fields(request)
|
||||
)
|
||||
if not NetworkMailProtocol.validate_send_request(request):
|
||||
if not reservation_id.is_empty():
|
||||
_reservations.release(reservation_id)
|
||||
|
|
@ -202,6 +218,23 @@ func _handle_send(sender: int, data: Dictionary) -> void:
|
|||
recipient == sender or not _session.is_authenticated_peer(recipient)
|
||||
):
|
||||
error = "That player is no longer connected."
|
||||
var sender_record := _session.get_peer_record(sender)
|
||||
var recipient_record := _session.get_peer_record(recipient)
|
||||
if error.is_empty() and (
|
||||
sender_record == null
|
||||
or recipient_record == null
|
||||
or sender_record.identity_fingerprint
|
||||
!= str(data.get("sender_fingerprint", ""))
|
||||
or recipient_record.identity_fingerprint
|
||||
!= str(data.get("recipient_fingerprint", ""))
|
||||
or not _session.verify_peer_action(
|
||||
sender,
|
||||
"mail_send",
|
||||
NetworkMailProtocol.mail_signature_fields(data),
|
||||
data.get("sender_signature", PackedByteArray()),
|
||||
)
|
||||
):
|
||||
error = "Letter identity could not be verified."
|
||||
if error.is_empty() and _letters.size() >= NetworkMailProtocol.MAX_SESSION_LETTERS:
|
||||
error = "The session mailbox is full."
|
||||
var recipient_count := 0
|
||||
|
|
@ -224,14 +257,12 @@ func _handle_send(sender: int, data: Dictionary) -> void:
|
|||
}
|
||||
if error.is_empty():
|
||||
_sequence += 1
|
||||
var record := _session.get_peer_record(sender)
|
||||
var recipient_record := _session.get_peer_record(recipient)
|
||||
var letter := {
|
||||
"mail_id": _new_id("mail"),
|
||||
"session_id": _session.get_session_id(),
|
||||
"sequence": _sequence,
|
||||
"sender_peer_id": sender,
|
||||
"sender_display_name": record.display_name,
|
||||
"sender_display_name": sender_record.display_name,
|
||||
"recipient_peer_id": recipient,
|
||||
"recipient_display_name": recipient_record.display_name,
|
||||
"greeting_id": str(data["greeting_id"]),
|
||||
|
|
@ -240,6 +271,11 @@ func _handle_send(sender: int, data: Dictionary) -> void:
|
|||
"reservation_id": str(data["reservation_id"]),
|
||||
"attachment": attachment.duplicate(true),
|
||||
"state": NetworkMailProtocol.State.SENT_UNREAD,
|
||||
"request_id": request_id,
|
||||
"sender_fingerprint": data["sender_fingerprint"],
|
||||
"recipient_fingerprint": data["recipient_fingerprint"],
|
||||
"sender_signature": data["sender_signature"],
|
||||
"sender_public_key": sender_record.identity_public_key,
|
||||
}
|
||||
_letters[letter["mail_id"]] = letter
|
||||
result["mail"] = letter
|
||||
|
|
@ -272,6 +308,21 @@ func _receive_letter(letter: Dictionary) -> void:
|
|||
]
|
||||
):
|
||||
return
|
||||
var sender_public := NetworkIdentityCrypto.normalize_public_pem(
|
||||
str(letter.get("sender_public_key", ""))
|
||||
)
|
||||
if (
|
||||
not _session.matches_authenticated_session_identity(
|
||||
str(letter["sender_fingerprint"]), sender_public
|
||||
)
|
||||
or not NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(sender_public),
|
||||
"mail_send",
|
||||
NetworkMailProtocol.mail_signature_fields(letter),
|
||||
letter["sender_signature"],
|
||||
)
|
||||
):
|
||||
return
|
||||
_local_letters[letter["mail_id"]] = letter.duplicate(true)
|
||||
_emit_mailbox()
|
||||
|
||||
|
|
@ -344,6 +395,15 @@ func accept_gift(mail_id: String) -> void:
|
|||
var request := _action_request(mail_id)
|
||||
if request.is_empty():
|
||||
return
|
||||
var letter: Dictionary = _local_letters.get(mail_id, {})
|
||||
request["recipient_signature"] = _session.sign_local_action(
|
||||
"mail_accept",
|
||||
NetworkMailProtocol.acceptance_signature_fields(
|
||||
request,
|
||||
str(letter.get("sender_fingerprint", "")),
|
||||
str(letter.get("recipient_fingerprint", "")),
|
||||
),
|
||||
)
|
||||
if _session.is_host():
|
||||
_handle_accept(_session.get_local_peer_id(), request)
|
||||
else:
|
||||
|
|
@ -373,9 +433,27 @@ func _handle_accept(peer_id: int, data: Dictionary) -> void:
|
|||
or not _session.is_authenticated_peer(int(letter["sender_peer_id"]))
|
||||
):
|
||||
return
|
||||
var recipient_record := _session.get_peer_record(peer_id)
|
||||
if (
|
||||
recipient_record == null
|
||||
or recipient_record.identity_fingerprint
|
||||
!= str(letter.get("recipient_fingerprint", ""))
|
||||
or not _session.verify_peer_action(
|
||||
peer_id,
|
||||
"mail_accept",
|
||||
NetworkMailProtocol.acceptance_signature_fields(
|
||||
data,
|
||||
str(letter.get("sender_fingerprint", "")),
|
||||
str(letter.get("recipient_fingerprint", "")),
|
||||
),
|
||||
data.get("recipient_signature", PackedByteArray()),
|
||||
)
|
||||
):
|
||||
return
|
||||
_action_ledger[action_id] = true
|
||||
var transfer_id := _new_id("mail_transfer")
|
||||
letter["state"] = NetworkMailProtocol.State.ACCEPTANCE_PENDING
|
||||
letter["transfer_id"] = transfer_id
|
||||
_pending_transfers[transfer_id] = {
|
||||
"letter": letter,
|
||||
"recipient_prepared": false,
|
||||
|
|
@ -462,28 +540,66 @@ func _award_recipient(transfer_id: String, letter: Dictionary) -> void:
|
|||
|
||||
|
||||
func _send_phase_ack(phase: String, transfer_id: String, applied: bool) -> void:
|
||||
var letter := _find_local_transfer_letter(transfer_id)
|
||||
var fields := _gift_phase_fields(phase, transfer_id, applied, letter)
|
||||
var fingerprint := _session.get_local_identity_fingerprint()
|
||||
var signature := _session.sign_local_action(
|
||||
"gift_%s" % phase, fields
|
||||
)
|
||||
if _session.is_host():
|
||||
_handle_phase_ack(_session.get_local_peer_id(), phase, transfer_id, applied)
|
||||
_handle_phase_ack(
|
||||
_session.get_local_peer_id(),
|
||||
phase,
|
||||
transfer_id,
|
||||
applied,
|
||||
fingerprint,
|
||||
signature,
|
||||
)
|
||||
else:
|
||||
acknowledge_transfer_phase.rpc_id(1, phase, transfer_id, applied)
|
||||
acknowledge_transfer_phase.rpc_id(
|
||||
1, phase, transfer_id, applied, fingerprint, signature
|
||||
)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", NetworkMailProtocol.RELIABLE_CHANNEL)
|
||||
func acknowledge_transfer_phase(
|
||||
phase: String, transfer_id: String, applied: bool
|
||||
phase: String,
|
||||
transfer_id: String,
|
||||
applied: bool,
|
||||
fingerprint: String,
|
||||
signature: PackedByteArray,
|
||||
) -> void:
|
||||
var sender := multiplayer.get_remote_sender_id()
|
||||
if _session.is_host() and _session.is_authenticated_peer(sender):
|
||||
_handle_phase_ack(sender, phase, transfer_id, applied)
|
||||
_handle_phase_ack(
|
||||
sender, phase, transfer_id, applied, fingerprint, signature
|
||||
)
|
||||
|
||||
|
||||
func _handle_phase_ack(
|
||||
peer_id: int, phase: String, transfer_id: String, applied: bool
|
||||
peer_id: int,
|
||||
phase: String,
|
||||
transfer_id: String,
|
||||
applied: bool,
|
||||
fingerprint: String,
|
||||
signature: PackedByteArray,
|
||||
) -> void:
|
||||
var transfer: Dictionary = _pending_transfers.get(transfer_id, {})
|
||||
if transfer.is_empty():
|
||||
return
|
||||
var letter: Dictionary = transfer["letter"]
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if (
|
||||
record == null
|
||||
or record.identity_fingerprint != fingerprint
|
||||
or not _session.verify_peer_action(
|
||||
peer_id,
|
||||
"gift_%s" % phase,
|
||||
_gift_phase_fields(phase, transfer_id, applied, letter),
|
||||
signature,
|
||||
)
|
||||
):
|
||||
return
|
||||
if phase == "recipient_prepared":
|
||||
if peer_id != int(letter["recipient_peer_id"]):
|
||||
return
|
||||
|
|
@ -514,6 +630,30 @@ func _handle_phase_ack(
|
|||
_finish_transfer(transfer_id, false, "Gift could not be transferred.")
|
||||
|
||||
|
||||
func _find_local_transfer_letter(transfer_id: String) -> Dictionary:
|
||||
for value: Dictionary in _local_letters.values():
|
||||
if str(value.get("transfer_id", "")) == transfer_id:
|
||||
return value
|
||||
return {}
|
||||
|
||||
|
||||
func _gift_phase_fields(
|
||||
phase: String,
|
||||
transfer_id: String,
|
||||
applied: bool,
|
||||
letter: Dictionary,
|
||||
) -> Array:
|
||||
return [
|
||||
_session.get_session_id(),
|
||||
transfer_id,
|
||||
str(letter.get("mail_id", "")),
|
||||
str(letter.get("sender_fingerprint", "")),
|
||||
str(letter.get("recipient_fingerprint", "")),
|
||||
phase,
|
||||
applied,
|
||||
]
|
||||
|
||||
|
||||
func _request_sender_rollback(peer_id: int, transfer_id: String) -> void:
|
||||
if peer_id == _session.get_local_peer_id():
|
||||
_rollback_sender(transfer_id)
|
||||
|
|
|
|||
|
|
@ -34,4 +34,26 @@ static func valid_apply_request(data: Variant) -> bool:
|
|||
and typeof(data.get("appearance")) == TYPE_DICTIONARY
|
||||
and valid_snapshot(data["appearance"])
|
||||
and typeof(data.get("use_anyway")) == TYPE_BOOL
|
||||
and NetworkIdentityCrypto.valid_fingerprint(
|
||||
data.get("sender_fingerprint")
|
||||
)
|
||||
and typeof(data.get("sender_signature")) == TYPE_PACKED_BYTE_ARRAY
|
||||
)
|
||||
|
||||
|
||||
static func signature_fields(data: Dictionary) -> Array:
|
||||
var appearance: Dictionary = data.get("appearance", {})
|
||||
return [
|
||||
str(data.get("session_id", "")),
|
||||
str(data.get("request_id", "")),
|
||||
str(data.get("sender_fingerprint", "")),
|
||||
str(data.get("display_name", "")),
|
||||
str(appearance.get("species", "")),
|
||||
str(appearance.get("fur_pattern", "")),
|
||||
str(appearance.get("ears", "")),
|
||||
str(appearance.get("eyes", "")),
|
||||
str(appearance.get("nose", "")),
|
||||
str(appearance.get("mouth", "")),
|
||||
str(appearance.get("tail", "")),
|
||||
bool(data.get("use_anyway", false)),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@ func get_persisted_appearance() -> Dictionary:
|
|||
return _appearance_store.get_snapshot()
|
||||
|
||||
|
||||
func get_identity_fingerprint() -> String:
|
||||
return (
|
||||
_session.get_local_identity_fingerprint()
|
||||
if _session != null else ""
|
||||
)
|
||||
|
||||
|
||||
func request_name_check(display_name: String) -> String:
|
||||
var request_id := _new_id()
|
||||
_latest_check_id = request_id
|
||||
|
|
@ -95,7 +102,11 @@ func apply_profile(
|
|||
"display_name": clean_name,
|
||||
"appearance": appearance.duplicate(true),
|
||||
"use_anyway": use_anyway,
|
||||
"sender_fingerprint": _session.get_local_identity_fingerprint(),
|
||||
}
|
||||
request["sender_signature"] = _session.sign_local_action(
|
||||
"profile_update", NetworkProfileProtocol.signature_fields(request)
|
||||
)
|
||||
_pending_apply[request_id] = request
|
||||
if _session == null or not _session.is_gameplay_session_active():
|
||||
_apply_local_result(request_id, true, "", false, PackedStringArray())
|
||||
|
|
@ -145,6 +156,18 @@ func submit_profile_apply(data: Dictionary) -> void:
|
|||
or str(data["session_id"]) != _session.get_session_id()
|
||||
):
|
||||
return
|
||||
var record := _session.get_peer_record(sender_id)
|
||||
if (
|
||||
record == null
|
||||
or record.identity_fingerprint != str(data["sender_fingerprint"])
|
||||
or not _session.verify_peer_action(
|
||||
sender_id,
|
||||
"profile_update",
|
||||
NetworkProfileProtocol.signature_fields(data),
|
||||
data["sender_signature"],
|
||||
)
|
||||
):
|
||||
return
|
||||
_process_apply_request(sender_id, data)
|
||||
|
||||
|
||||
|
|
@ -163,6 +186,13 @@ func _process_apply_request(peer_id: int, data: Dictionary) -> void:
|
|||
"peer_id": peer_id,
|
||||
"display_name": name,
|
||||
"appearance": Dictionary(data["appearance"]).duplicate(true),
|
||||
"authorization": {
|
||||
"request_id": data["request_id"],
|
||||
"session_id": data["session_id"],
|
||||
"sender_fingerprint": data["sender_fingerprint"],
|
||||
"sender_signature": data["sender_signature"],
|
||||
"use_anyway": data["use_anyway"],
|
||||
},
|
||||
}
|
||||
receive_profile_result.rpc_id(peer_id, {
|
||||
"request_id": data["request_id"],
|
||||
|
|
@ -187,8 +217,16 @@ func confirm_profile_saved(request_id: String) -> void:
|
|||
var name := str(pending["display_name"])
|
||||
var appearance := Dictionary(pending["appearance"])
|
||||
_session.apply_canonical_profile(sender_id, name, appearance)
|
||||
var record := _session.get_peer_record(sender_id)
|
||||
if record != null:
|
||||
record.profile_authorization = pending.get("authorization", {}).duplicate(true)
|
||||
_apply_to_avatar(sender_id, appearance)
|
||||
broadcast_profile_snapshot.rpc(sender_id, name, appearance)
|
||||
broadcast_profile_snapshot.rpc(
|
||||
sender_id,
|
||||
name,
|
||||
appearance,
|
||||
pending.get("authorization", {}),
|
||||
)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", NetworkProfileProtocol.RELIABLE_CHANNEL)
|
||||
|
|
@ -243,10 +281,16 @@ func _apply_local_result(
|
|||
_preferences.display_name,
|
||||
_appearance_store.get_snapshot(),
|
||||
)
|
||||
var record := _session.get_peer_record(
|
||||
_session.get_local_peer_id()
|
||||
)
|
||||
if record != null:
|
||||
record.profile_authorization = request.duplicate(true)
|
||||
broadcast_profile_snapshot.rpc(
|
||||
_session.get_local_peer_id(),
|
||||
_preferences.display_name,
|
||||
_appearance_store.get_snapshot(),
|
||||
request,
|
||||
)
|
||||
elif _session.supports_server_capability(&"profile_v1"):
|
||||
confirm_profile_saved.rpc_id(1, request_id)
|
||||
|
|
@ -264,12 +308,30 @@ func broadcast_profile_snapshot(
|
|||
peer_id: int,
|
||||
display_name: String,
|
||||
appearance: Dictionary,
|
||||
authorization: Dictionary = {},
|
||||
) -> void:
|
||||
if (
|
||||
not NetworkProfilePreferences.is_valid_display_name(display_name)
|
||||
or not CharacterCustomizationCatalog.validate_snapshot(appearance)
|
||||
):
|
||||
return
|
||||
if not authorization.is_empty():
|
||||
var signed := authorization.duplicate(true)
|
||||
signed["display_name"] = display_name
|
||||
signed["appearance"] = appearance
|
||||
var record := _session.get_peer_record(peer_id)
|
||||
if (
|
||||
record == null
|
||||
or record.identity_fingerprint
|
||||
!= str(signed.get("sender_fingerprint", ""))
|
||||
or not _session.verify_peer_action(
|
||||
peer_id,
|
||||
"profile_update",
|
||||
NetworkProfileProtocol.signature_fields(signed),
|
||||
signed.get("sender_signature", PackedByteArray()),
|
||||
)
|
||||
):
|
||||
return
|
||||
_session.apply_canonical_profile(peer_id, display_name, appearance)
|
||||
_apply_to_avatar(peer_id, appearance)
|
||||
profile_snapshot_changed.emit(peer_id, display_name, appearance.duplicate(true))
|
||||
|
|
@ -287,6 +349,7 @@ func _on_peer_authenticated(peer_id: int, _display_name: String) -> void:
|
|||
existing_id,
|
||||
record.display_name,
|
||||
record.appearance_snapshot,
|
||||
record.profile_authorization,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
class_name NetworkProtocol
|
||||
extends RefCounted
|
||||
|
||||
const PROTOCOL_VERSION: int = 2
|
||||
const PROTOCOL_VERSION: int = 3
|
||||
const GAME_BUILD: String = "prealpha"
|
||||
const MAX_DISPLAY_NAME_LENGTH: int = 24
|
||||
const MAX_PROFILE_ID_LENGTH: int = 96
|
||||
const MAX_NONCE_LENGTH: int = 96
|
||||
const MAX_PUBLIC_KEY_LENGTH: int = 8192
|
||||
const MAX_SIGNATURE_LENGTH: int = 2048
|
||||
# ENet channels: 0 reliable lifecycle, 1 movement input, 2 movement
|
||||
# snapshots, 3 fishing input, 4 fishing snapshots, 5 reliable sales,
|
||||
# 6 reliable shop transactions, 7 reliable item/equipment lifecycle,
|
||||
|
|
@ -23,17 +25,56 @@ enum RejectionCode {
|
|||
PROTOCOL_MISMATCH,
|
||||
SERVER_FULL,
|
||||
DUPLICATE_PROFILE,
|
||||
DUPLICATE_IDENTITY,
|
||||
INVALID_IDENTITY_PROOF,
|
||||
AUTHENTICATION_TIMEOUT,
|
||||
SERVER_SHUTTING_DOWN,
|
||||
UNSUPPORTED_CLIENT,
|
||||
}
|
||||
|
||||
|
||||
static func make_identity_hello(
|
||||
public_key: String,
|
||||
fingerprint: String,
|
||||
client_nonce: String,
|
||||
attempt_id: String,
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
"public_key": public_key,
|
||||
"fingerprint": fingerprint,
|
||||
"client_nonce": client_nonce,
|
||||
"attempt_id": attempt_id,
|
||||
"capability_flags": PackedStringArray(["identity_v1"]),
|
||||
}
|
||||
|
||||
|
||||
static func validate_identity_hello(data: Variant) -> bool:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var value: Dictionary = data
|
||||
return (
|
||||
typeof(value.get("protocol_version")) == TYPE_INT
|
||||
and typeof(value.get("public_key")) == TYPE_STRING
|
||||
and str(value["public_key"]).to_utf8_buffer().size() <= MAX_PUBLIC_KEY_LENGTH
|
||||
and NetworkIdentityCrypto.valid_fingerprint(value.get("fingerprint"))
|
||||
and typeof(value.get("client_nonce")) == TYPE_STRING
|
||||
and str(value["client_nonce"]).length() == 64
|
||||
and typeof(value.get("attempt_id")) == TYPE_STRING
|
||||
and str(value["attempt_id"]).length() == 32
|
||||
and typeof(value.get("capability_flags")) in [
|
||||
TYPE_ARRAY, TYPE_PACKED_STRING_ARRAY
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
static func make_client_hello(
|
||||
profile_id: String,
|
||||
display_name: String,
|
||||
client_nonce: String,
|
||||
cosmetic_snapshot: Dictionary = {},
|
||||
identity_fingerprint: String = "",
|
||||
identity_signature: PackedByteArray = PackedByteArray(),
|
||||
) -> Dictionary:
|
||||
return {
|
||||
"protocol_version": PROTOCOL_VERSION,
|
||||
|
|
@ -43,6 +84,8 @@ static func make_client_hello(
|
|||
"client_nonce": client_nonce,
|
||||
"capability_flags": PackedStringArray(),
|
||||
"cosmetic_snapshot": cosmetic_snapshot,
|
||||
"identity_fingerprint": identity_fingerprint,
|
||||
"identity_signature": identity_signature,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -58,6 +101,8 @@ static func validate_client_hello(data: Variant) -> String:
|
|||
"client_nonce",
|
||||
"capability_flags",
|
||||
"cosmetic_snapshot",
|
||||
"identity_fingerprint",
|
||||
"identity_signature",
|
||||
]:
|
||||
if not payload.has(key):
|
||||
return "Handshake is missing %s." % key
|
||||
|
|
@ -78,6 +123,13 @@ static func validate_client_hello(data: Variant) -> String:
|
|||
return "Capabilities are invalid."
|
||||
if typeof(payload["cosmetic_snapshot"]) != TYPE_DICTIONARY:
|
||||
return "Cosmetic snapshot is invalid."
|
||||
if (
|
||||
not NetworkIdentityCrypto.valid_fingerprint(
|
||||
payload["identity_fingerprint"]
|
||||
)
|
||||
or typeof(payload["identity_signature"]) != TYPE_PACKED_BYTE_ARRAY
|
||||
):
|
||||
return "Profile identity proof is invalid."
|
||||
var profile_id: String = payload["local_profile_id"]
|
||||
var display_name: String = payload["display_name"]
|
||||
var nonce: String = payload["client_nonce"]
|
||||
|
|
@ -93,6 +145,23 @@ static func validate_client_hello(data: Variant) -> String:
|
|||
return ""
|
||||
|
||||
|
||||
static func client_profile_fields(data: Dictionary) -> Array:
|
||||
var appearance: Dictionary = data.get("cosmetic_snapshot", {})
|
||||
return [
|
||||
str(data.get("client_nonce", "")),
|
||||
str(data.get("identity_fingerprint", "")),
|
||||
str(data.get("local_profile_id", "")),
|
||||
str(data.get("display_name", "")),
|
||||
str(appearance.get("species", "")),
|
||||
str(appearance.get("fur_pattern", "")),
|
||||
str(appearance.get("ears", "")),
|
||||
str(appearance.get("eyes", "")),
|
||||
str(appearance.get("nose", "")),
|
||||
str(appearance.get("mouth", "")),
|
||||
str(appearance.get("tail", "")),
|
||||
]
|
||||
|
||||
|
||||
static func make_server_hello(
|
||||
accepted: bool,
|
||||
rejection_code: RejectionCode,
|
||||
|
|
@ -120,6 +189,7 @@ static func make_server_hello(
|
|||
"chat_v1",
|
||||
"mail_v1",
|
||||
"profile_v1",
|
||||
"identity_v1",
|
||||
]),
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +201,11 @@ static func rejection_text(code: int) -> String:
|
|||
RejectionCode.SERVER_FULL:
|
||||
return "The server is full."
|
||||
RejectionCode.DUPLICATE_PROFILE:
|
||||
return "This local profile is already connected."
|
||||
return "This legacy local profile is already connected."
|
||||
RejectionCode.DUPLICATE_IDENTITY:
|
||||
return "This player identity is already connected."
|
||||
RejectionCode.INVALID_IDENTITY_PROOF:
|
||||
return "Player identity authentication failed."
|
||||
RejectionCode.AUTHENTICATION_TIMEOUT:
|
||||
return "The server did not finish authentication."
|
||||
RejectionCode.SERVER_SHUTTING_DOWN:
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const DEFAULT_PORT: int = 7777
|
|||
const DEFAULT_SESSION_MAX_PLAYERS: int = 8
|
||||
const DEFAULT_TRANSPORT_MAX_CLIENTS: int = 31
|
||||
const CONNECTION_TIMEOUT_SECONDS: float = 10.0
|
||||
const AUTHENTICATION_TIMEOUT_SECONDS: float = 8.0
|
||||
const AUTHENTICATION_TIMEOUT_SECONDS: float = 60.0
|
||||
const INPUT_INTERVAL: float = 1.0 / 25.0
|
||||
const SNAPSHOT_INTERVAL: float = 1.0 / 15.0
|
||||
|
||||
|
|
@ -23,6 +23,13 @@ signal peer_profile_changed(
|
|||
appearance: Dictionary,
|
||||
)
|
||||
signal join_authenticated
|
||||
signal server_trust_required(
|
||||
endpoint: String,
|
||||
expected_fingerprint: String,
|
||||
received_fingerprint: String,
|
||||
is_changed: bool,
|
||||
)
|
||||
signal peer_identity_observed(peer_id: int, status: String)
|
||||
signal server_lost
|
||||
signal remote_recovery_requested(peer_id: int, entry_position: Vector3)
|
||||
|
||||
|
|
@ -33,6 +40,7 @@ enum State {
|
|||
OPEN_HOST,
|
||||
CONNECTING,
|
||||
AUTHENTICATING,
|
||||
VERIFYING_SERVER_IDENTITY,
|
||||
JOINED_CLIENT,
|
||||
DISCONNECTING,
|
||||
CONNECTION_FAILED,
|
||||
|
|
@ -67,6 +75,17 @@ var _last_server_display_name: String = ""
|
|||
var _last_server_protocol_version: int = 0
|
||||
var _server_capabilities: PackedStringArray = PackedStringArray()
|
||||
var _profile_ready: bool = false
|
||||
var _player_identity: PlayerIdentityStore
|
||||
var _host_identity: HostIdentityStore
|
||||
var _known_players: KnownPlayerStore
|
||||
var _server_trust: ServerTrustStore
|
||||
var _pending_identity_challenges: Dictionary[int, Dictionary] = {}
|
||||
var _authenticated_identity_cache: Dictionary[int, Dictionary] = {}
|
||||
var _client_identity_attempt: Dictionary = {}
|
||||
var _pending_server_proof: Dictionary = {}
|
||||
var _server_identity_fingerprint: String = ""
|
||||
var _server_identity_public_key: String = ""
|
||||
var _session_identity_keys: Dictionary[String, String] = {}
|
||||
var _local_appearance_snapshot: Dictionary = (
|
||||
CharacterCustomizationCatalog.default_snapshot()
|
||||
)
|
||||
|
|
@ -84,12 +103,22 @@ func setup(
|
|||
profile: NetworkProfilePreferences,
|
||||
saved_servers: SavedServerStore,
|
||||
spawn_service: PlayerSpawnService,
|
||||
player_identity: PlayerIdentityStore,
|
||||
host_identity: HostIdentityStore,
|
||||
known_players: KnownPlayerStore,
|
||||
server_trust: ServerTrustStore,
|
||||
) -> void:
|
||||
_profile = profile
|
||||
_saved_servers = saved_servers
|
||||
_spawn_service = spawn_service
|
||||
_player_identity = player_identity
|
||||
_host_identity = host_identity
|
||||
_known_players = known_players
|
||||
_server_trust = server_trust
|
||||
if _profile != null:
|
||||
_profile_ready = _profile.load_or_create()
|
||||
if _player_identity != null:
|
||||
_profile_ready = _profile_ready and _player_identity.load_or_create()
|
||||
|
||||
|
||||
func start_private_host(port: int = DEFAULT_PORT) -> bool:
|
||||
|
|
@ -98,8 +127,12 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool:
|
|||
or not _profile_ready
|
||||
or _profile == null
|
||||
or _spawn_service == null
|
||||
or _host_identity == null
|
||||
):
|
||||
return false
|
||||
if not _host_identity.load_or_create():
|
||||
_fail(_host_identity.error_message)
|
||||
return false
|
||||
if port < 1 or port > 65535:
|
||||
_fail("The hosting port must be from 1 to 65535.")
|
||||
return false
|
||||
|
|
@ -122,9 +155,30 @@ func start_private_host(port: int = DEFAULT_PORT) -> bool:
|
|||
1,
|
||||
_profile.profile_id,
|
||||
_profile.display_name,
|
||||
NetworkProtocol.PROTOCOL_VERSION
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
_player_identity.fingerprint,
|
||||
_player_identity.public_pem,
|
||||
)
|
||||
_registry.update_appearance(1, _local_appearance_snapshot)
|
||||
var host_profile_hello := NetworkProtocol.make_client_hello(
|
||||
_profile.profile_id,
|
||||
_profile.display_name,
|
||||
NetworkIdentityCrypto.secure_id(32),
|
||||
_local_appearance_snapshot,
|
||||
_player_identity.fingerprint,
|
||||
)
|
||||
host_profile_hello["identity_signature"] = _player_identity.sign(
|
||||
"handshake_client_profile",
|
||||
NetworkProtocol.client_profile_fields(host_profile_hello),
|
||||
)
|
||||
var host_record := _registry.get_peer(1)
|
||||
if host_record != null:
|
||||
host_record.profile_authorization = {
|
||||
"domain": "handshake_client_profile",
|
||||
"signature": host_profile_hello["identity_signature"],
|
||||
"client_nonce": host_profile_hello["client_nonce"],
|
||||
}
|
||||
_archive_authenticated_identity(host_record)
|
||||
_spawn_service.clear_remote_players()
|
||||
_spawn_service.register_local_player(1)
|
||||
var host_avatar := _spawn_service.get_avatar(1)
|
||||
|
|
@ -172,7 +226,11 @@ func join_direct(endpoint_text: String) -> bool:
|
|||
|
||||
|
||||
func cancel_connection() -> void:
|
||||
if state not in [State.CONNECTING, State.AUTHENTICATING]:
|
||||
if state not in [
|
||||
State.CONNECTING,
|
||||
State.AUTHENTICATING,
|
||||
State.VERIFYING_SERVER_IDENTITY,
|
||||
]:
|
||||
return
|
||||
_operation_generation += 1
|
||||
_teardown_peer()
|
||||
|
|
@ -291,6 +349,7 @@ func supports_server_capability(capability: StringName) -> bool:
|
|||
"item_use_v1", "equipment_v1", "chat_v1",
|
||||
"mail_v1",
|
||||
"profile_v1",
|
||||
"identity_v1",
|
||||
])
|
||||
return str(capability) in _server_capabilities
|
||||
|
||||
|
|
@ -303,6 +362,77 @@ func get_authenticated_peer_ids() -> Array[int]:
|
|||
return _registry.get_peer_ids()
|
||||
|
||||
|
||||
func get_local_identity_fingerprint() -> String:
|
||||
return _player_identity.fingerprint if _player_identity != null else ""
|
||||
|
||||
|
||||
func sign_local_action(domain: String, fields: Array) -> PackedByteArray:
|
||||
return (
|
||||
_player_identity.sign(domain, fields)
|
||||
if _player_identity != null else PackedByteArray()
|
||||
)
|
||||
|
||||
|
||||
func verify_peer_action(
|
||||
peer_id: int,
|
||||
domain: String,
|
||||
fields: Array,
|
||||
signature: PackedByteArray,
|
||||
) -> bool:
|
||||
var record := _registry.get_peer(peer_id)
|
||||
if record == null or not record.identity_authenticated:
|
||||
return false
|
||||
return NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(record.identity_public_key),
|
||||
domain,
|
||||
fields,
|
||||
signature,
|
||||
)
|
||||
|
||||
|
||||
func matches_authenticated_session_identity(
|
||||
fingerprint: String, public_pem: String
|
||||
) -> bool:
|
||||
var normalized := NetworkIdentityCrypto.normalize_public_pem(public_pem)
|
||||
return (
|
||||
NetworkIdentityCrypto.valid_fingerprint(fingerprint)
|
||||
and NetworkIdentityCrypto.fingerprint_public_pem(normalized)
|
||||
== fingerprint
|
||||
and _session_identity_keys.get(fingerprint, "") == normalized
|
||||
)
|
||||
|
||||
|
||||
func sign_host_action(domain: String, fields: Array) -> PackedByteArray:
|
||||
return (
|
||||
_host_identity.sign(domain, fields)
|
||||
if is_host() and _host_identity != null else PackedByteArray()
|
||||
)
|
||||
|
||||
|
||||
func get_host_identity_fingerprint() -> String:
|
||||
return (
|
||||
_host_identity.fingerprint
|
||||
if is_host() and _host_identity != null
|
||||
else _server_identity_fingerprint
|
||||
)
|
||||
|
||||
|
||||
func verify_host_action(
|
||||
domain: String, fields: Array, signature: PackedByteArray
|
||||
) -> bool:
|
||||
var public_pem: String = (
|
||||
_host_identity.public_pem
|
||||
if is_host() and _host_identity != null
|
||||
else _server_identity_public_key
|
||||
)
|
||||
return NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(public_pem),
|
||||
domain,
|
||||
fields,
|
||||
signature,
|
||||
)
|
||||
|
||||
|
||||
func update_local_display_name(value: String) -> bool:
|
||||
if _profile == null or not _profile.set_display_name(value):
|
||||
return false
|
||||
|
|
@ -372,7 +502,11 @@ func get_local_peer_id() -> int:
|
|||
func _process(delta: float) -> void:
|
||||
var now: float = Time.get_ticks_msec() / 1000.0
|
||||
if (
|
||||
state in [State.CONNECTING, State.AUTHENTICATING]
|
||||
state in [
|
||||
State.CONNECTING,
|
||||
State.AUTHENTICATING,
|
||||
State.VERIFYING_SERVER_IDENTITY,
|
||||
]
|
||||
and _connection_deadline > 0.0
|
||||
and now >= _connection_deadline
|
||||
):
|
||||
|
|
@ -434,18 +568,22 @@ func _on_connected_to_server() -> void:
|
|||
if state != State.CONNECTING:
|
||||
return
|
||||
_set_state(State.AUTHENTICATING, "Authenticating...")
|
||||
_client_nonce = Crypto.new().generate_random_bytes(16).hex_encode()
|
||||
var hello: Dictionary = NetworkProtocol.make_client_hello(
|
||||
_profile.profile_id,
|
||||
_profile.display_name,
|
||||
_client_nonce = NetworkIdentityCrypto.secure_id(32)
|
||||
_client_identity_attempt = NetworkProtocol.make_identity_hello(
|
||||
_player_identity.public_pem,
|
||||
_player_identity.fingerprint,
|
||||
_client_nonce,
|
||||
_local_appearance_snapshot,
|
||||
NetworkIdentityCrypto.secure_id(16),
|
||||
)
|
||||
submit_client_hello.rpc_id(1, hello)
|
||||
submit_identity_hello.rpc_id(1, _client_identity_attempt)
|
||||
|
||||
|
||||
func _on_connection_failed() -> void:
|
||||
if state not in [State.CONNECTING, State.AUTHENTICATING]:
|
||||
if state not in [
|
||||
State.CONNECTING,
|
||||
State.AUTHENTICATING,
|
||||
State.VERIFYING_SERVER_IDENTITY,
|
||||
]:
|
||||
return
|
||||
_teardown_peer()
|
||||
_fail("Could not connect. The server may be private, unavailable, or unreachable.")
|
||||
|
|
@ -456,6 +594,13 @@ func _on_server_disconnected() -> void:
|
|||
return
|
||||
_operation_generation += 1
|
||||
_teardown_peer()
|
||||
if state in [
|
||||
State.CONNECTING,
|
||||
State.AUTHENTICATING,
|
||||
State.VERIFYING_SERVER_IDENTITY,
|
||||
]:
|
||||
_fail("The server rejected or ended authentication.")
|
||||
return
|
||||
_set_state(State.SERVER_LOST, "The server connection was lost.")
|
||||
server_lost.emit()
|
||||
connection_error.emit("The server connection was lost.")
|
||||
|
|
@ -463,6 +608,8 @@ func _on_server_disconnected() -> void:
|
|||
|
||||
func _on_peer_disconnected(peer_id: int) -> void:
|
||||
_pending_authentication.erase(peer_id)
|
||||
_pending_identity_challenges.erase(peer_id)
|
||||
_authenticated_identity_cache.erase(peer_id)
|
||||
if _registry.has_peer(peer_id):
|
||||
_registry.remove_peer(peer_id)
|
||||
_spawn_service.remove_peer(peer_id)
|
||||
|
|
@ -472,6 +619,190 @@ func _on_peer_disconnected(peer_id: int) -> void:
|
|||
_emit_peer_count()
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func submit_identity_hello(data: Dictionary) -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
if not is_host() or sender_id <= 1 or not _pending_authentication.has(sender_id):
|
||||
return
|
||||
if (
|
||||
not NetworkProtocol.validate_identity_hello(data)
|
||||
or int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION
|
||||
):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
|
||||
)
|
||||
return
|
||||
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["public_key"])
|
||||
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
|
||||
if (
|
||||
fingerprint != str(data["fingerprint"])
|
||||
or NetworkIdentityCrypto.load_public_key(public_pem) == null
|
||||
):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF
|
||||
)
|
||||
return
|
||||
if _registry.has_fingerprint(fingerprint):
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.DUPLICATE_IDENTITY)
|
||||
return
|
||||
var server_nonce := NetworkIdentityCrypto.secure_id(32)
|
||||
var challenge := {
|
||||
"peer_id": sender_id,
|
||||
"attempt_id": str(data["attempt_id"]),
|
||||
"client_nonce": str(data["client_nonce"]),
|
||||
"client_fingerprint": fingerprint,
|
||||
"client_public_key": public_pem,
|
||||
"server_nonce": server_nonce,
|
||||
"server_fingerprint": _host_identity.fingerprint,
|
||||
"session_id": _session_id,
|
||||
"generation": _operation_generation,
|
||||
"expires_at_msec": Time.get_ticks_msec() + 60000,
|
||||
}
|
||||
var fields := _identity_proof_fields(challenge)
|
||||
var proof := challenge.duplicate(true)
|
||||
proof["server_public_key"] = _host_identity.public_pem
|
||||
proof["server_signature"] = _host_identity.sign(
|
||||
"handshake_server_proof", fields
|
||||
)
|
||||
_pending_identity_challenges[sender_id] = challenge
|
||||
receive_server_identity_proof.rpc_id(sender_id, proof)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func receive_server_identity_proof(data: Dictionary) -> void:
|
||||
if state != State.AUTHENTICATING or not _valid_server_proof_shape(data):
|
||||
return
|
||||
if (
|
||||
str(data["attempt_id"]) != str(_client_identity_attempt.get("attempt_id", ""))
|
||||
or str(data["client_nonce"]) != _client_nonce
|
||||
):
|
||||
_fail_identity("The server identity proof did not match this connection.")
|
||||
return
|
||||
var public_pem := NetworkIdentityCrypto.normalize_public_pem(data["server_public_key"])
|
||||
var fingerprint := NetworkIdentityCrypto.fingerprint_public_pem(public_pem)
|
||||
var public_key := NetworkIdentityCrypto.load_public_key(public_pem)
|
||||
if (
|
||||
fingerprint != str(data["server_fingerprint"])
|
||||
or not NetworkIdentityCrypto.verify_fields(
|
||||
public_key,
|
||||
"handshake_server_proof",
|
||||
_identity_proof_fields(data),
|
||||
data["server_signature"],
|
||||
)
|
||||
):
|
||||
_fail_identity("The server identity proof was invalid.")
|
||||
return
|
||||
_pending_server_proof = data.duplicate(true)
|
||||
_server_identity_fingerprint = fingerprint
|
||||
_server_identity_public_key = public_pem
|
||||
var verification := _server_trust.verify(
|
||||
get_current_endpoint(), fingerprint
|
||||
)
|
||||
if verification == ServerTrustStore.Verification.MATCH:
|
||||
_server_trust.touch(get_current_endpoint())
|
||||
_send_client_identity_proof()
|
||||
return
|
||||
_set_state(
|
||||
State.VERIFYING_SERVER_IDENTITY,
|
||||
"Confirm this server identity before continuing.",
|
||||
)
|
||||
_connection_deadline = 0.0
|
||||
var expected := str(
|
||||
_server_trust.get_record(get_current_endpoint()).get("fingerprint", "")
|
||||
)
|
||||
server_trust_required.emit(
|
||||
get_current_route_display(),
|
||||
expected,
|
||||
fingerprint,
|
||||
verification == ServerTrustStore.Verification.CHANGED,
|
||||
)
|
||||
|
||||
|
||||
func resolve_server_trust(accepted: bool) -> void:
|
||||
if state != State.VERIFYING_SERVER_IDENTITY:
|
||||
return
|
||||
if not accepted:
|
||||
cancel_connection()
|
||||
return
|
||||
if not _server_trust.trust(
|
||||
get_current_endpoint(),
|
||||
str(_pending_server_proof["server_fingerprint"]),
|
||||
"NETFISHING",
|
||||
):
|
||||
_fail_identity("The server identity could not be pinned.")
|
||||
return
|
||||
_set_state(State.AUTHENTICATING, "Authenticating identity...")
|
||||
_connection_deadline = (
|
||||
Time.get_ticks_msec() / 1000.0 + CONNECTION_TIMEOUT_SECONDS
|
||||
)
|
||||
_send_client_identity_proof()
|
||||
|
||||
|
||||
func _send_client_identity_proof() -> void:
|
||||
var proof := {
|
||||
"attempt_id": _pending_server_proof["attempt_id"],
|
||||
"session_id": _pending_server_proof["session_id"],
|
||||
"client_fingerprint": _player_identity.fingerprint,
|
||||
"client_signature": _player_identity.sign(
|
||||
"handshake_client_proof",
|
||||
_identity_proof_fields(_pending_server_proof),
|
||||
),
|
||||
}
|
||||
submit_client_identity_proof.rpc_id(1, proof)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func submit_client_identity_proof(data: Dictionary) -> void:
|
||||
var sender_id := multiplayer.get_remote_sender_id()
|
||||
var challenge: Dictionary = _pending_identity_challenges.get(sender_id, {})
|
||||
if (
|
||||
not is_host()
|
||||
or challenge.is_empty()
|
||||
or Time.get_ticks_msec() > int(challenge["expires_at_msec"])
|
||||
or data.get("attempt_id") != challenge["attempt_id"]
|
||||
or data.get("session_id") != _session_id
|
||||
or data.get("client_fingerprint") != challenge["client_fingerprint"]
|
||||
or typeof(data.get("client_signature")) != TYPE_PACKED_BYTE_ARRAY
|
||||
):
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
|
||||
return
|
||||
var verified := NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(challenge["client_public_key"]),
|
||||
"handshake_client_proof",
|
||||
_identity_proof_fields(challenge),
|
||||
data["client_signature"],
|
||||
)
|
||||
_pending_identity_challenges.erase(sender_id)
|
||||
if not verified:
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
|
||||
return
|
||||
_authenticated_identity_cache[sender_id] = {
|
||||
"fingerprint": challenge["client_fingerprint"],
|
||||
"public_key": challenge["client_public_key"],
|
||||
}
|
||||
request_client_profile.rpc_id(sender_id)
|
||||
|
||||
|
||||
@rpc("authority", "call_remote", "reliable", 0)
|
||||
func request_client_profile() -> void:
|
||||
if state != State.AUTHENTICATING:
|
||||
return
|
||||
var hello := NetworkProtocol.make_client_hello(
|
||||
_profile.profile_id,
|
||||
_profile.display_name,
|
||||
_client_nonce,
|
||||
_local_appearance_snapshot,
|
||||
_player_identity.fingerprint,
|
||||
)
|
||||
hello["identity_signature"] = _player_identity.sign(
|
||||
"handshake_client_profile",
|
||||
NetworkProtocol.client_profile_fields(hello),
|
||||
)
|
||||
submit_client_hello.rpc_id(1, hello)
|
||||
|
||||
|
||||
@rpc("any_peer", "call_remote", "reliable", 0)
|
||||
func submit_client_hello(data: Dictionary) -> void:
|
||||
var sender_id: int = multiplayer.get_remote_sender_id()
|
||||
|
|
@ -479,27 +810,27 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
return
|
||||
var validation_error: String = NetworkProtocol.validate_client_hello(data)
|
||||
if not validation_error.is_empty():
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE
|
||||
)
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.MALFORMED_HANDSHAKE)
|
||||
return
|
||||
if int(data["protocol_version"]) != NetworkProtocol.PROTOCOL_VERSION:
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.PROTOCOL_MISMATCH
|
||||
var identity: Dictionary = _authenticated_identity_cache.get(sender_id, {})
|
||||
if identity.is_empty():
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
|
||||
return
|
||||
if (
|
||||
data["identity_fingerprint"] != identity["fingerprint"]
|
||||
or not NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(identity["public_key"]),
|
||||
"handshake_client_profile",
|
||||
NetworkProtocol.client_profile_fields(data),
|
||||
data["identity_signature"],
|
||||
)
|
||||
):
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.INVALID_IDENTITY_PROOF)
|
||||
return
|
||||
if _registry.size() >= session_max_players:
|
||||
_reject_peer(sender_id, NetworkProtocol.RejectionCode.SERVER_FULL)
|
||||
return
|
||||
var profile_id: String = data["local_profile_id"]
|
||||
if _registry.has_profile(profile_id):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
NetworkProtocol.RejectionCode.DUPLICATE_PROFILE
|
||||
)
|
||||
return
|
||||
var display_name: String = data["display_name"]
|
||||
if not NetworkProfilePreferences.is_valid_display_name(display_name):
|
||||
_reject_peer(
|
||||
|
|
@ -511,7 +842,9 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
sender_id,
|
||||
profile_id,
|
||||
display_name,
|
||||
NetworkProtocol.PROTOCOL_VERSION
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
identity["fingerprint"],
|
||||
identity["public_key"],
|
||||
):
|
||||
_reject_peer(
|
||||
sender_id,
|
||||
|
|
@ -522,7 +855,20 @@ func submit_client_hello(data: Dictionary) -> void:
|
|||
data["cosmetic_snapshot"]
|
||||
)
|
||||
_registry.update_appearance(sender_id, submitted_appearance)
|
||||
var peer_record := _registry.get_peer(sender_id)
|
||||
if peer_record != null:
|
||||
peer_record.profile_authorization = {
|
||||
"domain": "handshake_client_profile",
|
||||
"signature": data["identity_signature"],
|
||||
"client_nonce": data["client_nonce"],
|
||||
}
|
||||
_archive_authenticated_identity(peer_record)
|
||||
var identity_status := _known_players.observe(
|
||||
str(identity["fingerprint"]), display_name
|
||||
)
|
||||
peer_identity_observed.emit(sender_id, identity_status)
|
||||
_pending_authentication.erase(sender_id)
|
||||
_authenticated_identity_cache.erase(sender_id)
|
||||
var spawn_index: int = _registry.get_peer_ids().find(sender_id)
|
||||
var spawn_transform: Transform3D = (
|
||||
_spawn_service.get_spawn_transform_for_index(spawn_index)
|
||||
|
|
@ -625,9 +971,14 @@ func receive_server_hello(data: Dictionary) -> void:
|
|||
local_peer_id,
|
||||
_profile.profile_id,
|
||||
_profile.display_name,
|
||||
NetworkProtocol.PROTOCOL_VERSION
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
_player_identity.fingerprint,
|
||||
_player_identity.public_pem,
|
||||
)
|
||||
_registry.update_appearance(local_peer_id, _local_appearance_snapshot)
|
||||
var local_record := _registry.get_peer(local_peer_id)
|
||||
if local_record != null:
|
||||
_archive_authenticated_identity(local_record)
|
||||
_spawn_service.clear_remote_players()
|
||||
_spawn_service.register_local_player(local_peer_id)
|
||||
var local_avatar := _spawn_service.get_avatar(local_peer_id)
|
||||
|
|
@ -681,6 +1032,8 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
|
|||
or typeof(entry.get("yaw")) not in [TYPE_FLOAT, TYPE_INT]
|
||||
):
|
||||
return
|
||||
if not _verify_spawn_identity(entry):
|
||||
return
|
||||
var peer_id: int = entry["peer_id"]
|
||||
if peer_id == multiplayer.get_unique_id():
|
||||
var own_position: Array = entry["position"]
|
||||
|
|
@ -699,8 +1052,18 @@ func _apply_spawn_entry(entry: Dictionary) -> void:
|
|||
peer_id,
|
||||
entry["profile_id"],
|
||||
entry["display_name"],
|
||||
NetworkProtocol.PROTOCOL_VERSION
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
str(entry.get("identity_fingerprint", "")),
|
||||
str(entry.get("identity_public_key", "")),
|
||||
)
|
||||
var added_record := _registry.get_peer(peer_id)
|
||||
if added_record != null and added_record.identity_authenticated:
|
||||
_archive_authenticated_identity(added_record)
|
||||
var status := _known_players.observe(
|
||||
added_record.identity_fingerprint,
|
||||
added_record.display_name,
|
||||
)
|
||||
peer_identity_observed.emit(peer_id, status)
|
||||
if typeof(entry.get("appearance")) == TYPE_DICTIONARY:
|
||||
var appearance := CharacterCustomizationCatalog.sanitized_snapshot(
|
||||
entry["appearance"]
|
||||
|
|
@ -757,9 +1120,121 @@ func _make_spawn_entry(
|
|||
if record != null
|
||||
else CharacterCustomizationCatalog.default_snapshot()
|
||||
),
|
||||
"identity_fingerprint": (
|
||||
record.identity_fingerprint if record != null else ""
|
||||
),
|
||||
"identity_public_key": (
|
||||
record.identity_public_key if record != null else ""
|
||||
),
|
||||
"profile_authorization": (
|
||||
record.profile_authorization.duplicate(true)
|
||||
if record != null else {}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
func _verify_spawn_identity(entry: Dictionary) -> bool:
|
||||
var fingerprint := str(entry.get("identity_fingerprint", ""))
|
||||
var public_pem := NetworkIdentityCrypto.normalize_public_pem(
|
||||
str(entry.get("identity_public_key", ""))
|
||||
)
|
||||
if (
|
||||
NetworkIdentityCrypto.fingerprint_public_pem(public_pem) != fingerprint
|
||||
or typeof(entry.get("profile_authorization")) != TYPE_DICTIONARY
|
||||
):
|
||||
return false
|
||||
var authorization: Dictionary = entry["profile_authorization"]
|
||||
if authorization.is_empty():
|
||||
return int(entry.get("peer_id", 0)) == 1
|
||||
if authorization.get("domain") == "handshake_client_profile":
|
||||
var hello := NetworkProtocol.make_client_hello(
|
||||
str(entry.get("profile_id", "")),
|
||||
str(entry.get("display_name", "")),
|
||||
str(authorization.get("client_nonce", "")),
|
||||
Dictionary(entry.get("appearance", {})),
|
||||
fingerprint,
|
||||
authorization.get("signature", PackedByteArray()),
|
||||
)
|
||||
return NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(public_pem),
|
||||
"handshake_client_profile",
|
||||
NetworkProtocol.client_profile_fields(hello),
|
||||
hello["identity_signature"],
|
||||
)
|
||||
if authorization.has("sender_signature"):
|
||||
var signed := authorization.duplicate(true)
|
||||
signed["display_name"] = entry.get("display_name", "")
|
||||
signed["appearance"] = entry.get("appearance", {})
|
||||
return NetworkIdentityCrypto.verify_fields(
|
||||
NetworkIdentityCrypto.load_public_key(public_pem),
|
||||
"profile_update",
|
||||
NetworkProfileProtocol.signature_fields(signed),
|
||||
signed["sender_signature"],
|
||||
)
|
||||
return false
|
||||
|
||||
|
||||
func _identity_proof_fields(data: Dictionary) -> Array:
|
||||
return [
|
||||
NetworkProtocol.PROTOCOL_VERSION,
|
||||
str(data.get("attempt_id", "")),
|
||||
str(data.get("client_fingerprint", "")),
|
||||
str(data.get("client_nonce", "")),
|
||||
str(data.get("server_fingerprint", "")),
|
||||
str(data.get("server_nonce", "")),
|
||||
str(data.get("session_id", "")),
|
||||
int(data.get("peer_id", 0)),
|
||||
int(data.get("generation", 0)),
|
||||
int(data.get("expires_at_msec", 0)),
|
||||
]
|
||||
|
||||
|
||||
func _archive_authenticated_identity(record: PeerRegistry.PeerRecord) -> void:
|
||||
if (
|
||||
record != null
|
||||
and record.identity_authenticated
|
||||
and NetworkIdentityCrypto.fingerprint_public_pem(
|
||||
record.identity_public_key
|
||||
) == record.identity_fingerprint
|
||||
):
|
||||
_session_identity_keys[record.identity_fingerprint] = (
|
||||
NetworkIdentityCrypto.normalize_public_pem(
|
||||
record.identity_public_key
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
func _valid_server_proof_shape(data: Variant) -> bool:
|
||||
if typeof(data) != TYPE_DICTIONARY:
|
||||
return false
|
||||
var value: Dictionary = data
|
||||
return (
|
||||
typeof(value.get("attempt_id")) == TYPE_STRING
|
||||
and str(value["attempt_id"]).length() == 32
|
||||
and typeof(value.get("client_nonce")) == TYPE_STRING
|
||||
and str(value["client_nonce"]).length() == 64
|
||||
and typeof(value.get("server_nonce")) == TYPE_STRING
|
||||
and str(value["server_nonce"]).length() == 64
|
||||
and NetworkIdentityCrypto.valid_fingerprint(value.get("client_fingerprint"))
|
||||
and NetworkIdentityCrypto.valid_fingerprint(value.get("server_fingerprint"))
|
||||
and typeof(value.get("server_public_key")) == TYPE_STRING
|
||||
and str(value["server_public_key"]).to_utf8_buffer().size()
|
||||
<= NetworkProtocol.MAX_PUBLIC_KEY_LENGTH
|
||||
and typeof(value.get("server_signature")) == TYPE_PACKED_BYTE_ARRAY
|
||||
and PackedByteArray(value["server_signature"]).size()
|
||||
<= NetworkIdentityCrypto.MAX_SIGNATURE_BYTES
|
||||
and typeof(value.get("session_id")) == TYPE_STRING
|
||||
and typeof(value.get("peer_id")) == TYPE_INT
|
||||
and typeof(value.get("generation")) == TYPE_INT
|
||||
and typeof(value.get("expires_at_msec")) == TYPE_INT
|
||||
)
|
||||
|
||||
|
||||
func _fail_identity(message: String) -> void:
|
||||
_teardown_peer()
|
||||
_fail(message)
|
||||
|
||||
|
||||
func _send_local_input() -> void:
|
||||
var peer_id: int = multiplayer.get_unique_id()
|
||||
var avatar: Player = _spawn_service.get_avatar(peer_id)
|
||||
|
|
@ -944,12 +1419,19 @@ func _is_transition_allowed(from_state: State, to_state: State) -> bool:
|
|||
State.DISCONNECTING,
|
||||
State.INACTIVE,
|
||||
],
|
||||
State.AUTHENTICATING: [
|
||||
State.JOINED_CLIENT,
|
||||
State.AUTHENTICATING: [
|
||||
State.VERIFYING_SERVER_IDENTITY,
|
||||
State.JOINED_CLIENT,
|
||||
State.CONNECTION_FAILED,
|
||||
State.DISCONNECTING,
|
||||
State.INACTIVE,
|
||||
],
|
||||
],
|
||||
State.VERIFYING_SERVER_IDENTITY: [
|
||||
State.AUTHENTICATING,
|
||||
State.CONNECTION_FAILED,
|
||||
State.DISCONNECTING,
|
||||
State.INACTIVE,
|
||||
],
|
||||
State.JOINED_CLIENT: [
|
||||
State.DISCONNECTING,
|
||||
State.SERVER_LOST,
|
||||
|
|
@ -979,6 +1461,10 @@ func _fail(message: String) -> void:
|
|||
|
||||
func _teardown_peer() -> void:
|
||||
_pending_authentication.clear()
|
||||
_pending_identity_challenges.clear()
|
||||
_authenticated_identity_cache.clear()
|
||||
_client_identity_attempt.clear()
|
||||
_pending_server_proof.clear()
|
||||
_registry.clear()
|
||||
if _spawn_service != null:
|
||||
_spawn_service.clear_remote_players()
|
||||
|
|
@ -989,3 +1475,6 @@ func _teardown_peer() -> void:
|
|||
_input_accumulator = 0.0
|
||||
_snapshot_accumulator = 0.0
|
||||
_server_capabilities = PackedStringArray()
|
||||
_server_identity_fingerprint = ""
|
||||
_server_identity_public_key = ""
|
||||
_session_identity_keys.clear()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ class PeerRecord:
|
|||
var appearance_snapshot: Dictionary = (
|
||||
CharacterCustomizationCatalog.default_snapshot()
|
||||
)
|
||||
var identity_fingerprint: String = ""
|
||||
var identity_public_key: String = ""
|
||||
var identity_authenticated: bool = false
|
||||
var profile_authorization: Dictionary = {}
|
||||
|
||||
|
||||
var _records: Dictionary[int, PeerRecord] = {}
|
||||
|
|
@ -22,8 +26,17 @@ func add_peer(
|
|||
profile_id: String,
|
||||
display_name: String,
|
||||
protocol_version: int,
|
||||
identity_fingerprint: String = "",
|
||||
identity_public_key: String = "",
|
||||
) -> bool:
|
||||
if peer_id <= 0 or profile_id.is_empty() or has_profile(profile_id):
|
||||
if (
|
||||
peer_id <= 0
|
||||
or profile_id.is_empty()
|
||||
or (
|
||||
not identity_fingerprint.is_empty()
|
||||
and has_fingerprint(identity_fingerprint)
|
||||
)
|
||||
):
|
||||
return false
|
||||
var record := PeerRecord.new()
|
||||
record.peer_id = peer_id
|
||||
|
|
@ -31,6 +44,11 @@ func add_peer(
|
|||
record.display_name = display_name
|
||||
record.protocol_version = protocol_version
|
||||
record.joined_at_unix = int(Time.get_unix_time_from_system())
|
||||
record.identity_fingerprint = identity_fingerprint
|
||||
record.identity_public_key = identity_public_key
|
||||
record.identity_authenticated = NetworkIdentityCrypto.valid_fingerprint(
|
||||
identity_fingerprint
|
||||
)
|
||||
_records[peer_id] = record
|
||||
return true
|
||||
|
||||
|
|
@ -72,6 +90,13 @@ func has_profile(profile_id: String) -> bool:
|
|||
return false
|
||||
|
||||
|
||||
func has_fingerprint(fingerprint: String) -> bool:
|
||||
for record: PeerRecord in _records.values():
|
||||
if record.identity_fingerprint == fingerprint:
|
||||
return true
|
||||
return false
|
||||
|
||||
|
||||
func get_peer_ids() -> Array[int]:
|
||||
var result: Array[int] = []
|
||||
for peer_id: int in _records:
|
||||
|
|
|
|||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue