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
This commit is contained in:
@@ -39,6 +39,25 @@ Singleton {
|
||||
|
||||
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;
|
||||
@@ -53,6 +72,54 @@ Singleton {
|
||||
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);
|
||||
@@ -74,6 +141,27 @@ Singleton {
|
||||
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"]
|
||||
|
||||
Reference in New Issue
Block a user