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 // A caller's stated reason for the NEXT request, and the one attached to // the request on screen. Untrusted by design -- any process can state one // -- so the prompt shows it clearly labeled beside polkitd's real action // message, never in place of it. See stateReason(). property var pendingReason: null property string statedReason: "" readonly property bool active: root.request !== null // Held only between pressing Enter and the helper accepting it on stdin. property string pendingSecret: "" // panama-sudo's side channel: state WHY the authentication request about // to arrive is being made, so the prompt can say more than the generic // action text. Single-shot and short-lived -- it attaches only to the next // request, and only if that request arrives within ten seconds -- so a // stale reason can never dress up an unrelated prompt. function stateReason(text: string): void { const trimmed = String(text).trim().slice(0, 200); if (trimmed === "") return; root.pendingReason = { text: trimmed, at: Date.now() }; } 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 = ""; // Consume the stated reason whether or not it is still fresh: // either way it must not survive to a later request. const pending = root.pendingReason; root.pendingReason = null; root.statedReason = (pending !== null && Date.now() - pending.at <= 10000) ? pending.text : ""; } 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; } // Cancelled by the person at the keyboard, or withdrawn by whatever asked. // Both close the prompt; only the first is something they did. 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 !== "") { // umask first: everything in this directory concerns one // authentication attempt, and a file written with the default mask // would be world-readable in a directory whose whole point is that // it is not. answer.command = ["sh", "-c", "umask 077; 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; root.statedReason = ""; } 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 } }