- 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
101 lines
4.2 KiB
GDScript
101 lines
4.2 KiB
GDScript
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 ""
|