diff --git a/config/dot/hypr/autostart.lua b/config/dot/hypr/autostart.lua index c2a03a5..ace3516 100644 --- a/config/dot/hypr/autostart.lua +++ b/config/dot/hypr/autostart.lua @@ -32,7 +32,12 @@ hl.on("hyprland.start", function() -- so that failure mode is "lock-session goes to nobody". Re-import -- synchronously in the same shell invocation first so the Condition -- always sees it, regardless of how the dbus-update call above scheduled. - hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start hyprpolkitagent.service hyprpaper.service vicinae.service hypridle.service") + -- panama-polkit-agent replaces hyprpolkitagent, whose prompt is compiled + -- into its binary and cannot be themed. Only one agent may register per + -- session, so they must not both start. hyprpolkitagent stays INSTALLED as + -- the fallback: `systemctl --user start hyprpolkitagent` restores the stock + -- prompt if Panama's ever fails to come up. + hl.exec_cmd("systemctl --user import-environment WAYLAND_DISPLAY XDG_CURRENT_DESKTOP && systemctl --user start panama-polkit-agent.service hyprpaper.service vicinae.service hypridle.service") -- The shell: bar, dock, overview, quick settings, notifications, capture. -- No systemd unit ships with quickshell, so it runs as a compositor child. diff --git a/config/dot/quickshell/modules/polkit/PolkitPrompt.qml b/config/dot/quickshell/modules/polkit/PolkitPrompt.qml new file mode 100644 index 0000000..f6317ec --- /dev/null +++ b/config/dot/quickshell/modules/polkit/PolkitPrompt.qml @@ -0,0 +1,157 @@ +// The authentication prompt. +// +// Deliberately a Panama surface rather than the compositor's stock agent: this +// is the window that asks for the password to everything, and it was the one +// window on the desktop that looked like it belonged to something else. +// +// Two things here are security, not styling. It takes EXCLUSIVE keyboard focus, +// so keystrokes cannot reach the window underneath while a password is being +// typed. And the field is cleared on every exit path, including the ones nobody +// plans for. + +import Quickshell +import Quickshell.Wayland +import QtQuick +import qs.config +import qs.services +import qs.modules.settings + +PanelWindow { + id: root + + visible: Polkit.active + color: "transparent" + + anchors { top: true; bottom: true; left: true; right: true } + exclusiveZone: 0 + + WlrLayershell.namespace: "qs-polkit" + WlrLayershell.layer: WlrLayer.Overlay + // Exclusive, not OnDemand: a password prompt that lets keystrokes through + // to whatever is behind it is a keylogger with extra steps. + WlrLayershell.keyboardFocus: Polkit.active + ? WlrKeyboardFocus.Exclusive + : WlrKeyboardFocus.None + + onVisibleChanged: { + if (root.visible) { + field.text = ""; + field.forceActiveFocus(); + } else { + field.text = ""; + } + } + + // Dims what is behind, and swallows clicks so nothing outside the dialog + // can be operated while it is waiting. + Rectangle { + anchors.fill: parent + color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha) + + MouseArea { + anchors.fill: parent + // Clicking outside does nothing on purpose. Dismissing an + // authentication request by misclick, and having the thing that + // asked report a mysterious failure, is worse than an explicit + // Cancel. + hoverEnabled: true + } + } + + Rectangle { + id: dialog + + anchors.centerIn: parent + width: 420 + implicitHeight: layout.implicitHeight + 44 + radius: Theme.popoverRadius + color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) + border.width: 1 + border.color: Theme.alpha(Theme.fg, 0.1) + + Column { + id: layout + anchors.centerIn: parent + width: parent.width - 44 + spacing: 14 + + Text { + width: parent.width + text: "Authentication required" + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeLarge + font.weight: Font.DemiBold + wrapMode: Text.WordWrap + } + + Text { + width: parent.width + text: Polkit.message + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSize + wrapMode: Text.WordWrap + } + + Text { + width: parent.width + visible: Polkit.users.length > 1 + text: "Authenticating as " + Polkit.chosenUser + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + PasswordField { + id: field + width: parent.width + placeholder: "Password" + enabled: !Polkit.authenticating + onAccepted: { + if (field.text !== "") + Polkit.submit(field.text); + field.text = ""; + } + } + + Text { + width: parent.width + visible: Polkit.failureText !== "" + text: Polkit.failureText + color: Theme.danger + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.WordWrap + } + + Row { + anchors.right: parent.right + spacing: 8 + + SettingsButton { + text: "Cancel" + onClicked: Polkit.cancel() + } + + SettingsButton { + text: Polkit.authenticating ? "Checking…" : "Authenticate" + tone: "accent" + enabled: !Polkit.authenticating + onClicked: { + if (field.text !== "") + Polkit.submit(field.text); + field.text = ""; + } + } + } + } + } + + // Escape cancels, which is what every other dialog on this desktop does. + Item { + anchors.fill: parent + focus: true + Keys.onEscapePressed: Polkit.cancel() + } +} diff --git a/config/dot/quickshell/modules/polkit/qmldir b/config/dot/quickshell/modules/polkit/qmldir new file mode 100644 index 0000000..f004907 --- /dev/null +++ b/config/dot/quickshell/modules/polkit/qmldir @@ -0,0 +1,2 @@ +module qs.modules.polkit +PolkitPrompt 1.0 PolkitPrompt.qml diff --git a/config/dot/quickshell/scripts/panama-polkit-agent b/config/dot/quickshell/scripts/panama-polkit-agent new file mode 100755 index 0000000..e0bc0d8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-polkit-agent @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 + +"""Panama's polkit authentication agent -- the D-Bus half. + +This process never sees a password. It is worth being explicit about why the +work is split this way, because the split IS the security design: + + * This half registers with polkitd, receives an authentication request, and + hands the non-secret parts of it to the shell: which action, what message, + which identities may answer, and the one-time cookie. + * The shell half draws the prompt, and when someone types a password it + 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 + the result to polkitd directly. + +So the password exists only inside the shell process and the helper's stdin. It +never crosses D-Bus, never crosses this process, and never appears on a command +line -- argv is world-readable through /proc, which is why "just pass it as an +argument" is not an option anywhere in this codebase. + +The request itself is handed over as a file in the runtime directory, created +0600, rather than as IPC arguments. The cookie is not a password, but it is a +capability, and capabilities do not belong in a process listing either. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + +import gi + +gi.require_version("Gio", "2.0") +gi.require_version("GLib", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +AGENT_PATH = "/org/panama/PolkitAgent" +AGENT_INTERFACE = "org.freedesktop.PolicyKit1.AuthenticationAgent" +AUTHORITY_NAME = "org.freedesktop.PolicyKit1" +AUTHORITY_PATH = "/org/freedesktop/PolicyKit1/Authority" +AUTHORITY_INTERFACE = "org.freedesktop.PolicyKit1.Authority" + +# How long a prompt may stay on screen before this gives up and tells polkit the +# request failed. Without it, a shell that died mid-prompt would leave the +# caller (a package manager, say) waiting forever. +PROMPT_TIMEOUT_SECONDS = 300 + +INTROSPECTION = """ + + + + + + + + + + + + + + + +""" + + +def runtime_dir() -> Path: + base = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")) / "panama-polkit" + base.mkdir(mode=0o700, parents=True, exist_ok=True) + # Enforced rather than assumed: an inherited directory with looser + # permissions would expose every request that passes through it. + os.chmod(base, 0o700) + return base + + +def log(message: str) -> None: + print(f"panama-polkit-agent: {message}", file=sys.stderr, flush=True) + + +class Agent: + def __init__(self) -> None: + self.bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + self.pending: dict[str, Path] = {} + self.loop = GLib.MainLoop() + + # ── Registration ──────────────────────────────────────────────────────── + + def subject(self) -> GLib.Variant: + """This login session, which is what the agent authenticates for. + + XDG_SESSION_ID is set for a login shell but NOT in the systemd user + environment, so running as a unit has to ask logind which session this + process belongs to rather than trusting the variable to be there. + """ + session_id = os.environ.get("XDG_SESSION_ID", "") + if not session_id: + session_id = self.session_from_logind() + if not session_id: + raise RuntimeError("could not determine the login session to register for") + return GLib.Variant("(sa{sv})", ("unix-session", + {"session-id": GLib.Variant("s", session_id)})) + + def session_from_logind(self) -> str: + """This user's graphical session, asked of logind. + + Not GetSessionByPID: a systemd USER unit runs under user@N.service, + which belongs to no login session at all -- which is exactly why + XDG_SESSION_ID is missing when started that way. The user object's + Display property names the graphical session, which is the one whose + authentications this agent should answer. + """ + try: + user_path = self.bus.call_sync( + "org.freedesktop.login1", "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", "GetUser", + GLib.Variant("(u)", (os.getuid(),)), GLib.VariantType("(o)"), + Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] + display = self.bus.call_sync( + "org.freedesktop.login1", user_path, + "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", ("org.freedesktop.login1.User", "Display")), + GLib.VariantType("(v)"), Gio.DBusCallFlags.NONE, 5000, None).unpack()[0] + # (session-id, object-path) + return str(display[0]) if display and display[0] else "" + except Exception: # noqa: BLE001 - no session is a real answer + return "" + + def register(self) -> None: + node = Gio.DBusNodeInfo.new_for_xml(INTROSPECTION) + # The object MUST be exported on the same connection that registers the + # agent. polkitd calls BeginAuthentication back on the unique name that + # called RegisterAuthenticationAgent -- so exporting on the session bus + # while registering from the system bus leaves polkitd calling a path + # that does not exist, and every request fails as "Not authorized" + # without ever prompting. + self.bus.register_object( + AGENT_PATH, node.interfaces[0], self.on_call, None, None) + self.bus.call_sync( + AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE, + "RegisterAuthenticationAgent", + GLib.Variant.new_tuple(self.subject(), + GLib.Variant("s", "en_US.UTF-8"), + GLib.Variant("s", AGENT_PATH)), + None, Gio.DBusCallFlags.NONE, 10000, None) + log("registered with polkitd") + + def unregister(self) -> None: + try: + self.bus.call_sync( + AUTHORITY_NAME, AUTHORITY_PATH, AUTHORITY_INTERFACE, + "UnregisterAuthenticationAgent", + GLib.Variant.new_tuple(self.subject(), GLib.Variant("s", AGENT_PATH)), + None, Gio.DBusCallFlags.NONE, 5000, None) + log("unregistered") + except Exception: # noqa: BLE001 - shutting down either way + pass + + # ── The agent interface ───────────────────────────────────────────────── + + def on_call(self, connection, sender, path, interface, method, parameters, invocation): + if method == "BeginAuthentication": + action_id, message, icon_name, details, cookie, identities = parameters.unpack() + self.begin(action_id, message, icon_name, cookie, identities, invocation) + return + if method == "CancelAuthentication": + (cookie,) = parameters.unpack() + self.cancel(cookie) + invocation.return_value(None) + return + invocation.return_error_literal(Gio.dbus_error_quark(), + Gio.DBusError.UNKNOWN_METHOD, "Unknown method") + + def usernames(self, identities: list) -> list[str]: + """Who polkit will accept an answer from, as names a person recognizes.""" + names = [] + for kind, attributes in identities: + if kind != "unix-user": + continue + uid = attributes.get("uid") + if uid is None: + continue + try: + import pwd + + names.append(pwd.getpwuid(int(uid)).pw_name) + except (KeyError, ValueError): + continue + return names + + def begin(self, action_id, message, icon_name, cookie, identities, invocation) -> None: + names = self.usernames(identities) + if not names: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + "No user is allowed to authenticate this request.") + return + + request_path = runtime_dir() / f"request-{os.getpid()}-{int(time.time() * 1000)}.json" + payload = { + "actionId": action_id, + "message": message, + "iconName": icon_name, + "cookie": cookie, + "users": names, + "preferred": os.environ.get("USER", names[0]), + } + # 0600 before anything is written to it, so the cookie is never briefly + # world-readable. + handle = os.open(request_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(handle, "w", encoding="utf-8") as stream: + json.dump(payload, stream) + + self.pending[cookie] = request_path + response_path = request_path.with_suffix(".response") + + if not self.ask_shell(request_path): + self.finish(cookie, request_path, response_path) + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + "The desktop could not show an authentication prompt.") + return + + # Poll for the shell's answer rather than blocking the main loop, so a + # Cancel from polkit is still processed while a prompt is open. + started = time.monotonic() + + def check() -> bool: + if cookie not in self.pending: + # Cancelled from the polkit side. + self.finish(cookie, request_path, response_path) + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Cancelled.") + return False + if response_path.exists(): + try: + answer = json.loads(response_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + answer = {"result": "failed"} + self.finish(cookie, request_path, response_path) + if answer.get("result") == "ok": + # The helper has already told polkitd. Returning normally is + # what completes the request. + invocation.return_value(None) + else: + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, + "Authentication was not completed.") + return False + if time.monotonic() - started > PROMPT_TIMEOUT_SECONDS: + self.finish(cookie, request_path, response_path) + invocation.return_error_literal( + Gio.dbus_error_quark(), Gio.DBusError.FAILED, "Timed out.") + return False + return True + + GLib.timeout_add(150, check) + + def cancel(self, cookie: str) -> None: + request_path = self.pending.pop(cookie, None) + if request_path is None: + return + # A marker rather than a deletion: the shell watches for this to close a + # prompt that polkit no longer wants an answer to. + try: + request_path.with_suffix(".cancelled").touch(mode=0o600) + except OSError: + pass + + def finish(self, cookie: str, request_path: Path, response_path: Path) -> None: + self.pending.pop(cookie, None) + for path in (request_path, response_path, request_path.with_suffix(".cancelled")): + try: + path.unlink() + except OSError: + pass + + def ask_shell(self, request_path: Path) -> bool: + """Tell the shell to prompt. Only the path travels, never the contents.""" + try: + result = subprocess.run( + ["qs", "ipc", "call", "polkit", "begin", str(request_path)], + capture_output=True, text=True, timeout=15, check=False) + except (OSError, subprocess.TimeoutExpired): + return False + if result.returncode != 0: + log(f"the shell refused the prompt: {result.stderr.strip()[:120]}") + return False + return True + + def run(self) -> int: + self.register() + for received in (signal.SIGINT, signal.SIGTERM): + GLib.unix_signal_add(GLib.PRIORITY_DEFAULT, received, self.stop) + try: + self.loop.run() + finally: + self.unregister() + return 0 + + def stop(self) -> bool: + self.loop.quit() + return False + + +def main() -> int: + try: + return Agent().run() + except Exception as error: # noqa: BLE001 + log(f"could not start: {error}") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/config/dot/quickshell/services/Polkit.qml b/config/dot/quickshell/services/Polkit.qml new file mode 100644 index 0000000..380db53 --- /dev/null +++ b/config/dot/quickshell/services/Polkit.qml @@ -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 } +} diff --git a/config/dot/quickshell/shell.qml b/config/dot/quickshell/shell.qml index d79cdd4..cc2546c 100644 --- a/config/dot/quickshell/shell.qml +++ b/config/dot/quickshell/shell.qml @@ -38,6 +38,7 @@ import qs.modules.clipboard import qs.modules.datemenu import qs.modules.focus import qs.modules.signals +import qs.modules.polkit import qs.modules.settings import qs.modules.osd @@ -409,6 +410,19 @@ ShellRoot { } } + // Panama's authentication prompt. The agent process calls in here with a + // path to a request file; no password ever travels the other way. + PolkitPrompt {} + + IpcHandler { + target: "polkit" + function begin(path: string): void { Polkit.begin(path); } + function cancel(): void { Polkit.cancel(); } + function status(): string { + return JSON.stringify({ active: Polkit.active, action: Polkit.actionId }); + } + } + IpcHandler { target: "settings" function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); } diff --git a/config/local/share/systemd/user/panama-polkit-agent.service b/config/local/share/systemd/user/panama-polkit-agent.service new file mode 100644 index 0000000..0c64f0f --- /dev/null +++ b/config/local/share/systemd/user/panama-polkit-agent.service @@ -0,0 +1,23 @@ +[Unit] +Description=Panama polkit authentication agent +Documentation=https://github.com/gibbyb/Panama +# Only one agent may be registered per session, so this must not run alongside +# the one it replaces. +Conflicts=hyprpolkitagent.service +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/.config/quickshell/scripts/panama-polkit-agent +# The prompt is drawn by the shell, so an agent that outlives a shell restart +# would be registered with nowhere to prompt. Restarting reconnects it. +Restart=on-failure +RestartSec=2 +# If this fails repeatedly, stop rather than flap: a session with no agent is +# recoverable from a terminal, a session with a broken one is confusing. +StartLimitBurst=5 +StartLimitIntervalSec=60 + +[Install] +WantedBy=graphical-session.target diff --git a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md index 2757392..4594adc 100644 --- a/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md +++ b/docs/superpowers/plans/2026-08-19-settings-beyond-gnome.md @@ -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 `user@N.service`, 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. diff --git a/tests/quickshell/polkit-agent-contract.sh b/tests/quickshell/polkit-agent-contract.sh new file mode 100755 index 0000000..09a4dc0 --- /dev/null +++ b/tests/quickshell/polkit-agent-contract.sh @@ -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'