extends Node
## In-game keet identity: first-run naming + vault, back up / change the vault
## password, show the recovery phrase, restore from a phrase, rename
## (hyper-games #89 §3.2 + §5).
##
## Before this, the only way to name an identity or back it up was the
## launcher's `identity create`, a TTY prompt over the Unix socket. Windows
## (TCP transport) had neither. Everything here is plain daemon RPCs, so it
## works the same over UDS and TCP.
##
## The vault (keet-daemon lib/secure-storage.js) keeps the identity's mnemonic
## encrypted under a password (Argon2id + secretbox, mnemonic.enc).
## vault.setup {password} back up THIS identity (never replaces it)
## vault.setup {password, oldPassword} change the vault password
## vault.setup {password, phrase} back up when keet-core already dropped
## the phrase (Keet desktop does): the
## daemon refuses any phrase that is not
## this identity's
## vault.export {password} read the phrase back
## identity-manager.import {phrase, password, name}
## RESTORE: a NEW identity-N, swapped in;
## the current identity stays on disk
##
## `client` is anything with KeetUdsClient's call_rpc(method, params, cb, timeout)
## (the gate passes a scripted stub). Handlers are named methods + bind, never
## multi-line lambdas (they have hung headless runs).
signal identity_changed ## name set, vault changed, or identity restored
signal identity_swapping ## about to switch identity: leave the room first
const KeetNames := preload("res://addons/keet_social/keet_names.gd")
const MIN_PASSWORD := 8
const MAX_NAME := 64
const PHRASE_WORDS := [12, 15, 18, 21, 24]
const VAULT_TIMEOUT := 180.0 # Argon2id + first-boot onlineBoot can be slow
const PHRASE_NOT_HELD := "pass phrase" # vault.setup's error when core dropped the phrase
var client: Object = null
## False on headless runs (dedicated / --daemon-only): never block them on a modal.
var interactive: bool = DisplayServer.get_name() != "headless"
## The open form: {kind, dlg, inputs: {key: LineEdit|TextEdit}, status: Label}.
## Exposed for the gate.
var form: Dictionary = {}
var _first_run_open := false
# ---- First run ---------------------------------------------------------------
## Called with every `identity` reply. An identity with no name gets the
## required first-run step: a name, and (if there is no vault yet) a password
## for the backup of its 24 words.
func check_first_run(ident: Dictionary) -> void:
if not interactive or _first_run_open:
return
if not KeetNames.clean(ident.get("displayName")).is_empty():
return
if not KeetNames.clean(ident.get("username")).is_empty():
return
_first_run_open = true
client.call_rpc("vault.status", {}, _on_first_run_status)
func _on_first_run_status(result: Variant, err: Variant) -> void:
var has_vault: bool = err == null and result is Dictionary and bool(result.get("exists", false))
var fields: Array = [{"key": "name", "label": "Your name (how you appear in keet rooms)"}]
var note := "Choose your name. This is how you appear in keet rooms and in game."
if not has_vault:
fields.append({"key": "password", "label": "Password", "secret": true})
fields.append({"key": "confirm", "label": "Confirm password", "secret": true})
note += "\n\nThe password protects a backup of your identity's 24-word recovery phrase on this device. You need it to see the phrase later."
_open_form("first_run", "Welcome", "Continue", fields, note, false)
form["needs_vault"] = not has_vault
form.dlg.confirmed.connect(_on_first_run_ok)
func _on_first_run_ok() -> void:
var name := _text("name").strip_edges()
if name.is_empty() or name.length() > MAX_NAME:
_status("Enter a name (1–%d characters)." % MAX_NAME)
return
if form.get("needs_vault", false):
var pw_err := _check_password(_text("password"), _text("confirm"))
if not pw_err.is_empty():
_status(pw_err)
return
_busy("Backing up your identity…")
client.call_rpc("vault.setup", {"password": _text("password")}, _on_first_run_vault.bind(name), VAULT_TIMEOUT)
else:
_set_name(name)
func _on_first_run_vault(_result: Variant, err: Variant, name: String) -> void:
if err != null and not str(err).contains(PHRASE_NOT_HELD):
_fail("Backup failed: %s" % str(err))
return
# keet-core no longer holds this identity's phrase (set up in Keet desktop):
# naming still goes ahead; Identity → Back up takes the 24 words later.
_set_name(name)
func _set_name(name: String) -> void:
_busy("Saving your name…")
client.call_rpc("identity.setName", {"name": name}, _on_name_set)
func _on_name_set(_result: Variant, err: Variant) -> void:
if err != null:
_fail("Could not set the name: %s" % str(err))
return
_first_run_open = false
_close()
identity_changed.emit()
# ---- Rename ------------------------------------------------------------------
## The menu's name box: a rename is a keet rename, never a local-only label.
func rename(name: String) -> void:
name = name.strip_edges()
if name.is_empty() or name.length() > MAX_NAME:
return
client.call_rpc("identity.setName", {"name": name}, _on_renamed)
func _on_renamed(_result: Variant, err: Variant) -> void:
if err == null:
identity_changed.emit()
# ---- Back up / change password -----------------------------------------------
func backup() -> void:
client.call_rpc("vault.status", {}, _on_backup_status)
func _on_backup_status(result: Variant, err: Variant) -> void:
var has_vault: bool = err == null and result is Dictionary and bool(result.get("exists", false))
if has_vault:
_open_form("change_password", "Change backup password", "Change", [
{"key": "old", "label": "Current password", "secret": true},
{"key": "password", "label": "New password", "secret": true},
{"key": "confirm", "label": "Confirm new password", "secret": true},
], "Your identity is backed up on this device. This changes the password that protects it.", true)
else:
_open_form("backup", "Back up identity", "Back up", [
{"key": "password", "label": "Password", "secret": true},
{"key": "confirm", "label": "Confirm password", "secret": true},
{"key": "phrase", "label": "Your 24 words", "multiline": true, "hidden": true},
], "Saves your identity's 24-word recovery phrase on this device, encrypted with this password.", true)
form.dlg.confirmed.connect(_on_backup_ok)
func _on_backup_ok() -> void:
var pw_err := _check_password(_text("password"), _text("confirm"))
if not pw_err.is_empty():
_status(pw_err)
return
var params := {"password": _text("password")}
if form.kind == "change_password":
params["oldPassword"] = _text("old")
elif form.inputs.phrase.visible:
var words := _normalize_phrase(_text("phrase"))
if not PHRASE_WORDS.has(words.split(" ").size()):
_status("Enter all of your recovery words, separated by spaces.")
return
params["phrase"] = words
_busy("Saving…")
client.call_rpc("vault.setup", params, _on_backup_done, VAULT_TIMEOUT)
func _on_backup_done(_result: Variant, err: Variant) -> void:
if err != null and form.get("kind", "") == "backup" and str(err).contains(PHRASE_NOT_HELD) \
and not form.inputs.phrase.visible:
form.inputs.phrase.visible = true
form.labels.phrase.visible = true
_idle("This device no longer holds your recovery phrase (Keet keeps it only until you confirm it). Type your words to back them up. They are checked against your identity.")
return
if err != null:
_fail(_friendly(str(err)))
return
_close()
identity_changed.emit()
# ---- Show recovery phrase ----------------------------------------------------
func show_phrase() -> void:
_open_form("show_phrase", "Recovery phrase", "Show", [
{"key": "password", "label": "Backup password", "secret": true},
], "Anyone with these words can take over your identity. Show them only where no one can see your screen.", true)
form.dlg.confirmed.connect(_on_show_phrase_ok)
func _on_show_phrase_ok() -> void:
if form.get("shown", false):
_close()
return
_busy("Unlocking…")
client.call_rpc("vault.export", {"password": _text("password")}, _on_phrase_exported, VAULT_TIMEOUT)
func _on_phrase_exported(result: Variant, err: Variant) -> void:
if err != null:
_fail(_friendly(str(err)))
return
var phrase: String = str(result.get("phrase", "")) if result is Dictionary else ""
var shown := TextEdit.new()
shown.text = phrase
shown.editable = false
shown.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
shown.custom_minimum_size = Vector2(420, 90)
form.box.add_child(shown)
form.inputs["phrase"] = shown
form.inputs.password.text = ""
form.inputs.password.visible = false
form.labels.password.visible = false
form["shown"] = true
form.dlg.get_ok_button().text = "Done"
_idle("Write these words down and keep them offline.")
# ---- Restore -----------------------------------------------------------------
func restore() -> void:
_open_form("restore", "Restore identity", "Restore…", [
{"key": "phrase", "label": "Recovery words", "multiline": true},
{"key": "password", "label": "New backup password", "secret": true},
{"key": "confirm", "label": "Confirm password", "secret": true},
], "Switch this device to the identity of these words. Your current identity is kept on this device.", true)
form.dlg.confirmed.connect(_on_restore_ok)
func _on_restore_ok() -> void:
var words := _normalize_phrase(_text("phrase"))
if not PHRASE_WORDS.has(words.split(" ").size()):
_status("Enter all of your recovery words, separated by spaces.")
return
var pw_err := _check_password(_text("password"), _text("confirm"))
if not pw_err.is_empty():
_status(pw_err)
return
var ask := ConfirmationDialog.new()
ask.title = "Switch identity?"
ask.dialog_text = "This device will switch to the identity of these words: your name, rooms and contacts will be that identity's.\n\nYour current identity stays on this device."
ask.ok_button_text = "Switch"
ask.confirmed.connect(_on_restore_confirmed.bind(ask, words, _text("password")))
ask.canceled.connect(ask.queue_free)
form["ask"] = ask
add_child(ask)
ask.popup_centered()
func _on_restore_confirmed(ask: ConfirmationDialog, words: String, password: String) -> void:
ask.queue_free()
form.erase("ask")
identity_swapping.emit()
_busy("Restoring…")
client.call_rpc("identity-manager.import", {
"phrase": words,
"password": password,
"name": "restored-%d" % int(Time.get_unix_time_from_system()),
}, _on_restored, VAULT_TIMEOUT)
func _on_restored(_result: Variant, err: Variant) -> void:
if err != null:
_fail("Restore failed: %s" % str(err))
return
_close()
identity_changed.emit()
# ---- Form plumbing -----------------------------------------------------------
func _open_form(kind: String, title: String, ok_text: String, fields: Array, note: String, cancellable: bool) -> void:
_close()
var dlg: AcceptDialog = ConfirmationDialog.new() if cancellable else AcceptDialog.new()
dlg.title = title
dlg.ok_button_text = ok_text
dlg.dialog_hide_on_ok = false # stay open until the RPCs succeed
dlg.exclusive = true
if not cancellable:
# Required: closing it (window X) emits canceled — just show it again.
dlg.dialog_close_on_escape = false
dlg.canceled.connect(dlg.popup_centered.bind(Vector2i.ZERO), CONNECT_DEFERRED)
var box := VBoxContainer.new()
box.custom_minimum_size = Vector2(440, 0)
var note_label := Label.new()
note_label.text = note
note_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
box.add_child(note_label)
var inputs := {}
var labels := {}
for f: Dictionary in fields:
var lbl := Label.new()
lbl.text = String(f.label)
box.add_child(lbl)
var input: Control
if f.get("multiline", false):
var te := TextEdit.new()
te.wrap_mode = TextEdit.LINE_WRAPPING_BOUNDARY
te.custom_minimum_size = Vector2(420, 80)
input = te
else:
var le := LineEdit.new()
le.secret = f.get("secret", false)
input = le
box.add_child(input)
if f.get("hidden", false):
lbl.visible = false
input.visible = false
inputs[f.key] = input
labels[f.key] = lbl
var status := Label.new()
status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
status.add_theme_color_override("font_color", Color(1.0, 0.6, 0.4))
box.add_child(status)
dlg.add_child(box)
if cancellable:
dlg.canceled.connect(_close)
form = {"kind": kind, "dlg": dlg, "box": box, "inputs": inputs, "labels": labels, "status": status}
add_child(dlg)
dlg.popup_centered()
func _close() -> void:
if form.is_empty():
return
var dlg: AcceptDialog = form.dlg
form = {}
if is_instance_valid(dlg):
dlg.hide()
dlg.queue_free()
func _text(key: String) -> String:
var input: Variant = form.get("inputs", {}).get(key)
if input is LineEdit or input is TextEdit:
return input.text
return ""
func _status(msg: String) -> void:
if not form.is_empty():
form.status.text = msg
func _busy(msg: String) -> void:
_status(msg)
if not form.is_empty():
form.dlg.get_ok_button().disabled = true
func _idle(msg: String) -> void:
_status(msg)
if not form.is_empty():
form.dlg.get_ok_button().disabled = false
func _fail(msg: String) -> void:
_idle(msg)
static func _check_password(pw: String, confirm: String) -> String:
if pw.length() < MIN_PASSWORD:
return "Use a password of at least %d characters." % MIN_PASSWORD
if pw != confirm:
return "The passwords do not match."
return ""
static func _normalize_phrase(text: String) -> String:
var words := PackedStringArray()
for w in text.strip_edges().to_lower().replace("\n", " ").replace("\t", " ").split(" ", false):
words.append(w)
return " ".join(words)
static func _friendly(err: String) -> String:
if err.contains("Decryption failed"):
return "Wrong password."
if err.contains("Wrong recovery phrase"):
return "Those words are not this identity's recovery phrase."
return err