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.
227 lines
8.4 KiB
QML
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 }
|
|
}
|