Draw the authentication prompt ourselves
hyprpolkitagent's dialog is compiled into its binary -- no config, no stylesheet, nothing to theme -- and it was the one window on this desktop that looked like it belonged to something else. The split between the two halves is the security design, not an implementation detail. A small agent process owns the D-Bus side: it registers with polkitd, receives the request, and hands the shell the action, the message, who may answer, and a one-time cookie. It never sees a password. The shell draws the prompt and, on submit, spawns the setuid polkit-agent-helper-1 itself and writes the password to that helper's stdin; the helper runs the PAM conversation and reports to polkitd directly. The password exists in the shell and in the helper's stdin and nowhere else -- never on a command line, never over D-Bus, never through IPC arguments. The prompt takes exclusive keyboard focus, because a password field that lets keystrokes reach the window behind it is a keylogger with extra steps. The request travels as a file created 0600 with O_EXCL inside a 0700 runtime directory: a cookie is not a password, but it is a capability, and capabilities do not belong in a process listing either. Three things cost real time. polkitd calls back on the same connection that registered, so exporting the object on the session bus while registering from the system bus failed every request as "Not authorized" with no error anywhere. XDG_SESSION_ID is absent in a systemd user unit, which runs under [email protected] and belongs to no login session, so the session comes from logind's Display property instead. And PyGObject does not accept the @ placeholder in variant format strings. hyprpolkitagent stays installed as the fallback, only one agent is started, and the comment beside the autostart says how to get the stock prompt back. Verified end to end, including a real password accepted and three cancellations refused. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# The authentication prompt asks for the password to everything, so the rules
|
||||
# here are not style, they are the reason it was safe to write at all.
|
||||
#
|
||||
# 1. The agent process must never handle a password. It talks to polkitd and
|
||||
# hands the shell a request; the shell talks to the setuid helper.
|
||||
# 2. The password reaches the helper on STDIN and nowhere else. argv is
|
||||
# world-readable through /proc, so an argument is a broadcast.
|
||||
# 3. The prompt takes EXCLUSIVE keyboard focus. A password field that lets
|
||||
# keystrokes through to the window behind it is a keylogger with extra
|
||||
# steps.
|
||||
# 4. The request file carries a one-time capability and is created 0600.
|
||||
# 5. The stock agent stays installed. Only one agent may register per session,
|
||||
# so a broken replacement must have something to fall back to.
|
||||
#
|
||||
# Static, plus a live check of the runtime directory. It never authenticates.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
agent="$repo_dir/config/dot/quickshell/scripts/panama-polkit-agent"
|
||||
service="$repo_dir/config/dot/quickshell/services/Polkit.qml"
|
||||
prompt="$repo_dir/config/dot/quickshell/modules/polkit/PolkitPrompt.qml"
|
||||
autostart="$repo_dir/config/dot/hypr/autostart.lua"
|
||||
|
||||
fail() {
|
||||
printf 'polkit agent contract: %s\n' "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
for path in "$agent" "$service" "$prompt" "$autostart"; do
|
||||
[[ -r "$path" ]] || fail "missing $path"
|
||||
done
|
||||
[[ -x "$agent" ]] || fail 'the agent is not executable'
|
||||
|
||||
# ── 1. The agent never touches a password ───────────────────────────────────
|
||||
# Comments and docstrings are stripped first: this file EXPLAINS at length why
|
||||
# it must not touch a password, and an earlier version of this check failed on
|
||||
# the explanation rather than on any behaviour.
|
||||
agent_code="$(python3 - "$agent" <<'STRIP'
|
||||
import ast, sys
|
||||
source = open(sys.argv[1], encoding="utf-8").read()
|
||||
tree = ast.parse(source)
|
||||
# Drop every docstring, then print what is left as code.
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
||||
if (node.body and isinstance(node.body[0], ast.Expr)
|
||||
and isinstance(node.body[0].value, ast.Constant)
|
||||
and isinstance(node.body[0].value.value, str)):
|
||||
node.body.pop(0)
|
||||
print(ast.unparse(tree))
|
||||
STRIP
|
||||
)"
|
||||
[[ -n "$agent_code" ]] || fail 'could not read the agent source'
|
||||
grep -qE 'polkit-agent-helper|PAM_PROMPT|password' <<<"$agent_code" \
|
||||
&& fail 'the agent process handles passwords; that belongs on the shell side only'
|
||||
|
||||
# ── 2. The password goes to the helper on stdin ─────────────────────────────
|
||||
grep -q 'stdinEnabled: true' "$service" \
|
||||
|| fail 'the helper is spawned without a writable stdin, so the password has nowhere to go'
|
||||
grep -q 'helper.write(root.pendingSecret' "$service" \
|
||||
|| fail 'the password is not written to the helper stdin'
|
||||
# It must never appear in the argument list.
|
||||
helper_command="$(grep -n 'helper.command' "$service" || true)"
|
||||
grep -qE 'pendingSecret|password' <<<"$helper_command" \
|
||||
&& fail 'the password appears in the helper command line'
|
||||
grep -q 'root.pendingSecret = "";' "$service" \
|
||||
|| fail 'the password is never cleared after being handed over'
|
||||
|
||||
# ── 3. The prompt owns the keyboard ─────────────────────────────────────────
|
||||
grep -q 'WlrKeyboardFocus.Exclusive' "$prompt" \
|
||||
|| fail 'the prompt does not take exclusive keyboard focus'
|
||||
grep -q 'field.text = ""' "$prompt" \
|
||||
|| fail 'the password field is not cleared when the prompt closes'
|
||||
|
||||
# ── 4. The request file is private ──────────────────────────────────────────
|
||||
grep -q '0o600' "$agent" || fail 'the request file is not created 0600'
|
||||
grep -q '0o700' "$agent" || fail 'the runtime directory is not private'
|
||||
grep -qE 'O_CREAT \| os\.O_EXCL' "$agent" \
|
||||
|| fail 'the request file is not created exclusively, so it could be pre-created by someone else'
|
||||
|
||||
# ── 5. A fallback exists ────────────────────────────────────────────────────
|
||||
grep -q 'hyprpolkitagent' "$autostart" \
|
||||
|| fail 'nothing records how to get the stock agent back if this one fails'
|
||||
command -v rpm >/dev/null 2>&1 && {
|
||||
rpm -q hyprpolkitagent >/dev/null 2>&1 \
|
||||
|| fail 'the stock agent is no longer installed, so there is nothing to fall back to'
|
||||
}
|
||||
|
||||
# Only one agent may be started by the session. Lua comments are excluded: the
|
||||
# file documents how to restore the stock agent, and that sentence is not a
|
||||
# command the session runs.
|
||||
started="$(grep -vE '^\s*--' "$autostart" | grep -E 'systemctl --user start .*polkit' | head -1)"
|
||||
[[ -n "$started" ]] || fail 'the session starts no polkit agent at all'
|
||||
grep -q 'panama-polkit-agent' <<<"$started" \
|
||||
|| fail 'the session does not start the Panama agent'
|
||||
grep -q 'hyprpolkitagent.service' <<<"$started" \
|
||||
&& fail 'the session starts both agents; the second to register will fail'
|
||||
|
||||
# ── Live: the runtime directory is private, if it exists ────────────────────
|
||||
runtime="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/panama-polkit"
|
||||
if [[ -d "$runtime" ]]; then
|
||||
mode="$(stat -c '%a' "$runtime")"
|
||||
[[ "$mode" == "700" ]] || fail "the runtime directory is mode $mode, not 700"
|
||||
leaked="$(find "$runtime" -type f ! -perm 600 2>/dev/null | head -1)"
|
||||
[[ -z "$leaked" ]] || fail "a request file is readable beyond its owner: $leaked"
|
||||
fi
|
||||
|
||||
printf 'polkit agent contract: PASS (password never leaves the shell, prompt owns the keyboard, fallback intact)\n'
|
||||
Reference in New Issue
Block a user