OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / identity_flow.gd

386 lines · gd ref:
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