#!/usr/bin/env bash # Managing a LOCAL user account (see online-accounts for the other kind). # # Managing an account must not leak the credential it sets, and must not let # someone lock themselves out of their own machine. # # Nothing here creates, deletes, or modifies a real account. It reads the # account state, which is safe, and exercises the refusals, which are the part # that has to hold. Every verb that would change something is read from the # source instead of being run -- `set-icon gib ""` really does clear the # avatar, and `delete-user` really does delete, so those are pinned statically # and never invoked. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-users" service="$repo_dir/config/dot/quickshell/services/UserAccounts.qml" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" page="$settings_dir/UsersPage.qml" fail() { printf 'user accounts contract: %s\n' "$1" >&2 exit 1 } for path in "$helper" "$service" "$page"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-users is not executable' # The page was rebuilt into several files, so the add-user form and the # per-user rows may not live in UsersPage.qml any more. Everything structural # is looked up by what it calls rather than by which file it sits in. file_calling() { grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1 } # ── A new password never reaches a command line ───────────────────────────── # argv is world-readable through /proc, so a password passed as an argument is # published to every process on the machine. It is read from stdin, and the # hashing step reads ITS stdin too, so the cleartext exists only inside these # two processes. password_body="$(sed -n '/^def set_password/,/^def /p' "$helper")" [[ -n "$password_body" ]] || fail 'set_password is missing' grep -q 'sys.stdin.buffer.read()' <<<"$password_body" \ || fail 'the new password is not read from stdin' grep -q 'input=secret' <<<"$password_body" \ || fail 'the password is not handed to the hashing tool on stdin' grep -qE '"openssl", "passwd"[^]]*secret' <<<"$password_body" \ && fail 'the password appears in the hashing command line' grep -qE '^\s*print\((secret|hashed)' <<<"$password_body" \ && fail 'the password or its hash is printed' # The service must not hold one either, beyond the moment it hands it over. grep -q 'stdinEnabled' "$service" \ || fail 'the service does not write the password over stdin' grep -qE 'command:.*set-password.*password' "$service" \ && fail 'the service puts the password in the command line' grep -q 'root.pendingPassword = ""' "$service" \ || fail 'the service never clears the password it was holding' # ── Resetting a password is not the same as setting one ───────────────────── # # "Reset" hands the account back to its owner: accountsservice's # SetPasswordMode(1) means "choose one at the next sign-in". The whole point is # that an administrator resetting somebody else's password never learns, types, # or transports a password -- so this verb must not touch stdin, must not reach # for a hashing tool, and must not go anywhere near SetPassword. python3 - "$helper" <<'PY' || fail 'reset-password does not set password mode 1, or it handles password material' import ast import sys tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) target = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "reset_password"), None) if target is None: print("reset-password has no implementation", file=sys.stderr) raise SystemExit(1) # Module constants, so the mode may be a name with a meaning rather than a 1. constants = {} for node in tree.body: if isinstance(node, ast.Assign) and isinstance(node.value, ast.Constant): for name in node.targets: if isinstance(name, ast.Name): constants[name.id] = node.value.value def literal(node): if isinstance(node, ast.Constant): return node.value if isinstance(node, ast.Name): return constants.get(node.id) return None methods = [element.value for node in ast.walk(target) for element in ast.walk(node) if isinstance(element, ast.Constant) and isinstance(element.value, str)] if "SetPasswordMode" not in methods: print("reset-password does not use accountsservice SetPasswordMode", file=sys.stderr) raise SystemExit(1) for forbidden in ("SetPassword", "SetPasswordHint"): if forbidden in methods: print(f"reset-password calls {forbidden}, which carries password material", file=sys.stderr) raise SystemExit(1) # Mode 1: "no usable password, choose one at the next sign-in". 0 would mean a # password is set, 2 would mean none is ever needed -- both are somebody else's # account handed away. modes = [literal(element) for node in ast.walk(target) if isinstance(node, ast.Tuple) for element in node.elts] if 1 not in modes: print(f"reset-password does not ask for mode 1: {modes}", file=sys.stderr) raise SystemExit(1) # Nothing that could be a password may pass through it. The docstring is # excluded on purpose -- it is allowed to say the word. code = ast.dump(ast.Module(body=[node for node in target.body if not (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant))], type_ignores=[])) for word in ("stdin", "openssl", "crypt", "passwd"): if word in code: print(f"reset-password touches {word}; it must only set the mode", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY grep -q 'function resetPassword' "$service" \ || fail 'the service cannot reset a password' grep -qE 'resetPassword[^}]*stdin' "$service" \ && fail 'the service opens stdin for a reset, which carries nothing to write' reset_page="$(file_calling 'resetPassword(')" [[ -n "$reset_page" ]] || fail 'no page offers to reset another account password' grep -q 'at next sign-in\|at their next sign-in' "$reset_page" \ || fail 'the reset row does not say the other person sets the new password themselves' # ── Removing a picture is a real verb, not a deletion of the file ─────────── # # accountsservice takes an empty IconFile to mean "no avatar" and cleans up # after itself. Anything else -- unlinking the file the snapshot named, writing # a blank image -- leaves the database pointing at something that is not there. icon_body="$(sed -n '/^def set_icon/,/^def [a-z_]*(/p' "$helper")" [[ -n "$icon_body" ]] || fail 'set_icon is missing' grep -q 'SetIconFile' <<<"$icon_body" \ || fail 'the avatar is not written through accountsservice' python3 - "$helper" <<'PY' || fail 'set-icon with an empty path does not clear the avatar through SetIconFile("")' import ast import sys tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) target = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "set_icon"), None) if target is None: raise SystemExit(1) dump = ast.dump(target) # An empty path is a value the function has to recognise, not a path it hands # to GdkPixbuf -- which would fail, and the avatar would stay. if 'Constant(value=\'\')' not in dump and 'value=""' not in dump: print("set_icon never compares its path against the empty string", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY grep -q 'function removeIcon' "$service" \ || fail 'the service cannot remove a picture' python3 - "$service" <<'PY' || fail 'removeIcon does not send an empty path to the helper' import re import sys text = open(sys.argv[1], encoding="utf-8").read() start = text.find("function removeIcon") if start < 0: raise SystemExit(1) body = text[start:text.find("\n }", start)] if not re.search(r'"set-icon"[^\]]*""', body): print(body.strip()[:300], file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── Deleting says what happens to the files, and honors the answer ────────── # # The choice is the whole feature: "Keep the files" and "Remove everything" are # different, irreversible outcomes, and a dropdown whose answer is dropped on # the way down is worse than no dropdown at all. Each link is pinned # separately, because any one of them can invert on its own. # 1. The helper turns its argument into accountsservice's boolean. delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")" [[ -n "$delete_body" ]] || fail 'delete_user is missing' grep -q 'You cannot delete the account you are signed in to' <<<"$delete_body" \ || fail 'the helper would delete the account running it' grep -q 'only administrator' <<<"$delete_body" \ || fail 'the helper would remove the last administrator, leaving nobody able to administer the machine' python3 - "$helper" <<'PY' || fail 'the helper does not derive the DeleteUser flag from its keep/remove argument' import ast import sys tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) target = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "delete_user"), None) if target is None: raise SystemExit(1) parameters = [argument.arg for argument in target.args.args] if len(parameters) < 2: print("delete_user takes no keep/remove argument at all", file=sys.stderr) raise SystemExit(1) choice = parameters[1] # The boolean handed to DeleteUser must be computed from that argument. A # literal True or False there is the bug this whole block exists to catch. for node in ast.walk(target): if not isinstance(node, ast.Call): continue name = getattr(node.func, "attr", getattr(node.func, "id", "")) if name != "Variant": continue for element in ast.walk(node): if isinstance(element, ast.Compare) and any( isinstance(sub, ast.Name) and sub.id == choice for sub in ast.walk(element)): raise SystemExit(0) print(f"the DeleteUser flag is not computed from {choice}", file=sys.stderr) raise SystemExit(1) PY # 2. The service maps its own parameter the way its name reads. Panama has # flipped this polarity once already (removeFiles -> keepFiles); a mapping # that says one and sends the other reads correctly in every diff. python3 - "$service" <<'PY' || fail 'the service maps its keep/remove parameter the wrong way round' import re import sys text = open(sys.argv[1], encoding="utf-8").read() match = re.search(r"function deleteUser\(([^)]*)\)", text) if match is None: print("the service has no deleteUser", file=sys.stderr) raise SystemExit(1) parameters = [part.split(":")[0].strip() for part in match.group(1).split(",")] if len(parameters) < 2: print("deleteUser takes no keep/remove argument", file=sys.stderr) raise SystemExit(1) choice = parameters[1] body = text[match.end():text.find("\n }", match.end())] ternary = re.search(re.escape(choice) + r"\s*\?\s*\"([a-z-]+)\"\s*:\s*\"([a-z-]+)\"", body) if ternary is None: print(f"deleteUser does not pass {choice} through to the helper: {body.strip()[:200]}", file=sys.stderr) raise SystemExit(1) when_true, when_false = ternary.groups() wanted = "keep" if "keep" in choice.lower() else "remove" if wanted not in when_true or wanted in when_false: print(f"{choice} true sends {when_true!r}, false sends {when_false!r}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # 3. The page passes the answer, not a constant. delete_page="$(file_calling 'deleteUser(')" [[ -n "$delete_page" ]] || fail 'no page deletes an account' grep -qE 'deleteUser\([^)]*,\s*(true|false)\s*\)' "$delete_page" \ && fail 'the page hardcodes what happens to the files, so the choice it offers is decorative' grep -q 'Keep the files' "$delete_page" \ || fail 'the page never offers to keep the deleted account files' grep -qE 'Remove everything|Delete the files|Remove the files' "$delete_page" \ || fail 'the page never offers to remove the deleted account files' # ── Deleting is confirmed, and says what it destroys ──────────────────────── grep -q 'confirmingRemoval' "$delete_page" \ || fail 'the page deletes an account without a confirmation step' grep -q 'This cannot be undone' "$delete_page" \ || fail 'the page does not say that deleting an account destroys their files' # ── The consent names the real directory, or none ─────────────────────────── # # Four sentences, the armed confirmation among them, built "/home/" + userName # and presented the result as fact. A home directory is not reliably there: it # can be moved, or live on another mount. accountsservice reports the real one # and the helper already carries it, unread. The one place the app was most # confident was the one place it was guessing, and it was asking for consent to # an irreversible deletion at the time. grep -Fq '"homeDirectory": str(values.get("HomeDirectory") or "")' "$helper" \ || fail 'the helper no longer reports the real home directory' grep -Fq 'function homeDirectory(user: var): string' "$service" \ || fail 'the service does not expose the reported home directory' if grep -q '"/home/"' "$delete_page"; then fail 'the deletion consent constructs a home path instead of reading the reported one' fi grep -Fq 'UserAccounts.homeDirectory(' "$delete_page" \ || fail 'the deletion consent does not read the reported home directory' # And an account with no reported path must be described, not invented. grep -Fq 'their home directory' "$delete_page" \ || fail 'an account with no reported home directory has nothing honest to say about it' # The page must not offer to change the type of the only administrator either. type_page="$(file_calling 'administratorCount')" [[ -n "$type_page" ]] || fail 'nothing on the page knows how many administrators there are' grep -q 'administratorCount <= 1' "$type_page" \ || fail 'the page offers to demote the only administrator' # And the helper refuses it regardless of what the page offers. Demoting the # only administrator is the same loss as deleting them -- a machine nobody can # administer -- and the page is not the only thing that can call this. python3 - "$helper" <<'PY' || fail 'the helper would demote the only administrator' import ast import sys tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) target = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "set_account_type"), None) if target is None: print("set-account-type has no implementation", file=sys.stderr) raise SystemExit(1) dump = ast.dump(target) if "administratorCount" not in dump: print("set-account-type never counts the administrators", file=sys.stderr) raise SystemExit(1) if "BoundaryError" not in dump: print("set-account-type counts them and refuses nothing", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── Managing somebody else ────────────────────────────────────────────────── # # Account type, locked accounts and the reset above are the verbs that only # make sense for an account that is not yours, and each has to exist on both # sides or the row is a button that does nothing. grep -q 'function setAccountTypeFor\|function setAccountType' "$service" \ || fail 'the service cannot change another account type' grep -q 'function setLocked' "$service" \ || fail 'the service cannot unlock a locked account' grep -qE '"set-locked"' "$service" \ || fail 'the service does not reach the helper set-locked verb' grep -qE '\bset-locked\b' "$helper" \ || fail 'the helper has no set-locked verb' grep -q 'Unlock' "$(file_calling 'setLocked(')" \ || fail 'a locked account is never offered an unlock' # ── The add-user form validates while it is being typed ───────────────────── # # The regression this pins: the fields were TextFieldRow, which commits on # blur. Typing a perfectly good user name left the Create button disabled, # because the property behind the enabled predicate had not been told yet, and # the form looked broken for as long as the field had focus. So the property # the predicate reads must be updated on every keystroke, and the field it # comes from must not be the blur-committing row. create_page="$(file_calling 'UserAccounts.createUser(')" [[ -n "$create_page" ]] || fail 'nothing creates a user' # The blunt half of the same pin, and the one that cannot be argued with: the # page that carries the add-user form has no blur-committing field anywhere. # LiveFieldRow reports per keystroke and also on accept, so a row that only # wants the accept behaviour has no reason to reach for the old one. grep -nE '^\s*TextFieldRow\s*\{' "$create_page" \ && fail 'the add-user page still has a blur-committing field, which is the bug' python3 - "$create_page" "$helper" <<'PY' || fail 'the add-user form does not validate live' import re import sys page = open(sys.argv[1], encoding="utf-8").read() lines = page.splitlines() # The rules the form enforces: the one regular expression it tests a user name # against, wherever it lives -- an `enabled:` predicate, or a property that # turns the same test into the message under the field. Found by the shape of # the rule rather than by where it sits, because it has moved once already. predicate = next((line for line in lines if ".test(" in line and re.search(r"/\^\[a-z", line)), None) if predicate is None: print("the add-user form validates the user name nowhere at all", file=sys.stderr) raise SystemExit(1) held = re.search(r"\.test\(\s*(?:root\.)?([A-Za-z_][\w.]*)", predicate) if held is None: print(f"cannot tell what the form validates: {predicate.strip()}", file=sys.stderr) raise SystemExit(1) name = held.group(1).split(".")[-1] # They must be the helper's rules. Two expressions that drift apart give a form # that accepts a name accountsservice then refuses, with no way to tell why. helper_rule = re.search(r'USERNAME\s*=\s*re\.compile\(r"([^"]+)"\)', open(sys.argv[2], encoding="utf-8").read()) page_rule = re.search(r"/(\^[^/]+\$)/", predicate) if page_rule is None: print(f"the form's user-name test is not a regular expression: {predicate.strip()}", file=sys.stderr) raise SystemExit(1) if helper_rule and helper_rule.group(1) != page_rule.group(1): print(f"the form validates {page_rule.group(1)}, the helper enforces {helper_rule.group(1)}", file=sys.stderr) raise SystemExit(1) # The cap is part of the rules and part of the sentence under the field: a # 40-character name is accepted by the form and refused by accountsservice. if "31" not in page_rule.group(1): print(f"the form does not cap the user name length: {page_rule.group(1)}", file=sys.stderr) raise SystemExit(1) if "31" not in page: print("the form never tells anybody about the length cap", file=sys.stderr) raise SystemExit(1) # Where that property is written, and by what kind of field. `edited` (or a # text-changed handler) is per-keystroke; `accepted` is blur, which is the bug. assignments = [index for index, line in enumerate(lines) if re.search(rf"\b{re.escape(name)}\s*=", line) and "property" not in line] if not assignments: print(f"{name} is never assigned, so the form can never become valid", file=sys.stderr) raise SystemExit(1) LIVE = re.compile(r"on(Edited|TextChanged|TextEdited|DisplayTextChanged)\b") live = False for index in assignments: window = "\n".join(lines[max(0, index - 4):index + 1]) if LIVE.search(window): live = True # The enclosing component: the nearest `Type {` above, at any indentation. for above in range(index, -1, -1): opener = re.match(r"\s*([A-Z]\w*)\s*\{", lines[above]) if opener: if opener.group(1) == "TextFieldRow": print(f"{name} is committed by a TextFieldRow, which only commits on blur " f"(line {above + 1})", file=sys.stderr) raise SystemExit(1) break if not live: print(f"{name} is only committed on accept, so the form cannot validate while it is typed", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── The snapshot is real, and reports no secrets ──────────────────────────── command -v jq >/dev/null 2>&1 || { printf 'user accounts contract: SKIP (no jq)\n'; exit 0; } snapshot="$("$helper" snapshot 2>/dev/null)" || fail 'snapshot failed' jq -e '.users | type == "array" and length > 0' <<<"$snapshot" >/dev/null \ || fail 'no accounts were reported' jq -e '[.users[] | (.userName | length > 0)] | all' <<<"$snapshot" >/dev/null \ || fail 'an account has no user name' jq -e '.currentUser | length > 0' <<<"$snapshot" >/dev/null \ || fail 'the snapshot does not say which account is signed in' # The page renders these; a snapshot that drops them turns a locked account # into a normal-looking one nobody can sign in to. jq -e '[.users[] | has("locked") and has("loginTime")] | all' <<<"$snapshot" >/dev/null \ || fail 'the snapshot no longer reports whether an account is locked' offenders="$(jq -r '[paths | map(tostring) | join(".")] | map(select(test("(password|secret|hash)$";"i"))) | join(", ")' <<<"$snapshot")" [[ -z "$offenders" ]] || fail "the snapshot carries credential-shaped fields: $offenders" # System accounts are not people and must not be offered for management. jq -e '[.users[] | .uid >= 1000] | all' <<<"$snapshot" >/dev/null \ || fail 'a system account is listed as a manageable user' # ── The stock avatars are a list, of a shape the gallery can draw ─────────── # # Read-only: it lists files that ship with the distribution. An empty list is # a legitimate answer on a machine without the faces package, so the shape is # what is pinned, not the contents. stock="$("$helper" stock-avatars 2>/dev/null)" || fail 'stock-avatars failed' jq -e '.avatars | type == "array"' <<<"$stock" >/dev/null \ || fail "stock-avatars does not answer with a list: $stock" jq -e '[.avatars[] | has("name") and has("path")] | all' <<<"$stock" >/dev/null \ || fail "a stock avatar is missing its name or its path: $stock" jq -e '[.avatars[] | (.path | startswith("/"))] | all' <<<"$stock" >/dev/null \ || fail 'a stock avatar path is not absolute, so nothing can load it' grep -q 'stockAvatars' "$service" \ || fail 'the service does not offer the stock avatars to the gallery' # ── Input validation ──────────────────────────────────────────────────────── for bad in "root; rm -rf /" "../escape" "UPPER" ""; do result="$("$helper" set-real-name "$bad" "Test" 2>/dev/null | jq -r '.error // ""')" [[ -n "$result" ]] || fail "the helper accepted \"$bad\" as a user name" done # Deleting refuses before it reaches accountsservice, not after. [[ -n "$("$helper" delete-user "${USER:-nobody}" 2>/dev/null | jq -r '.error // ""')" ]] \ || fail 'delete-user with no keep/remove answer was accepted' [[ -n "$("$helper" delete-user "${USER:-nobody}" sideways 2>/dev/null | jq -r '.error // ""')" ]] \ || fail 'delete-user accepted an answer that is neither keep nor remove' # Both vocabularies, on purpose: `keep`/`remove` is what the page says out # loud, `keep-files`/`remove-files` is what this helper has always taken and # what anything older still passes. The refusal below is the self-deletion one, # which is reached only once the keep/remove answer has been understood -- so a # vocabulary that stopped being recognized would show up here as the wrong # message rather than as no message. for vocabulary in keep remove keep-files remove-files; do refusal="$("$helper" delete-user "${USER:-nobody}" "$vocabulary" 2>/dev/null \ | jq -r '.error // ""')" [[ -n "$refusal" ]] || fail "the helper agreed to delete the account running it" grep -q 'signed in to' <<<"$refusal" \ || fail "delete-user no longer understands \"$vocabulary\": $refusal" done for bad in "root; rm -rf /" "UPPER" ""; do [[ -n "$("$helper" reset-password "$bad" 2>/dev/null | jq -r '.error // ""')" ]] \ || fail "reset-password accepted \"$bad\" as a user name" done printf 'user accounts contract: PASS (%d account(s), credentials never on a command line, keep-files honored end to end)\n' \ "$(jq '.users | length' <<<"$snapshot")"