#!/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. 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" page="$repo_dir/config/dot/quickshell/modules/settings/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' # ── 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' # ── Refusals that keep a machine administrable ────────────────────────────── delete_body="$(sed -n '/^def delete_user/,/^def /p' "$helper")" 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' # The page must not offer to change the type of the only administrator either. grep -q 'administratorCount <= 1' "$page" \ || fail 'the page offers to demote the only administrator' # ── Deleting is confirmed, and says what it destroys ──────────────────────── grep -q 'confirmingRemoval' "$page" \ || fail 'the page deletes an account without a confirmation step' grep -q 'This cannot be undone' "$page" \ || fail 'the page does not say that deleting an account destroys their files' # ── 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' 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' # ── 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 printf 'user accounts contract: PASS (%d account(s), credentials never on a command line)\n' \ "$(jq '.users | length' <<<"$snapshot")"