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
320 lines
13 KiB
QML
320 lines
13 KiB
QML
// Privacy & Security.
|
|
//
|
|
// GNOME's Privacy panel covers screen lock, camera and microphone access, file
|
|
// history, trash, and device security. Panama covers the parts it genuinely
|
|
// owns and is explicit about the parts it does not.
|
|
//
|
|
// The file-history and trash settings are the notable omission, and the reason
|
|
// is worth stating: those are GNOME preferences enforced by gsd-housekeeping,
|
|
// which is not running in a Hyprland session. Offering switches for them would
|
|
// store a preference, change nothing, and give no sign of it -- the exact
|
|
// failure this codebase keeps designing against. So they are delegated by name
|
|
// rather than reimplemented as controls that lie.
|
|
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
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."
|
|
|
|
Component.onCompleted: {
|
|
if (!DeviceSecurity.scanned)
|
|
DeviceSecurity.refresh();
|
|
if (!Keyring.scanned)
|
|
Keyring.refresh();
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Screen lock"
|
|
subtitle: "The same settings as Power & Lock, which is where the idle timings live."
|
|
|
|
SliderRow { setting: "lockMinutes"; zeroLabel: "Never" }
|
|
ToggleRow { setting: "lockOnSleep"; divider: false }
|
|
}
|
|
|
|
// The login keyring, which nothing else surfaces.
|
|
//
|
|
// It is unlocked at sign-in by PAM, so this card normally just confirms
|
|
// that. It earns its place on the rare occasion it is not: a locked keyring
|
|
// breaks saved passwords everywhere at once, and does it without ever
|
|
// saying the word "keyring" -- you get a mail account that will not
|
|
// authenticate and a git push that cannot find its key.
|
|
SettingsCard {
|
|
visible: Keyring.scanned
|
|
title: "Saved passwords"
|
|
subtitle: !Keyring.available
|
|
? "No secret service is answering, so saved passwords are unavailable."
|
|
: Keyring.locked
|
|
? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons."
|
|
: "The login keyring is unlocked, as it is after every normal sign-in."
|
|
|
|
// Two rows rather than one with a conditional button: a locked keyring
|
|
// needs an action, an unlocked one is a statement of fact, and ActionRow
|
|
// and TextRow already say exactly those two things.
|
|
ActionRow {
|
|
visible: Keyring.available && Keyring.locked
|
|
label: "Login keyring"
|
|
detail: "Unlock to restore access to stored passwords and keys"
|
|
action: Keyring.unlocking ? "Waiting…" : "Unlock"
|
|
enabled: !Keyring.unlocking
|
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
|
onTriggered: Keyring.unlock()
|
|
}
|
|
|
|
TextRow {
|
|
visible: !(Keyring.available && Keyring.locked)
|
|
label: "Login keyring"
|
|
detail: Keyring.available
|
|
? "Unlocked at sign-in by PAM, the same way GNOME does it"
|
|
: "No secret service is answering on this session"
|
|
value: Keyring.available ? "Unlocked" : "Unavailable"
|
|
divider: Keyring.replacementDaemon || Keyring.lastError !== ""
|
|
}
|
|
|
|
// Only shown when it is true, because it is a diagnostic rather than a
|
|
// setting: it means the daemon holding your secrets is not the one PAM
|
|
// started, so whatever unlocked it will not survive a restart.
|
|
SettingRow {
|
|
visible: Keyring.replacementDaemon
|
|
label: "Keyring service"
|
|
detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks."
|
|
value: "Replaced"
|
|
divider: Keyring.lastError !== ""
|
|
}
|
|
|
|
SettingRow {
|
|
visible: Keyring.lastError !== ""
|
|
label: "Keyring problem"
|
|
detail: Keyring.lastError
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
|
|
// ── 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
|
|
? "In use right now — the bar shows an indicator whenever this is true."
|
|
: "Nothing is using your camera or microphone."
|
|
|
|
TextRow {
|
|
label: "Camera"
|
|
detail: PrivacyState.cameraActive
|
|
? "In use by " + (PrivacyState.cameraApp || "an application")
|
|
: "Not in use"
|
|
value: PrivacyState.cameraActive ? "Active" : "Idle"
|
|
}
|
|
|
|
TextRow {
|
|
label: "Microphone"
|
|
detail: PrivacyState.microphoneActive
|
|
? "In use by " + (PrivacyState.microphoneApp || "an application")
|
|
: "Not in use"
|
|
value: PrivacyState.microphoneActive ? "Active" : "Idle"
|
|
}
|
|
|
|
TextRow {
|
|
label: "Screen sharing"
|
|
detail: PrivacyState.screenSharingActive
|
|
? "Being shared by " + (PrivacyState.screenSharingApp || "an application")
|
|
: "Not being shared"
|
|
value: PrivacyState.screenSharingActive ? "Active" : "Idle"
|
|
divider: false
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Device security"
|
|
subtitle: DeviceSecurity.attentionCount === 0
|
|
? "Everything below is in its recommended state."
|
|
: DeviceSecurity.attentionCount + " item"
|
|
+ (DeviceSecurity.attentionCount === 1 ? "" : "s") + " below may deserve attention."
|
|
|
|
Repeater {
|
|
model: DeviceSecurity.facts
|
|
|
|
TextRow {
|
|
id: factRow
|
|
required property var modelData
|
|
required property int index
|
|
|
|
label: factRow.modelData.label
|
|
detail: factRow.modelData.detail
|
|
value: factRow.modelData.value
|
|
divider: factRow.index < DeviceSecurity.facts.length - 1
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Owned by Fedora"
|
|
subtitle: "File history and trash retention are GNOME preferences, applied by a housekeeping service that does not run in a Hyprland session. They are not offered as switches here, because storing that preference would change nothing."
|
|
|
|
ActionRow {
|
|
label: "File history & trash"
|
|
detail: "Opens GNOME Settings, which owns these"
|
|
action: "Open privacy"
|
|
divider: false
|
|
onTriggered: SystemSettings.openGnomePanel("privacy")
|
|
}
|
|
}
|
|
}
|