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
+192
View File
@@ -0,0 +1,192 @@
pragma Singleton
// Panama's authentication prompt -- the half that handles the password.
//
// The split with scripts/panama-polkit-agent is the security design, not an
// accident of implementation:
//
// * The agent talks to polkitd and hands this a request FILE containing the
// action, the message, who may answer, and a one-time cookie. No password
// ever reaches it.
// * This draws the prompt, and on submit spawns the setuid
// polkit-agent-helper-1 and writes the password to that helper's stdin. The
// helper performs the PAM conversation and reports to polkitd itself.
//
// So the password lives in this process and the helper's stdin, and nowhere
// else. It is never an argument -- argv is world-readable through /proc -- and
// it is cleared the moment it has been handed over.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperBinary: "/usr/lib/polkit-1/polkit-agent-helper-1"
// The request currently on screen, or null.
property var request: null
property string requestPath: ""
property string message: ""
property string actionId: ""
property var users: []
property string chosenUser: ""
property bool authenticating: false
property string failureText: ""
// How many times a wrong password has been offered for this request.
property int attempts: 0
readonly property bool active: root.request !== null
// Held only between pressing Enter and the helper accepting it on stdin.
property string pendingSecret: ""
function begin(path: string): void {
// A second request while one is open would leave the first
// unanswerable; polkit serializes these in practice, and refusing is
// safer than stacking prompts.
if (root.active) {
console.warn("Polkit: a prompt is already open; ignoring", path);
return;
}
root.requestPath = path;
requestFile.path = path;
requestFile.reload();
}
function adopt(text: string): void {
try {
const parsed = JSON.parse(text);
root.request = parsed;
root.actionId = String(parsed.actionId ?? "");
root.message = String(parsed.message ?? "Authentication is required");
root.users = Array.isArray(parsed.users) ? parsed.users : [];
root.chosenUser = root.users.includes(String(parsed.preferred ?? ""))
? String(parsed.preferred)
: (root.users.length > 0 ? String(root.users[0]) : "");
root.attempts = 0;
root.failureText = "";
} catch (error) {
console.warn("Polkit: could not read the request:", error);
root.dismiss("failed");
}
}
function submit(password: string): void {
if (!root.active || root.authenticating || root.chosenUser === "")
return;
root.authenticating = true;
root.failureText = "";
root.pendingSecret = password;
helper.command = [root.helperBinary, root.chosenUser];
helper.running = true;
}
function cancel(): void {
root.dismiss("cancelled");
}
// Writes the outcome where the agent is watching for it, then forgets
// everything about the request.
function dismiss(result: string): void {
if (root.requestPath !== "") {
answer.command = ["sh", "-c",
"printf '%s' " + JSON.stringify(JSON.stringify({ result: result }))
+ " > " + JSON.stringify(root.responsePathFor(root.requestPath))];
answer.running = true;
}
root.request = null;
root.requestPath = "";
root.message = "";
root.actionId = "";
root.users = [];
root.chosenUser = "";
root.attempts = 0;
root.failureText = "";
root.pendingSecret = "";
root.authenticating = false;
}
function responsePathFor(path: string): string {
return String(path).replace(/\.json$/, "") + ".response";
}
FileView {
id: requestFile
onLoaded: root.adopt(this.text())
onLoadFailed: {
console.warn("Polkit: the request file could not be read");
root.dismiss("failed");
}
}
// Watches for polkit withdrawing the request while the prompt is open --
// the caller gave up, or another agent answered it.
Timer {
running: root.active
interval: 500
repeat: true
onTriggered: cancelledCheck.running = true
}
Process {
id: cancelledCheck
command: ["test", "-e", root.requestPath.replace(/\.json$/, "") + ".cancelled"]
onExited: (code) => { if (code === 0 && root.active) root.dismiss("cancelled"); }
}
Process {
id: helper
stdinEnabled: true
onStarted: {
// The cookie first, then the password when the helper asks for it.
// Both go to stdin; neither is ever an argument.
helper.write(String(root.request?.cookie ?? "") + "\n");
}
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
const text = String(line);
if (text.startsWith("PAM_PROMPT_ECHO_OFF")
|| text.startsWith("PAM_PROMPT_ECHO_ON")) {
helper.write(root.pendingSecret + "\n");
// Gone from this process the instant it has been handed
// over; the helper owns it from here.
root.pendingSecret = "";
return;
}
if (text.startsWith("PAM_ERROR_MSG")) {
const detail = text.slice("PAM_ERROR_MSG".length).trim();
if (detail !== "")
root.failureText = detail;
return;
}
if (text.startsWith("SUCCESS")) {
root.dismiss("ok");
return;
}
if (text.startsWith("FAILURE")) {
root.attempts += 1;
if (root.failureText === "")
root.failureText = "That password was not accepted.";
// Three tries, then the request is failed rather than left
// open forever -- polkit's own agents behave the same way.
if (root.attempts >= 3)
root.dismiss("failed");
}
}
}
onExited: {
root.authenticating = false;
root.pendingSecret = "";
helper.stdinEnabled = true;
}
}
Process { id: answer }
}