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:
Gabriel Brown
2026-08-19 11:14:32 -04:00
parent 99433c0e8e
commit 8e93f08977
6 changed files with 552 additions and 4 deletions
@@ -19,6 +19,25 @@ SettingsPage {
id: root
title: "Privacy & Security"
// The stored-secret list is collapsed until asked for, and one item at a
// time can be waiting on a confirmed Forget.
property bool showingSecrets: false
property string confirmingPath: ""
// What a stored secret is FOR, from its attributes. Never its value.
function describe(item: var): string {
const attributes = item?.attributes ?? {};
const parts = [];
for (const key of ["user", "username", "account", "server", "host", "domain", "service", "application"]) {
if (attributes[key])
parts.push(String(attributes[key]));
}
if (parts.length > 0)
return parts.join(" · ");
const schema = String(item?.schema ?? "");
return schema !== "" ? schema : "No further detail stored";
}
lede: DeviceSecurity.scanned && DeviceSecurity.attentionCount === 0
? "Screen lock, device access, and a machine whose security settings all check out."
: "Screen lock, which applications can see you, and how this machine is protected."
@@ -96,6 +115,140 @@ SettingsPage {
}
}
// ── What is actually stored ──────────────────────────────────────────────
// Collapsed until asked. Opening the Privacy page should not enumerate
// someone's saved passwords as a side effect, and the list is long enough
// that it would bury every other setting on the page.
SettingsCard {
visible: Keyring.scanned && Keyring.available && !Keyring.locked
title: "Stored secrets"
subtitle: Keyring.listed
? "Passwords and tokens applications have saved. The values are never shown here."
: "Passwords and tokens applications have saved, listed only when you ask."
ActionRow {
label: "Saved items"
detail: Keyring.listed
? Keyring.storedCount + " stored across "
+ Keyring.collections.length + " keyring"
+ (Keyring.collections.length === 1 ? "" : "s")
: "Read the keyring and list what is in it"
action: root.showingSecrets
? "Hide"
: (Keyring.listing ? "Reading…" : "Show")
enabled: !Keyring.listing
divider: root.showingSecrets
onTriggered: {
if (root.showingSecrets) {
root.showingSecrets = false;
root.confirmingPath = "";
return;
}
root.showingSecrets = true;
if (!Keyring.listed)
Keyring.list();
}
}
TextRow {
visible: root.showingSecrets && Keyring.copiedPath !== ""
label: "Copied to the clipboard"
detail: "It clears itself in about a minute, unless you copy something else first."
value: ""
divider: true
}
Repeater {
model: root.showingSecrets && Keyring.listed ? Keyring.collections : []
delegate: Column {
id: collectionBlock
required property var modelData
width: parent.width
TextRow {
width: collectionBlock.width
label: String(collectionBlock.modelData.label ?? "")
detail: collectionBlock.modelData.locked
? "Locked, so its contents cannot be listed"
: (collectionBlock.modelData.items ?? []).length + " stored"
value: ""
divider: false
}
Repeater {
model: collectionBlock.modelData.items ?? []
delegate: SettingRow {
id: secretRow
required property var modelData
required property int index
readonly property string itemPath: String(secretRow.modelData.path ?? "")
readonly property bool confirming: root.confirmingPath === secretRow.itemPath
width: collectionBlock.width
label: String(secretRow.modelData.label ?? "")
// Attributes, never the value: what the secret is FOR is
// the part that identifies it.
detail: root.describe(secretRow.modelData)
controlWidth: 200
divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1
Row {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
spacing: 8
SettingsButton {
text: secretRow.confirming ? "Cancel" : "Copy"
enabled: !Keyring.working
onClicked: {
if (secretRow.confirming) {
root.confirmingPath = "";
return;
}
Keyring.copy(secretRow.itemPath);
}
}
SettingsButton {
// Two presses, always. Forgetting a stored
// password cannot be undone, and the button
// sits next to Copy where a misclick is cheap.
text: secretRow.confirming ? "Forget it" : "Forget"
tone: secretRow.confirming ? "danger" : "normal"
enabled: !Keyring.working
onClicked: {
if (!secretRow.confirming) {
root.confirmingPath = secretRow.itemPath;
return;
}
root.confirmingPath = "";
Keyring.forget(secretRow.itemPath);
}
}
}
}
}
Item { width: 1; height: 6 }
}
}
TextRow {
visible: root.showingSecrets && Keyring.listed && Keyring.storedCount === 0
label: "Nothing stored yet"
detail: "Applications that save a password or token will appear here."
value: ""
divider: false
}
}
SettingsCard {
title: "Camera & microphone"
subtitle: PrivacyState.anyActive