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:
Gabriel Brown
2026-08-19 18:08:52 -04:00
parent b10f8e2593
commit 116510caa8
9 changed files with 867 additions and 1 deletions
@@ -236,3 +236,47 @@ and it is the page that makes this feel like *your* operating system.
Batch 3 and 4 in either order. Firewall is the most valuable of the remainder
and also the one that most deserves care.
---
## The authentication prompt
Not on the original list, and worth recording because of how it was built.
hyprpolkitagent's dialog is compiled into its binary -- no config, no
stylesheet, nothing to theme -- and it was the one window on the desktop that
looked like it belonged to something else. Panama now provides the agent.
**The split is the security design.** A Python 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 performs the PAM conversation and reports to polkitd directly. So the
password exists in the shell process and the helper's stdin, and nowhere else --
never on a command line, never over D-Bus, never through IPC arguments.
The request travels as a file created 0600 with O_EXCL in 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 and are worth writing down.**
polkitd calls `BeginAuthentication` back on the same connection that called
`RegisterAuthenticationAgent`. Exporting the object on the session bus while
registering from the system bus produced no error at all -- every request just
failed as "Not authorized" without ever prompting.
`XDG_SESSION_ID` is not in the systemd user environment, because a user unit
runs under `[email protected]`, which belongs to no login session. `GetSessionByPID`
fails for the same reason. The user object's `Display` property is the answer.
PyGObject does not accept the `@` placeholder in variant format strings; a tuple
has to be assembled from already-built variants.
**The fallback is deliberate.** hyprpolkitagent stays installed, and only one
agent may register per session, so the autostart starts exactly one and the
comment beside it says how to get the stock prompt back. A session with no
working agent can still authenticate from a terminal, which is the escape hatch
that made this safe to attempt at all.