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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user