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