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 `/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. ## - `/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 ""