Files
Gabriel Brown f42b3cfe0e Let the password prompt say why
panama-sudo is pkexec with a stated reason: the reason travels to the
shell over the existing polkit IPC target, and the prompt renders it
labeled "Stated reason (unverified)" beside polkitd's real action
message -- beside, never instead of, because any process can claim any
reason and the action text is the trust anchor. Reasons are single-shot
and expire in ten seconds, so a stale one cannot dress up an unrelated
prompt; without a reason, a running shell, or qs the wrapper is exactly
pkexec. Built for agents, so the person typing their password learns
what for. Verified live end to end -- reason shown, consumed once,
expired when stale, cleared on dismissal -- and pinned by the polkit
reason contract.
2026-08-21 18:57:26 -04:00

227 lines
8.4 KiB
QML

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 }
}