Files
Gabriel Brown 8e93f08977 Show what the keyring holds, without showing what it holds
Managing a stored credential meant installing Seahorse. The keyring
rows on Privacy could say whether it was locked and nothing about what
was in it.

Four rules, each pinned by a contract, because each is a way this could
leak the thing it exists to protect:

Listing never reads values. Enumerating reports labels and attributes;
it does not ask the keyring to hand over what it is protecting.

A secret never reaches a command line. /proc makes argv readable by
every process on this machine, so a password passed as an argument is
published to all of them. The helper reads the value in process and
writes it to wl-copy on stdin.

A secret never reaches an error message, a log, or a QML property. An
exception raised while holding a password does not get to choose what
text is printed, so the clipboard tool's stderr is discarded rather
than echoed.

Forgetting one is irreversible, so the first press asks and the second
does it, and the confirming button is the only one wearing danger.

The list is collapsed until asked for: opening Privacy should not
enumerate someone's passwords as a side effect. A copied value clears
itself about a minute later, but only if the clipboard still holds it --
the guard compares a SHA-256, so the waiting process never has the
password.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-19 11:14:32 -04:00

172 lines
6.0 KiB
QML

pragma Singleton
// The login keyring's lock state.
//
// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is
// under GNOME. What a bare Hyprland session lacks is anywhere to see when that
// has stopped being true.
//
// It stops being true rarely but expensively: gnome-keyring-daemon can crash,
// D-Bus activates a replacement, and the replacement never received the login
// password -- so the keyring is locked in the middle of a session that unlocked
// it correctly. Nothing announces this. What the user sees instead is a mail
// account that will not authenticate, a git push that cannot find its key, or
// an integration reporting "not configured", none of which mention keyrings.
//
// Checked on demand and after an unlock, not polled: the state changes only
// when a daemon dies or a password is entered.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring"
property bool available: false
property bool locked: false
property bool scanned: false
property bool unlocking: false
property string lastError: ""
// "pam" when the daemon that holds the keyring is the one PAM started at
// login, "dbus" when it is a D-Bus-activated replacement -- which is the
// signature of the crash case, and worth showing, because a dbus daemon
// that is currently unlocked was unlocked by hand and will not survive.
property string daemon: ""
readonly property bool replacementDaemon: root.daemon === "dbus"
// What is stored, never what is stored IN it. No property on this service
// ever holds a password: the value is read by the helper, handed straight
// to the clipboard, and forgotten. It does not cross into QML at all.
property var collections: []
property bool listed: false
property bool listing: false
property bool working: false
// Set for a moment after a successful copy, so the page can say what
// happened without the page having to know how long a clipboard lasts.
property string copiedPath: ""
readonly property int storedCount: {
let total = 0;
for (const collection of root.collections)
total += (collection.items ?? []).length;
return total;
}
function refresh(): void {
if (!query.running)
query.running = true;
}
// Raises the standard password dialog. The password never passes through
// Panama -- the Secret Service prompts, the same way it does under GNOME.
function unlock(): void {
if (root.unlocking)
return;
root.unlocking = true;
unlockProcess.running = true;
}
// The stored-secret list. Separate from status() because it is the only
// read that needs the keyring UNLOCKED, and because a settings page should
// not enumerate someone's passwords just because it was opened.
function list(): void {
if (root.listing)
return;
root.listing = true;
items.command = [root.helperPath, "items"];
items.running = true;
}
// Puts one stored secret on the clipboard. The value never reaches this
// process; the helper reads it and writes it to wl-copy's stdin, and clears
// it again shortly afterwards if it is still there.
function copy(path: string): void {
if (root.working)
return;
root.working = true;
root.copiedPath = path;
items.command = [root.helperPath, "copy", path];
items.running = true;
}
// Irreversible. The page confirms before calling this.
function forget(path: string): void {
if (root.working)
return;
root.working = true;
root.copiedPath = "";
items.command = [root.helperPath, "forget", path];
items.running = true;
}
function absorbItems(text: string): void {
try {
const parsed = JSON.parse(text);
root.collections = Array.isArray(parsed.collections) ? parsed.collections : [];
root.lastError = String(parsed.error ?? "");
if (root.lastError !== "")
root.copiedPath = "";
} catch (error) {
root.collections = [];
root.lastError = "Could not read the stored secrets.";
console.warn("Keyring: could not parse item output:", error);
}
root.listed = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.locked = parsed.locked === true;
root.daemon = String(parsed.daemon ?? "");
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.available = false;
root.lastError = "Could not read the keyring helper's output.";
console.warn("Keyring: could not parse helper output:", error);
}
root.scanned = true;
}
Process {
id: query
command: [root.helperPath, "status"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
}
// One process for all three item operations: each of them answers with the
// same list, so a page never has to ask again to find out what changed.
Process {
id: items
stdout: StdioCollector { onStreamFinished: root.absorbItems(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: {
root.listing = false;
root.working = false;
}
}
// The clipboard notice is transient, and says so by disappearing.
Timer {
running: root.copiedPath !== ""
interval: 12000
onTriggered: root.copiedPath = ""
}
Process {
id: unlockProcess
command: [root.helperPath, "unlock"]
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
onExited: root.unlocking = false
}
}