#!/usr/bin/env bash # Online accounts -- the other kind (see user-accounts for the local ones). # # Three things can go wrong here, and two of them are silent. # # The loud one is a leaked password. Adding a Nextcloud or an IMAP account is # the first time Panama collects a credential for somebody else's server, and # GOA's AddAccount takes it as a D-Bus argument -- which is fine, that is a # direct call to a daemon. What is not fine is the same string reaching this # helper's own argv, where /proc publishes it to every process on the machine, # or the account listing, which the page renders. # # The first silent one is the page telling somebody they have no online # accounts. `available` used to be `lastError === ""`, and `lastError` is set by # every failed write -- so refusing to remove one account replaced the whole # card with "Online accounts are not available on this machine", listing # nothing, while four accounts sat there working. Availability is whether GOA # answered the listing. An error is a row. # # The second is a removal that happens on the first click. GOA's Remove is # immediate and unrecoverable: the account is gone, and re-adding it means the # whole sign-in again. It must take two. # # SAFETY. This is the account store of a signed-in desktop session, so it must # be impossible for anything here to reach the real GOA: # # 1. the helper is run under `env -i` with a stub directory first on PATH, # and with both D-Bus bus addresses pointed at sockets that do not exist, # so a client that got as far as connecting could not; # 2. `import gi` resolves to a stand-in on PYTHONPATH whose require_version # always raises, which is the same seam network-tools-contract uses; # 3. the account data comes from PANAMA_ACCOUNTS_FIXTURE, a file in the # scratch tree, so nothing is read from or written to the session; # 4. every D-Bus client binary is stubbed with one that records and refuses. # # The write verbs are exercised only against that fixture. Nothing here signs # in to anything, and nothing here removes a real account. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-accounts" service="$repo_dir/config/dot/quickshell/services/OnlineAccounts.qml" settings_dir="$repo_dir/config/dot/quickshell/modules/settings" page="$settings_dir/OnlineAccountsPage.qml" fail() { printf 'online 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-accounts is not executable' file_calling() { grep -rl --include='*.qml' -F "$1" "$settings_dir" | head -1 } # Sentinels. Neither may come back out of anything. readonly NEXTCLOUD_PW='nextcloud-pw-must-never-leave-6d3a' readonly IMAP_PW='imap-pw-must-never-leave-91be' # ── Static: the helper cannot walk past the stubs ──────────────────────────── # # Checked before anything runs, because the safety claim above rests on it. absolute="$(grep -nE '"/(usr/)?s?bin/[a-z-]+"' "$helper")" [[ -z "$absolute" ]] \ || fail "the helper names a binary by absolute path, so PATH stubs cannot contain it: $absolute" # ── Static: a password is read, never taken as an argument ─────────────────── # # The distinction that matters: handing the password to GOA over D-Bus is the # supported way to add a password-based account. Handing it to THIS program as # argv[4] would publish it through /proc for as long as the command runs. grep -q 'sys.stdin' "$helper" \ || fail 'the helper never reads stdin, so the password has no way in but argv' python3 - "$helper" <<'PY' || fail 'the helper takes a password from its own command line' import ast import re import sys SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I) tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) bad = [] for node in ast.walk(tree): # password = sys.argv[n], or any assignment of an argv slice to one. if isinstance(node, ast.Assign): names = [t.id for t in node.targets if isinstance(t, ast.Name)] if any(SECRETISH.search(name) for name in names): if "argv" in ast.dump(node.value): bad.append(f"line {node.lineno}: {names} <- argv") # And no command list may carry one either. if isinstance(node, (ast.List, ast.Tuple)): literals = {e.value for e in node.elts if isinstance(e, ast.Constant) and isinstance(e.value, str)} if not literals & {"gdbus", "busctl", "dbus-send", "gnome-control-center", "sh", "bash"}: continue for element in node.elts: name = getattr(element, "id", getattr(element, "attr", "")) if name and SECRETISH.search(name): bad.append(f"line {node.lineno}: {name} in a command list") if bad: print("; ".join(bad), file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── Static: an added account is proved, not assumed ────────────────────────── # # GOA's AddAccount does not check what it is handed: it writes the account out # and stores the password. A mistyped one therefore produces an account that # exists, looks correct in the list, and never syncs -- which is the exact # failure this page was built to be able to explain, so it must not be the # failure the page creates. The credentials are exercised once, and an account # that cannot sign in is taken back out. python3 - "$helper" <<'PY' || fail 'a new account is never asked to prove its credentials' import ast import sys tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) source = ast.dump(tree) if "call_add_account_sync" not in source: print("nothing adds an account through GOA at all", file=sys.stderr) raise SystemExit(1) if "call_ensure_credentials_sync" not in source: print("an added account is never signed in with, so a mistyped password " "produces an account that exists and never syncs", file=sys.stderr) raise SystemExit(1) # And the undo. The function that verifies has to be able to remove. verify = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "verify"), None) if verify is None or "call_remove_sync" not in ast.dump(verify): print("an account whose credentials were refused is left behind", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # GOA's provider type for Nextcloud is "owncloud" -- the fork kept the id. A # provider type GOA does not know is not an error: AddAccount is simply never # offered it, and the form reports a failure nobody can act on. grep -qE '"owncloud"' "$helper" \ || fail 'Nextcloud is added under a provider type GOA does not have (it is "owncloud")' grep -qE '"imap_smtp"' "$helper" \ || fail 'mail is added under a provider type GOA does not have (it is "imap_smtp")' # ── Static: the service hands it down the same way ─────────────────────────── grep -qE 'stdinEnabled' "$service" \ || fail 'the service never opens a helper stdin, so the password would have to be an argument' python3 - "$service" <<'PY' || fail 'the service puts an account password on the command line' import re import sys text = open(sys.argv[1], encoding="utf-8").read() found = False for match in re.finditer(r"function (addNextcloud|addImap)\(([^)]*)\)", text): found = True body = text[match.end():text.find("\n }", match.end())] for argv in re.findall(r"\[[^\[\]]*\]", body): if re.search(r"(password|secret|token)", argv, re.I): print(f"{match.group(1)}: {argv.strip()[:200]}", file=sys.stderr) raise SystemExit(1) if not found: print("the service cannot add a password-based account at all", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── Static: availability is not an error ───────────────────────────────────── # # The regression, pinned at the definition. `available` may be computed from # whether the listing was answered; it may not be computed from lastError, # which every refused write sets. python3 - "$service" <<'PY' || fail 'the page-wide availability is still derived from a write error' import re import sys text = open(sys.argv[1], encoding="utf-8").read() if re.search(r"property bool available:", text) is None: print("the service no longer says whether online accounts are available", file=sys.stderr) raise SystemExit(1) # Every place `available` is decided: the declaration, and every assignment. # A write error may not reach any of them. This is checked at each one rather # than at the declaration alone, because the regression came back the second # time as an assignment in the error handler of a failed toggle. WRITE_ERRORS = re.compile(r"(lastError|writeError|setError|removeError)") sites = [line for line in text.splitlines() if re.search(r"property bool available:|\bavailable\s*=[^=]", line)] if not sites: print("nothing sets availability at all", file=sys.stderr) raise SystemExit(1) for line in sites: if WRITE_ERRORS.search(line): print(line.strip(), file=sys.stderr) raise SystemExit(1) # And the two must be separable at all: one string for "GOA did not answer", # another for "that one change did not happen". if len(set(re.findall(r"property string (\w*[Ee]rror)", text))) < 2: print("the service keeps one error string, so a failed write is indistinguishable " "from a daemon that is not there", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── Static: the page ───────────────────────────────────────────────────────── # # An error is a row on a working page, not a replacement for the page. python3 - "$page" <<'PY' || fail 'the page still hides everything behind an error' import re import sys text = open(sys.argv[1], encoding="utf-8").read() for line in text.splitlines(): if re.search(r"visible:.*OnlineAccounts\.lastError\s*===\s*\"\"", line): print(line.strip(), file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY remove_page="$(file_calling 'OnlineAccounts.remove(')" [[ -n "$remove_page" ]] || fail 'nothing removes an online account' python3 - "$remove_page" <<'PY' || fail 'removing an account is not gated on a confirmation' import re import sys lines = open(sys.argv[1], encoding="utf-8").read().splitlines() calls = [index for index, line in enumerate(lines) if "OnlineAccounts.remove(" in line] if not calls: raise SystemExit(1) # The call has to sit behind a state the first click sets: a second button that # only exists once "confirming" is true. Read from the same object block, so a # `confirming` property declared elsewhere in the file does not count. for index in calls: window = "\n".join(lines[max(0, index - 25):index + 3]) if not re.search(r"confirm", window, re.I): print(f"line {index + 1}: {lines[index].strip()}", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # The account is named in the confirmation, and what is lost is said out loud: # GOA's Remove cannot be undone and the sign-in has to be done again. grep -qE 'Sign in again' "$page" \ || fail 'an account whose credentials expired is not offered a way back in' # The password field must not survive the form that collected it. add_page="$(file_calling 'addNextcloud(')" [[ -n "$add_page" ]] || fail 'nothing adds a Nextcloud account natively' grep -qE '\.clear\(\)|password = ""|secret = ""' "$add_page" \ || fail 'the add form never clears the password it collected' # ── Static: the listing itself cannot carry a credential ───────────────────── # # The listing is built from a fixed set of GOA properties, and the page renders # every one of them. A password property added to that dict later is the way a # secret would arrive on screen, so the keys are read rather than trusted. python3 - "$helper" <<'PY' || fail 'the account listing exposes a credential-shaped field' import ast import re import sys SECRETISH = re.compile(r"(password|passwd|secret|token|credential)", re.I) tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) describe = next((node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "describe"), None) if describe is None: print("nothing builds the account listing", file=sys.stderr) raise SystemExit(1) keys = [] for node in ast.walk(describe): if isinstance(node, ast.Dict): keys += [key.value for key in node.keys if isinstance(key, ast.Constant) and isinstance(key.value, str)] if not keys: print("the listing has no fields at all", file=sys.stderr) raise SystemExit(1) offenders = [key for key in keys if SECRETISH.search(key)] if offenders: print(f"the listing carries {offenders}", file=sys.stderr) raise SystemExit(1) # And the GOA properties it reads: `password` is a real property on some # provider objects, and reading it here would put it in the dict above under # whatever name somebody chose. reads = [node.attr for node in ast.walk(describe) if isinstance(node, ast.Attribute)] offenders = [name for name in reads if SECRETISH.search(name)] if offenders: print(f"the listing reads {offenders} off the GOA account", file=sys.stderr) raise SystemExit(1) raise SystemExit(0) PY # ── The fake GOA ───────────────────────────────────────────────────────────── command -v jq >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no jq)\n'; exit 0; } command -v python3 >/dev/null 2>&1 || { printf 'online accounts contract: SKIP (no python3)\n'; exit 0; } grep -q 'PANAMA_ACCOUNTS_FIXTURE' "$helper" \ || fail 'the helper has no fixture seam, so nothing can exercise it without the real account store' work="$(mktemp -d /tmp/panama-accounts-contract.XXXXXX)" stub_dir="$work/bin" home_dir="$work/home" config_home="$work/config" state_home="$work/xdg-state" run_dir="$work/run" pystub="$work/pystub" mkdir -p "$stub_dir" "$home_dir" "$config_home" "$state_home" "$run_dir" "$pystub/gi" trap 'rm -rf "$work"' EXIT # The stand-in for PyGObject. With it in place the helper cannot construct a # Goa.Client at all, so if the fixture seam were ever removed this contract # would stop working rather than quietly start editing the session's accounts. cat >"$pystub/gi/__init__.py" <<'GISTUB' """Stand-in for PyGObject, so panama-accounts cannot reach the real GOA.""" def require_version(namespace, version): raise ValueError(f"Namespace {namespace} not available") GISTUB # Two accounts, one of them needing attention. The fixture is the listing's own # shape, so it holds no password -- that half is pinned above, from the source. fixture="$work/accounts.json" log="$work/accounts.log" cat >"$fixture" <<'FIXTURE' { "accounts": [ { "path": "/org/gnome/OnlineAccounts/Accounts/account_0", "provider": "owncloud", "providerName": "Nextcloud", "providerIcons": ["goa-account-owncloud"], "identity": "gib@cloud.example.org", "needsAttention": false, "services": [ { "key": "files", "label": "Files", "enabled": true }, { "key": "calendar", "label": "Calendar", "enabled": false } ] }, { "path": "/org/gnome/OnlineAccounts/Accounts/account_1", "provider": "imap_smtp", "providerName": "Mail", "providerIcons": ["goa-account-mail"], "identity": "gib@example.com", "needsAttention": true, "services": [ { "key": "mail", "label": "Mail", "enabled": true } ] } ], "error": "" } FIXTURE # Anything that could still be a way out is closed rather than left open. for blocked in gdbus busctl dbus-send gnome-control-center goa-daemon pkexec; do cat >"$stub_dir/$blocked" <&2 exit 1 STUB done chmod +x "$stub_dir"/* runh() { env -i \ PATH="$stub_dir:/usr/bin:/bin" \ PYTHONPATH="$pystub" \ HOME="$home_dir" \ XDG_CONFIG_HOME="$config_home" \ XDG_STATE_HOME="$state_home" \ XDG_RUNTIME_DIR="$run_dir" \ DBUS_SESSION_BUS_ADDRESS="unix:path=$run_dir/absent-session-bus" \ DBUS_SYSTEM_BUS_ADDRESS="unix:path=$run_dir/absent-system-bus" \ PANAMA_ACCOUNTS_FIXTURE="$fixture" \ PANAMA_ACCOUNTS_LOG="$log" \ LANG=C LC_ALL=C \ "$helper" "$@" } # The safety claim, verified rather than assumed. for binary in gdbus busctl dbus-send gnome-control-center; do resolved="$(env -i PATH="$stub_dir:/usr/bin:/bin" bash -c "command -v $binary" || true)" [[ "$resolved" == "$stub_dir/$binary" ]] \ || fail "$binary resolves to '$resolved', not the stub; refusing to run" done leak_in_scratch() { grep -rlF "$1" "$home_dir" "$config_home" "$state_home" "$run_dir" "$log" \ 2>/dev/null | head -1 } recorded() { cat "$log" 2>/dev/null; } error_of() { runh "$@" 2>/dev/null | jq -r '.error // ""'; } # ── The listing answers, under both of its names ───────────────────────────── # # The service asks for `snapshot`, which is what every other Panama helper # calls this; `list` is what this one was called first and what anything older # still passes. Both have to work, or one of them is a page with no accounts. : >"$log" accounts="$(runh snapshot 2>/dev/null)" || fail 'snapshot failed against the fixture' [[ "$(runh list 2>/dev/null)" == "$accounts" ]] \ || fail 'list and snapshot do not answer the same thing' jq -e '.accounts | type == "array" and length == 2' <<<"$accounts" >/dev/null \ || fail "the fixture accounts did not come back: $accounts" jq -e '[.accounts[] | has("path") and has("provider") and has("identity") and has("needsAttention") and (.services | type == "array")] | all' \ <<<"$accounts" >/dev/null || fail "an account is missing part of its shape: $accounts" jq -e '[.accounts[] | select(.needsAttention)] | length == 1' <<<"$accounts" >/dev/null \ || fail 'the account whose credentials expired is not reported as needing attention' jq -e '.error == ""' <<<"$accounts" >/dev/null \ || fail "reading the accounts reported an error: $accounts" [[ -z "$(recorded)" ]] || fail 'reading the account list changed something' # ── Adding: the password arrives on stdin and stays nowhere ────────────────── # # The sentinel is typed in, and then looked for everywhere it could have gone: # back out of the helper, into the log, into the scratch home. The log records # its LENGTH, which is what proves it was read at all rather than dropped. : >"$log" added="$(printf '%s\n' "$NEXTCLOUD_PW" \ | runh add-nextcloud 'https://cloud.example.org' 'gib' 2>"$work/nextcloud.err")" jq -e '.error == ""' <<<"$added" >/dev/null \ || fail "a well-formed Nextcloud account was refused: $added" jq -e '.accounts | length == 2' <<<"$added" >/dev/null \ || fail "add-nextcloud does not answer with the account list: $added" jq -e --argjson want "${#NEXTCLOUD_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \ || fail "the password never reached the helper on stdin: $(recorded)" grep -Fq "$NEXTCLOUD_PW" <<<"$added" \ && fail 'add-nextcloud echoes the password back in its own output' grep -Fq "$NEXTCLOUD_PW" "$work/nextcloud.err" \ && fail 'add-nextcloud wrote the password to stderr' leaked="$(leak_in_scratch "$NEXTCLOUD_PW")" [[ -z "$leaked" ]] || fail "the Nextcloud password was written to $leaked" : >"$log" mail_added="$(printf '%s\n' "$IMAP_PW" \ | runh add-imap 'gib@example.com' 'imap.example.com' 'smtp.example.com' 'gib' \ 2>"$work/imap.err")" jq -e '.error == ""' <<<"$mail_added" >/dev/null \ || fail "a well-formed mail account was refused: $mail_added" jq -e --argjson want "${#IMAP_PW}" '.passwordBytes == $want' <<<"$(recorded)" >/dev/null \ || fail "the mail password never reached the helper on stdin: $(recorded)" grep -Fq "$IMAP_PW" <<<"$mail_added" && fail 'add-imap echoes the password back' grep -Fq "$IMAP_PW" "$work/imap.err" && fail 'add-imap wrote the password to stderr' leaked="$(leak_in_scratch "$IMAP_PW")" [[ -z "$leaked" ]] || fail "the mail password was written to $leaked" # ── An error is a row, not an empty page ───────────────────────────────────── # # The regression, from the data. Whatever went wrong, the accounts that are # there must still come back with it -- the page draws its list from the same # answer that carries the message. : >"$log" refused="$(printf 'anything\n' | runh add-nextcloud '' 'gib' 2>/dev/null)" jq -e '(.error | length) > 0' <<<"$refused" >/dev/null \ || fail "adding an account with no server was accepted: $refused" jq -e '.accounts | length == 2' <<<"$refused" >/dev/null \ || fail "a refused add emptied the account list, which is how one error hides four working accounts: $refused" # ── Adding validates what it was given ─────────────────────────────────────── # # Each of these produces an account that exists, looks right, and never syncs # -- the failure this page is meant to be able to explain. They must be refused # here, with nothing recorded, rather than handed to GOA to fail slowly. # # A password is piped in on purpose: without one the refusal would be "no # password was provided", which every case would pass on regardless of whether # the field it is about is checked at all. attempt() { printf 'unused-password\n' | runh "$@" 2>/dev/null | jq -r '.error // ""'; } for bad_server in '' 'not a url' 'ftp://cloud.example.org' 'https://' \ 'https://cloud.example.org; reboot' 'https://cloud example org'; do : >"$log" [[ -n "$(attempt add-nextcloud "$bad_server" 'gib')" ]] \ || fail "add-nextcloud accepted ${bad_server@Q} as a server" [[ -z "$(recorded)" ]] \ || fail "a refused Nextcloud server was acted on anyway: ${bad_server@Q}" done [[ -n "$(attempt add-nextcloud 'https://cloud.example.org' '')" ]] \ || fail 'add-nextcloud accepted an empty user name' [[ -n "$(error_of add-nextcloud 'https://cloud.example.org' 'gib' "$log" [[ -n "$(attempt add-imap 'gib@example.com' "$bad_host" 'smtp.example.com' 'gib')" ]] \ || fail "add-imap accepted ${bad_host@Q} as an incoming server" [[ -n "$(attempt add-imap 'gib@example.com' 'imap.example.com' "$bad_host" 'gib')" ]] \ || fail "add-imap accepted ${bad_host@Q} as an outgoing server" [[ -z "$(recorded)" ]] \ || fail "a refused mail server was acted on anyway: ${bad_host@Q}" done [[ -n "$(error_of bogus-verb)" ]] || fail 'an unknown command was accepted' # ── Removing names the account it was asked about ──────────────────────────── # # Confirmed on the page (pinned above); here, only that the path travels intact # -- removing the wrong account is unrecoverable and looks like a success. : >"$log" runh remove '/org/gnome/OnlineAccounts/Accounts/account_1' >/dev/null 2>&1 jq -e '.arguments[0] == "/org/gnome/OnlineAccounts/Accounts/account_1"' \ <<<"$(recorded)" >/dev/null || fail "remove did not carry the account path: $(recorded)" : >"$log" runh set '/org/gnome/OnlineAccounts/Accounts/account_0' calendar true >/dev/null 2>&1 jq -e '.arguments == ["/org/gnome/OnlineAccounts/Accounts/account_0", "calendar", "true"]' \ <<<"$(recorded)" >/dev/null || fail "a service toggle did not carry its arguments: $(recorded)" # ── Nothing anywhere left a secret behind ──────────────────────────────────── for secret in "$NEXTCLOUD_PW" "$IMAP_PW"; do leaked="$(leak_in_scratch "$secret")" [[ -z "$leaked" ]] || fail "a password was left behind in $leaked" done printf 'online accounts contract: PASS (listing, availability, add, remove, no secret leaves)\n'