M2.1: dedicated server, joining, identity handshake, names, lobby
- Net autoload: headless dedicated server (--server) on UDP 24242 via ENet, server.json preferences (flags override per run, never saved), reserved names in names.json - Identity: RSA-2048 key per install (Godot has no Ed25519), player id = key hash, challenge/response so ids can't be faked; profile with name and limited appearance (tunic, hair, beard) - Server refuses bans, allowlist misses, duplicate logins, bad or taken names, wrong passwords, old protocol versions and a full server, each with a clear reason; first player on a new server becomes admin - Title screen: Profile, Join Game, and a lobby with server info, rules and who's online - test_net_lobby: real headless server + bot processes over UDP (38 checks); --exit-with so test servers never outlive the test
This commit is contained in:
100
scripts/net/identity.gd
Normal file
100
scripts/net/identity.gd
Normal file
@@ -0,0 +1,100 @@
|
||||
extends RefCounted
|
||||
## Who you are on multiplayer servers: a private key, a display name and how
|
||||
## your character looks. No accounts and no passwords.
|
||||
##
|
||||
## - The first time the game runs it generates an RSA-2048 key pair and keeps
|
||||
## the private key in `<dir>/key.pem`. It never leaves this machine.
|
||||
## - Your **player id** is a hash of the public key. Servers recognise you by
|
||||
## it (your reserved name, your admin rights, later your inventory).
|
||||
## - When you join, the server sends a random challenge and you sign it.
|
||||
## Nobody can claim your id without your private key.
|
||||
## - `<dir>/profile.json` holds your name, appearance and last server.
|
||||
##
|
||||
## Godot's Crypto has no Ed25519, hence RSA (docs/MULTIPLAYER_PLAN.md §3).
|
||||
|
||||
## Limited appearance for v1: a tunic colour, a hair colour, a beard or not.
|
||||
const TUNICS := [Color(0.55, 0.22, 0.18), Color(0.22, 0.38, 0.58), Color(0.28, 0.46, 0.24),
|
||||
Color(0.62, 0.52, 0.28), Color(0.42, 0.28, 0.52), Color(0.35, 0.35, 0.36)]
|
||||
const HAIR := [Color(0.18, 0.12, 0.08), Color(0.45, 0.28, 0.14), Color(0.78, 0.6, 0.32),
|
||||
Color(0.62, 0.22, 0.1), Color(0.55, 0.55, 0.55), Color(0.1, 0.1, 0.1)]
|
||||
const DEFAULT_DIR := "user://identity"
|
||||
|
||||
var dir := DEFAULT_DIR
|
||||
var key: CryptoKey
|
||||
var public_pem := ""
|
||||
var player_id := ""
|
||||
var name := ""
|
||||
var appearance := {"tunic": 0, "hair": 0, "beard": false}
|
||||
var last_server := ""
|
||||
|
||||
## Loads the identity in `d`, creating the key on first use.
|
||||
static func open(d: String = DEFAULT_DIR) -> RefCounted:
|
||||
var me = load("res://scripts/net/identity.gd").new()
|
||||
me.dir = d
|
||||
DirAccess.make_dir_recursive_absolute(d)
|
||||
var key_path := d.path_join("key.pem")
|
||||
me.key = CryptoKey.new()
|
||||
if not FileAccess.file_exists(key_path) or me.key.load(key_path) != OK:
|
||||
me.key = Crypto.new().generate_rsa(2048)
|
||||
var err: int = me.key.save(key_path)
|
||||
if err != OK:
|
||||
push_error("[identity] couldn't save the key to %s (%d)" % [key_path, err])
|
||||
me.public_pem = me.key.save_to_string(true)
|
||||
me.player_id = id_for(me.public_pem)
|
||||
var p: Variant = JSON.parse_string(FileAccess.get_file_as_string(d.path_join("profile.json"))) \
|
||||
if FileAccess.file_exists(d.path_join("profile.json")) else null
|
||||
if p is Dictionary:
|
||||
me.name = String(p.get("name", ""))
|
||||
me.appearance = sanitize_appearance(p.get("appearance", {}))
|
||||
me.last_server = String(p.get("last_server", ""))
|
||||
return me
|
||||
|
||||
func save_profile() -> void:
|
||||
var f := FileAccess.open(dir.path_join("profile.json"), FileAccess.WRITE)
|
||||
if f == null:
|
||||
push_error("[identity] couldn't write the profile")
|
||||
return
|
||||
f.store_string(JSON.stringify({"name": name, "appearance": appearance, "last_server": last_server}, "\t"))
|
||||
|
||||
## Signs a server's challenge with the private key.
|
||||
func sign(nonce: PackedByteArray) -> PackedByteArray:
|
||||
return Crypto.new().sign(HashingContext.HASH_SHA256, _sha256(nonce), key)
|
||||
|
||||
## A short, stable id from a public key (first 20 hex characters of SHA-256).
|
||||
static func id_for(pem: String) -> String:
|
||||
return pem.sha256_text().substr(0, 20)
|
||||
|
||||
## Whether `sig` is the owner of `pem` signing `nonce`.
|
||||
static func verify(pem: String, nonce: PackedByteArray, sig: PackedByteArray) -> bool:
|
||||
var pk := CryptoKey.new()
|
||||
if pem.length() > 4096 or pk.load_from_string(pem, true) != OK:
|
||||
return false
|
||||
return Crypto.new().verify(HashingContext.HASH_SHA256, _sha256(nonce), sig, pk)
|
||||
|
||||
static func _sha256(b: PackedByteArray) -> PackedByteArray:
|
||||
var h := HashingContext.new()
|
||||
h.start(HashingContext.HASH_SHA256)
|
||||
h.update(b)
|
||||
return h.finish()
|
||||
|
||||
## Clamps appearance from a file or the network to the allowed choices.
|
||||
static func sanitize_appearance(d: Variant) -> Dictionary:
|
||||
var a: Dictionary = d if d is Dictionary else {}
|
||||
return {
|
||||
"tunic": clampi(int(a.get("tunic", 0)), 0, TUNICS.size() - 1),
|
||||
"hair": clampi(int(a.get("hair", 0)), 0, HAIR.size() - 1),
|
||||
"beard": bool(a.get("beard", false)),
|
||||
}
|
||||
|
||||
## "" if `n` is an allowed display name, otherwise why not.
|
||||
static func name_problem(n: String) -> String:
|
||||
if n != n.strip_edges():
|
||||
return "Names can't start or end with a space"
|
||||
if n.length() < 3 or n.length() > 16:
|
||||
return "Names need 3-16 characters"
|
||||
if n.contains(" "):
|
||||
return "Names can't have double spaces"
|
||||
var re := RegEx.create_from_string("^[A-Za-z0-9 _\\-]+$")
|
||||
if re.search(n) == null:
|
||||
return "Names can use letters, digits, spaces, _ and -"
|
||||
return ""
|
||||
Reference in New Issue
Block a user