From 6f0ce639d9c0bf4b168bf1714ff8f6ca3509a284 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Mon, 24 Aug 2026 19:26:56 -0400 Subject: [PATCH] Show every answer the portal remembers, and give SSH keys their missing half Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8 --- README.md | 2 +- .../modules/settings/PrivacyLiveTile.qml | 81 ++ .../modules/settings/PrivacyPage.qml | 701 ++++++++++++++---- .../dot/quickshell/modules/settings/README.md | 9 +- .../modules/settings/SectionLabel.qml | 48 ++ .../modules/settings/SshKeysPage.qml | 357 +++++++-- config/dot/quickshell/modules/settings/qmldir | 2 + .../dot/quickshell/scripts/panama-permissions | 268 +++++-- config/dot/quickshell/scripts/panama-privacy | 269 +++++++ config/dot/quickshell/scripts/panama-ssh-keys | 285 ++++++- .../dot/quickshell/services/Permissions.qml | 97 ++- .../quickshell/services/SettingsSearch.qml | 18 + config/dot/quickshell/services/SshKeys.qml | 97 ++- config/dot/quickshell/services/Traces.qml | 114 +++ .../share/vicinae/scripts/settings-privacy | 2 +- .../share/vicinae/scripts/settings-ssh-keys | 2 +- ...26-08-23-settings-redesign-test-backlog.md | 189 +++++ .../2026-08-24-privacy-security-redesign.md | 157 ++++ tests/quickshell/gnome-handoff-contract | 18 + .../quickshell/lock-screen-settings-contract | 18 + tests/quickshell/permissions-contract | 474 ++++++++++-- tests/quickshell/privacy-traces-contract | 354 +++++++++ tests/quickshell/secrets-contract | 73 +- tests/quickshell/settings-ownership-contract | 10 +- tests/quickshell/ssh-keys-contract | 385 +++++++++- 25 files changed, 3622 insertions(+), 408 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/PrivacyLiveTile.qml create mode 100644 config/dot/quickshell/modules/settings/SectionLabel.qml create mode 100755 config/dot/quickshell/scripts/panama-privacy create mode 100644 config/dot/quickshell/services/Traces.qml create mode 100644 docs/superpowers/specs/2026-08-24-privacy-security-redesign.md create mode 100755 tests/quickshell/privacy-traces-contract diff --git a/README.md b/README.md index ab7210d..5fcdc8e 100644 --- a/README.md +++ b/README.md @@ -151,7 +151,7 @@ docs/ Settings reference, and the design specs behind the work ## Tests -172 of them, under `tests/`. Run the lot, or a subset by pattern: +173 of them, under `tests/`. Run the lot, or a subset by pattern: ```sh panama test # everything diff --git a/config/dot/quickshell/modules/settings/PrivacyLiveTile.qml b/config/dot/quickshell/modules/settings/PrivacyLiveTile.qml new file mode 100644 index 0000000..35a4f2a --- /dev/null +++ b/config/dot/quickshell/modules/settings/PrivacyLiveTile.qml @@ -0,0 +1,81 @@ +// One tile of the camera/microphone/screen strip: whether the thing is being +// used right this second, and by what. +// +// PrivacyLiveTile { +// glyph: "\u{F0100}" +// label: "Camera" +// active: PrivacyState.cameraActive +// app: PrivacyState.cameraApp +// } +// +// Deliberately still. An indicator that pulses or fades is reporting the same +// fact over and over at sixty frames a second, and this one sits on a settings +// page that may be open for a long time -- the border and the warn-toned line +// carry the whole of the difference, and they carry it without repainting. + +import QtQuick +import qs.config + +Rectangle { + id: root + + property string glyph: "" + property string label: "" + property bool active: false + // The application name PipeWire reported, which is often empty even while + // a stream is plainly running. + property string app: "" + + // Not `state`: Item already owns that name. + readonly property string useLine: root.active + ? "In use by " + (root.app !== "" ? root.app : "an application") + : "Idle" + + implicitHeight: 56 + radius: Theme.cardRadius + color: Theme.alpha(Theme.bgDark, 0.55) + border.width: 1 + border.color: root.active + ? Theme.alpha(Theme.warn, 0.45) + : Theme.alpha(Theme.fg, 0.08) + + Text { + id: icon + + anchors.left: parent.left + anchors.leftMargin: 12 + anchors.verticalCenter: parent.verticalCenter + text: root.glyph + color: root.active ? Theme.warn : Theme.fgDim + font.family: Theme.fontMono + font.pixelSize: 17 + } + + Column { + anchors.left: icon.right + anchors.leftMargin: 10 + anchors.right: parent.right + anchors.rightMargin: 12 + anchors.verticalCenter: parent.verticalCenter + spacing: 2 + + Text { + width: parent.width + text: root.label + color: Theme.fg + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + font.weight: Font.DemiBold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: root.useLine + color: root.active ? Theme.warn : Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + elide: Text.ElideRight + } + } +} diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml index 30bb244..dda118f 100644 --- a/config/dot/quickshell/modules/settings/PrivacyPage.qml +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -1,15 +1,24 @@ -// Privacy & Security. +// Privacy. // -// 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. +// Three questions, in the order anyone actually asks them: what can see or hear +// me right now, what was allowed to before, and what does this machine remember +// about what I did. // -// 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. +// The first two used to be separate cards -- live PipeWire indicators in one, +// portal permissions in another -- which split one subject in half and left the +// screencast and remote-desktop tables, the two most consequential grants the +// portal records, showing nowhere at all. They are one card now, live strip on +// top and the tables under it. +// +// The third used to be a door: a card headed "Owned by Fedora" whose only row +// opened GNOME's privacy panel, because file history and trash retention are +// gsd-housekeeping preferences and gsd-housekeeping does not run here. That was +// true about the SETTINGS and beside the point about the DATA. Clearing recent +// files, thumbnails and the trash needs no daemon's cooperation, so Panama does +// it, and the door is gone. +// +// What is deliberately not here: file-history and trash RETENTION switches, +// which really would store a preference and change nothing. import QtQuick import qs.config @@ -18,12 +27,70 @@ import qs.services SettingsPage { id: root - title: "Privacy & Security" + objectName: "privacy" + title: "Privacy" + lede: "What can see you, what remembers you, and what this machine's hardware promises." // 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: "" + // Which stored item's Forget is armed, and nothing else. It used to be read + // by the Copy button too, which turned Copy into "Cancel" on any row whose + // Forget was waiting -- so the press that meant "copy this password" meant + // "never mind" instead, and the button that did it was the one labelled + // Copy a moment earlier. Copy is only ever Copy now. + property string confirmingItem: "" + + // "table/app" for the portal grant whose Revoke is armed, and the id of the + // trace row whose Clear is armed. One each -- arming a second disarms the + // first, which is what makes a misclick cheap. + property string confirmingRevoke: "" + property string confirmingTrace: "" + + // The portal's tables, in the order they matter, with the words for each -- + // what a row means, and what an empty section means. Which CONTROL a row + // gets is not decided here: the service answers that, from the helper's own + // table list, so a page cannot grow a switch for something the permission + // store refuses to write. Location is read-only and appears only when it + // has entries, because geoclue is not running on this machine. + readonly property var portalSections: [ + { + table: "camera", + label: "Camera", + detail: "", + empty: "No app has asked for the camera. The portal records an answer the first time each one asks." + }, + { + table: "microphone", + label: "Microphone", + detail: "", + empty: "No app has asked for the microphone. The portal records an answer the first time each one asks." + }, + { + table: "screencast", + label: "Screen sharing", + detail: "Can capture the screen", + empty: "No app holds a screen-capture grant." + }, + { + table: "remote-desktop", + label: "Remote desktop", + detail: "Can move the pointer and type", + empty: "No app holds a remote-control grant." + }, + { + table: "location", + label: "Location", + detail: "Recorded by the portal", + empty: "" + } + ] + + function askedCount(count: int): string { + if (count === 0) + return ""; + return count + (count === 1 ? " app has asked" : " apps have asked"); + } // What a stored secret is FOR, from its attributes. Never its value. function describe(item: var): string { @@ -38,9 +105,18 @@ SettingsPage { 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." + + // The trash row is Storage's measurement, not a second one. Disks already + // walks ~/.local/share/Trash and already knows how to empty it through gio, + // and two implementations of "how big is the trash" would disagree the + // first time one of them was changed. + readonly property var trashCleanable: { + for (const item of Disks.cleanables) { + if (String(item.id ?? "") === "trash") + return item; + } + return null; + } Component.onCompleted: { if (!DeviceSecurity.scanned) @@ -49,40 +125,249 @@ SettingsPage { Keyring.refresh(); if (!Permissions.scanned) Permissions.refresh(); + // Two du walks over ~/.local/share/recently-used.xbel and + // ~/.cache/thumbnails. Cheap enough to do on open; the trash figure + // comes from Storage's own scan, which is not. + if (!Traces.measured) + Traces.measure(); + if (!Disks.cleanablesMeasured) + Disks.measureCleanables(); } + // ── What can see and hear you ──────────────────────────────────────────── + SettingsCard { - title: "Screen lock" - subtitle: "The same settings as Power & Lock, which is where the idle timings live." + title: "Camera, microphone & screen" + subtitle: Permissions.available + ? "What is in use now, and the answers applications gave the desktop portal when they asked." + : (Permissions.lastError || "The desktop portal's permission store is not running, so the grants below cannot be read.") - SliderRow { setting: "lockMinutes"; zeroLabel: "Never" } - ToggleRow { setting: "lockOnSleep"; divider: false } + Row { + width: parent.width + spacing: 10 + bottomPadding: 12 + + PrivacyLiveTile { + width: (parent.width - 20) / 3 + glyph: "\u{F0100}" + label: "Camera" + active: PrivacyState.cameraActive + app: PrivacyState.cameraApp + } + + PrivacyLiveTile { + width: (parent.width - 20) / 3 + glyph: "\u{F036C}" + label: "Microphone" + active: PrivacyState.microphoneActive + app: PrivacyState.microphoneApp + } + + PrivacyLiveTile { + width: (parent.width - 20) / 3 + glyph: "\u{F0379}" + label: "Screen sharing" + active: PrivacyState.screenSharingActive + app: PrivacyState.screenSharingApp + } + } + + Repeater { + model: root.portalSections + + delegate: Column { + id: section + + required property var modelData + + readonly property string table: String(section.modelData.table) + readonly property var rows: Permissions.rowsFor(section.table) + // The service decides, not the page: `simple` is a table the + // permission store will let something write, `revokeOnly` is one + // it will only let something delete. A table that is neither + // gets no buttons at all. + readonly property bool simple: Permissions.isSimple(section.table) + readonly property bool revokeOnly: Permissions.isRevokeOnly(section.table) + + width: parent.width + // Location is the one section that disappears when empty: there + // is no geoclue on this machine, so an empty Location heading + // would be a category invented by the page. + visible: section.table !== "location" || section.rows.length > 0 + + SectionLabel { + text: String(section.modelData.label) + count: root.askedCount(section.rows.length) + } + + TextRow { + width: section.width + visible: section.rows.length === 0 + label: "Nothing has asked yet" + detail: String(section.modelData.empty) + divider: false + } + + Repeater { + model: section.rows + + delegate: SettingRow { + id: grantRow + + required property var modelData + required property int index + + readonly property string app: String(grantRow.modelData.app ?? "") + readonly property bool confirming: + root.confirmingRevoke === section.table + "/" + grantRow.app + + width: section.width + label: grantRow.app + // `grants` is how many separate entries the store folded + // into this row -- screencast keeps one per remembered + // session, so an app that has shared four times is four + // entries and one question. + detail: grantRow.confirming + ? "The recorded answer goes away, and this app is asked again the next time it tries." + : (!section.simple && !section.revokeOnly + ? (grantRow.modelData.allowed === true ? "Allowed" : "Withheld") + : String(section.modelData.detail) + + (Number(grantRow.modelData.grants ?? 0) > 1 + ? " · " + grantRow.modelData.grants + " remembered sessions" + : "")) + controlWidth: section.simple ? 175 : (section.revokeOnly ? 165 : 10) + divider: grantRow.index < section.rows.length - 1 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + // camera and microphone: forget the answer, or + // change it. Both are writes the store accepts. + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: section.simple + text: "Ask again" + enabled: !Permissions.busy + onClicked: Permissions.revoke(section.table, grantRow.app) + } + + SettingsToggle { + anchors.verticalCenter: parent.verticalCenter + visible: section.simple + checked: grantRow.modelData.allowed === true + onToggled: value => Permissions.setPermission( + section.table, grantRow.app, value) + } + + // screencast and remote-desktop: two presses, and + // no toggle at all. The value is a structured + // GVariant this page cannot compose, so revoking is + // the only thing it can honestly offer. + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: section.revokeOnly && grantRow.confirming + text: "Revoke it" + tone: "danger" + enabled: !Permissions.busy + onClicked: { + root.confirmingRevoke = ""; + Permissions.revoke(section.table, grantRow.app); + } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: section.revokeOnly + text: grantRow.confirming ? "Keep" : "Revoke…" + enabled: !Permissions.busy + onClicked: root.confirmingRevoke = grantRow.confirming + ? "" : section.table + "/" + grantRow.app + } + } + } + } + + Item { width: 1; height: 4 } + } + } + + // The limit, stated rather than left to be discovered. Saying "your + // camera is protected" when a system package can open /dev/video0 + // would be a claim this page cannot back up. + TextRow { + label: "Only sandboxed apps are covered" + detail: "These are the answers Flatpak apps gave the permission portal. Programs installed as system packages are not sandboxed, never ask, and reach these devices directly." + divider: false + } } - // The login keyring, which nothing else surfaces. + // ── Background ─────────────────────────────────────────────────────────── + // Its own card because it is a different question -- not who can watch you, + // but who keeps running when you thought you had closed them -- and because + // it is the longest table the portal keeps. + + SettingsCard { + id: backgroundCard + + readonly property var rows: Permissions.rowsFor("background") + + visible: Permissions.available + title: "Run in the background" + subtitle: backgroundCard.rows.length === 0 + ? "No app has asked to keep running with no window open." + : backgroundCard.rows.length + " app" + + (backgroundCard.rows.length === 1 ? " is" : "s are") + + " allowed to keep running with no window open." + + Repeater { + model: backgroundCard.rows + + delegate: SettingRow { + id: backgroundRow + + required property var modelData + required property int index + + width: parent.width + label: String(backgroundRow.modelData.app ?? "") + controlWidth: 70 + divider: backgroundRow.index < backgroundCard.rows.length - 1 + + SettingsToggle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + checked: backgroundRow.modelData.allowed === true + onToggled: value => Permissions.setPermission( + "background", String(backgroundRow.modelData.app ?? ""), value) + } + } + } + } + + // ── Saved passwords & secrets ──────────────────────────────────────────── // - // It is unlocked at sign-in by PAM, so this card normally just confirms + // The keyring is unlocked at sign-in by PAM, so this 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" + title: "Saved passwords & secrets" 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." + ? "The login keyring is locked. Applications that need a saved password will appear to fail for unrelated reasons until it is unlocked." + : "Values never leave the keyring except onto your clipboard, one at a time, when you ask." - // 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" + action: Keyring.unlocking ? "Waiting…" : "Unlock…" enabled: !Keyring.unlocking divider: Keyring.replacementDaemon || Keyring.lastError !== "" onTriggered: Keyring.unlock() @@ -92,10 +377,10 @@ SettingsPage { visible: !(Keyring.available && Keyring.locked) label: "Login keyring" detail: Keyring.available - ? "Unlocked at sign-in by PAM, the same way GNOME does it" + ? "Unlocked with your password at sign-in, the same way GNOME does it" : "No secret service is answering on this session" value: Keyring.available ? "Unlocked" : "Unavailable" - divider: Keyring.replacementDaemon || Keyring.lastError !== "" + divider: Keyring.available || Keyring.replacementDaemon || Keyring.lastError !== "" } // Only shown when it is true, because it is a diagnostic rather than a @@ -106,30 +391,20 @@ SettingsPage { 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 !== "" + divider: Keyring.available || Keyring.lastError !== "" } SettingRow { visible: Keyring.lastError !== "" label: "Keyring problem" detail: Keyring.lastError - divider: false + divider: Keyring.available } - } - - - // ── 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." + // Collapsed until asked. Opening the Privacy page should not enumerate + // someone's saved passwords as a side effect. ActionRow { + visible: Keyring.available && !Keyring.locked label: "Saved items" detail: Keyring.listed ? Keyring.storedCount + " stored across " @@ -144,7 +419,7 @@ SettingsPage { onTriggered: { if (root.showingSecrets) { root.showingSecrets = false; - root.confirmingPath = ""; + root.confirmingItem = ""; return; } root.showingSecrets = true; @@ -153,14 +428,6 @@ SettingsPage { } } - 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 : [] @@ -191,14 +458,15 @@ SettingsPage { required property int index readonly property string itemPath: String(secretRow.modelData.path ?? "") - readonly property bool confirming: root.confirmingPath === secretRow.itemPath + readonly property bool confirming: root.confirmingItem === secretRow.itemPath + readonly property bool copied: Keyring.copiedPath === 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 + controlWidth: 300 divider: secretRow.index < (collectionBlock.modelData.items ?? []).length - 1 Row { @@ -206,31 +474,44 @@ SettingsPage { anchors.verticalCenter: parent.verticalCenter spacing: 8 + // Said next to the row it happened on, and true: + // the helper leaves a watcher that clears the + // clipboard 45 seconds later, and only if the + // clipboard is still holding what it put there. + Text { + anchors.verticalCenter: parent.verticalCenter + visible: secretRow.copied + text: "Copied — clears in 45 s" + color: Theme.ok + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + // Copy is only ever Copy. It used to turn into + // Cancel while the row next to it was awaiting a + // confirmed Forget, which put the word "Cancel" + // where the user had just learnt to find "Copy". SettingsButton { - text: secretRow.confirming ? "Cancel" : "Copy" + anchors.verticalCenter: parent.verticalCenter + text: "Copy" enabled: !Keyring.working - onClicked: { - if (secretRow.confirming) { - root.confirmingPath = ""; - return; - } - Keyring.copy(secretRow.itemPath); - } + onClicked: 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" + anchors.verticalCenter: parent.verticalCenter + text: secretRow.confirming ? "Forget it" : "Forget…" tone: secretRow.confirming ? "danger" : "normal" enabled: !Keyring.working onClicked: { if (!secretRow.confirming) { - root.confirmingPath = secretRow.itemPath; + root.confirmingItem = secretRow.itemPath; return; } - root.confirmingPath = ""; + root.confirmingItem = ""; Keyring.forget(secretRow.itemPath); } } @@ -251,101 +532,185 @@ SettingsPage { } } + // ── Traces ─────────────────────────────────────────────────────────────── + // + // Panama's own, now. Each row clears one thing, once, when asked -- there + // is no "clean up your machine" button here and no number coloured red, + // because none of this is a problem to be solved. It is a list of what is + // remembered, with a way to stop remembering it. + 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." + title: "Traces" + subtitle: "What this machine remembers about what you did. Each row clears itself, once, when you say so." - TextRow { - label: "Camera" - detail: PrivacyState.cameraActive - ? "In use by " + (PrivacyState.cameraApp || "an application") - : "Not in use" - value: PrivacyState.cameraActive ? "Active" : "Idle" + SettingRow { + id: recentsRow + + readonly property bool confirming: root.confirmingTrace === "recents" + + label: "Recent files" + detail: recentsRow.confirming + ? "This empties the list. Applications start filling it again as you open things." + : "The recents list GTK apps show" + + (Traces.measured + ? " · " + Traces.recentsEntries + " entr" + + (Traces.recentsEntries === 1 ? "y" : "ies") + : "") + controlWidth: 210 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !recentsRow.confirming && Traces.measured + text: Disks.formatBytes(Traces.recentsBytes) + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: recentsRow.confirming + text: "Clear it" + tone: "danger" + enabled: !Traces.busy + onClicked: { + root.confirmingTrace = ""; + Traces.clearRecents(); + } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: recentsRow.confirming ? "Keep" : "Clear…" + enabled: !Traces.busy + onClicked: root.confirmingTrace = recentsRow.confirming ? "" : "recents" + } + } } - TextRow { - label: "Microphone" - detail: PrivacyState.microphoneActive - ? "In use by " + (PrivacyState.microphoneApp || "an application") - : "Not in use" - value: PrivacyState.microphoneActive ? "Active" : "Idle" + SettingRow { + id: thumbnailsRow + + readonly property bool confirming: root.confirmingTrace === "thumbnails" + + label: "Thumbnails" + detail: thumbnailsRow.confirming + ? "This empties the cache. File managers rebuild each preview the next time they show it." + : "Previews of images you have seen · rebuilt as you browse" + controlWidth: 210 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !thumbnailsRow.confirming && Traces.measured + // "over" when the walk ran out of budget: the number is a + // floor then, not an answer, and saying so costs one word. + text: (Traces.thumbnailsComplete ? "" : "over ") + + Disks.formatBytes(Traces.thumbnailsBytes) + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: thumbnailsRow.confirming + text: "Clear it" + tone: "danger" + enabled: !Traces.busy + onClicked: { + root.confirmingTrace = ""; + Traces.clearThumbnails(); + } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: thumbnailsRow.confirming ? "Keep" : "Clear…" + enabled: !Traces.busy + onClicked: root.confirmingTrace = thumbnailsRow.confirming ? "" : "thumbnails" + } + } } + SettingRow { + id: trashRow + + readonly property bool confirming: root.confirmingTrace === "trash" + + visible: root.trashCleanable !== null + label: "Trash" + detail: trashRow.confirming + ? "Emptying is permanent — the files do not go anywhere else first." + : "Files you deleted — the same Trash that Storage cleans" + controlWidth: 210 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: !trashRow.confirming && Disks.cleanablesMeasured + text: Disks.formatBytes(Number(root.trashCleanable?.bytes ?? 0)) + color: Theme.fgDim + font.family: Theme.fontFamily + font.features: Theme.tabularFigures + font.pixelSize: Theme.fontSize + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: trashRow.confirming + text: "Empty it" + tone: "danger" + enabled: Disks.cleaningId === "" + onClicked: { + root.confirmingTrace = ""; + Disks.clean("trash"); + } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: trashRow.confirming ? "Keep" : "Empty…" + enabled: Disks.cleaningId === "" + onClicked: root.confirmingTrace = trashRow.confirming ? "" : "trash" + } + } + } + + // A pointer, not a control: the history belongs to Vicinae, which owns + // the database Super+V browses. Offering a Clear here would mean + // reaching into another program's storage behind its back. TextRow { - label: "Screen sharing" - detail: PrivacyState.screenSharingActive - ? "Being shared by " + (PrivacyState.screenSharingApp || "an application") - : "Not being shared" - value: PrivacyState.screenSharingActive ? "Active" : "Idle" + label: "Clipboard history" + detail: "Kept by Vicinae, which owns the database Super+V browses. Panama reads that history and does not clear it." + value: "" + divider: Traces.lastError !== "" + } + + SettingRow { + visible: Traces.lastError !== "" + label: "That did not work" + detail: Traces.lastError divider: false } } - SettingsCard { - title: "Application permissions" - // The limit is stated here rather than left to be discovered. Saying - // "your camera is protected" when a native binary can open it - // directly would be a claim this page cannot back up. - subtitle: Permissions.available - ? "Applications that asked through the desktop portal. Programs installed outside it can still reach these devices directly." - : (Permissions.lastError || "The desktop portal's permission store is not running.") - - Repeater { - model: Permissions.devices - - delegate: Column { - id: deviceBlock - - required property var modelData - readonly property var applications: deviceBlock.modelData.applications ?? [] - - width: parent.width - - TextRow { - width: parent.width - visible: deviceBlock.applications.length === 0 - label: String(deviceBlock.modelData.label ?? "") - detail: "No application has asked for this." - value: "" - } - - Repeater { - model: deviceBlock.applications - - delegate: SettingRow { - required property var modelData - width: parent.width - label: String(modelData.app ?? "") - detail: String(deviceBlock.modelData.label ?? "") - controlWidth: 150 - - Row { - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - spacing: 9 - - SettingsButton { - anchors.verticalCenter: parent.verticalCenter - text: "Ask again" - enabled: !Permissions.busy - onClicked: Permissions.forget( - String(deviceBlock.modelData.id), String(modelData.app)) - } - - SettingsToggle { - anchors.verticalCenter: parent.verticalCenter - checked: modelData.allowed === true - onToggled: value => Permissions.setAllowed( - String(deviceBlock.modelData.id), String(modelData.app), value) - } - } - } - } - } - } - } + // ── Device security ────────────────────────────────────────────────────── SettingsCard { title: "Device security" @@ -360,26 +725,44 @@ SettingsPage { 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 } } + + ActionRow { + label: "Check again" + detail: "Re-reads the firmware, the kernel and the filesystem" + action: "Refresh" + divider: false + onTriggered: DeviceSecurity.refresh() + } } + // ── Elsewhere ──────────────────────────────────────────────────────────── + // The screen-lock timings used to be duplicated onto this page, which meant + // two sliders writing one preference and no way to tell which one you had + // last touched. They live with the idle rules they belong to; this points. + 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." + title: "Elsewhere" + subtitle: "Two neighbours of this page, kept where the rest of their settings are." ActionRow { - label: "File history & trash" - detail: "Opens GNOME Settings, which owns these" - action: "Open privacy" + label: "Screen lock timing" + detail: "Lives with the idle rules it belongs to" + action: "Open Power & Lock" + onTriggered: ShellState.openSettings("power") + } + + ActionRow { + label: "Per-app notifications" + detail: "Which apps may notify, and how much they may show on a locked screen" + action: "Open Notifications" divider: false - onTriggered: SystemSettings.openGnomePanel("privacy") + onTriggered: ShellState.openSettings("notifications") } } } diff --git a/config/dot/quickshell/modules/settings/README.md b/config/dot/quickshell/modules/settings/README.md index 9c76d34..a8a7b80 100644 --- a/config/dot/quickshell/modules/settings/README.md +++ b/config/dot/quickshell/modules/settings/README.md @@ -295,13 +295,14 @@ the owner instead of duplicating it. | `cursorInactiveTimeout` | Mouse | Accessibility | Pointer visibility is configured with pointer behavior but affects motor and visual access. | | `cursorSize` | Accessibility | Mouse | Large cursors are an accessibility adaptation that users also look for beside pointer controls. | | `inactiveOpacity` | Appearance | Accessibility | Window translucency is an appearance choice with a direct readability impact. | -| `lockMinutes` | Power | Privacy | Idle timing owns the mechanism; privacy owns the expectation that the unattended desktop locks. | -| `lockOnSleep` | Power | Privacy | Suspend owns the transition; privacy owns whether waking requires authentication. | Lock-screen visuals belong only to **Appearance**: background source, blur, clock, date, user name, and password-field presentation. **Power** owns when -the session locks, while **Privacy** keeps only the established timing mirrors -above. Visual controls must not be copied onto either page. +the session locks — the timings and the suspend transition both, with no mirror +anywhere. **Privacy** carried a second Screen-lock card until it was removed: +two sliders writing one preference is not a convenience, it is a page where the +number you are reading may not be the one you last set. Privacy points at Power +& Lock instead. Visual controls must not be copied onto either page. **Displays** is the sole owner of mode, scale, rotation, arrangement, and primary role. Those values form one safety transaction: every connected output diff --git a/config/dot/quickshell/modules/settings/SectionLabel.qml b/config/dot/quickshell/modules/settings/SectionLabel.qml new file mode 100644 index 0000000..6f6813a --- /dev/null +++ b/config/dot/quickshell/modules/settings/SectionLabel.qml @@ -0,0 +1,48 @@ +// A small uppercase heading inside a card, with an optional count beside it. +// +// SectionLabel { text: "Camera"; count: "2 apps have asked" } +// +// A card whose rows come from four different permission tables needs to say +// which table a row belongs to, and a second SettingsCard per table would give +// four headers, four subtitles and four borders to one subject. This is the +// lighter divider: it groups rows without pretending they are separate topics. + +import QtQuick +import qs.config + +Item { + id: root + + property string text: "" + property string count: "" + + width: parent ? parent.width : 620 + implicitHeight: 30 + + Row { + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: parent.bottom + anchors.bottomMargin: 4 + spacing: 8 + + Text { + text: root.text + color: Theme.fgMuted + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + font.weight: Font.DemiBold + font.capitalization: Font.AllUppercase + font.letterSpacing: 0.7 + } + + Text { + visible: root.count !== "" + text: root.count + color: Theme.alpha(Theme.fgMuted, 0.75) + font.family: Theme.fontFamily + font.pixelSize: Math.max(9, Theme.fontSizeSmall - 1) + font.letterSpacing: 0.7 + } + } +} diff --git a/config/dot/quickshell/modules/settings/SshKeysPage.qml b/config/dot/quickshell/modules/settings/SshKeysPage.qml index ddcbb9c..f40e1ae 100644 --- a/config/dot/quickshell/modules/settings/SshKeysPage.qml +++ b/config/dot/quickshell/modules/settings/SshKeysPage.qml @@ -1,12 +1,16 @@ // SSH keys, and what this machine can reach with them. // -// Read-heavy on purpose. The genuinely useful things a person wants from a page -// like this are "which key is this", "is the agent holding it", "copy the public -// half", and "forget a host whose key changed" -- and all four are safe. What is -// not here is generating a key, because a passphrase cannot be collected and -// handed to ssh-keygen without putting it somewhere it should not be, and a -// page offering to make an unencrypted key instead would be a downgrade -// disguised as a feature. +// Private keys are never read here. What the page knows about one is what +// ssh-keygen will say about it from the outside -- its type, its fingerprint, +// its comment, whether it has a passphrase, and what its file mode is. +// +// Generating a key used to be missing on the grounds that a passphrase could +// not be collected safely. The grounds were half right: it cannot go in argv, +// which /proc publishes to every process on the machine, and it cannot go in a +// temp file. It CAN go down a pipe. The helper reads it from stdin and feeds it +// to ssh-keygen over a pty, so it exists in two processes' memory and nowhere +// else -- which is a better place for it than the alternative this page used to +// leave people with, an unencrypted key made by hand at a terminal. import Quickshell import QtQuick @@ -18,29 +22,82 @@ SettingsPage { objectName: "ssh-keys" title: "SSH Keys" - lede: "The keys this machine signs in with, and the hosts it has met." + lede: "The keys this machine signs in with. Private keys are never read — only their public halves and their locks." property string confirmingForget: "" - Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh() + // ── The generate form ─────────────────────────────────────────────────── + property bool showingGenerator: false + property string newName: "" + property string newComment: "" + // Held only while the form is open, and emptied the moment it closes or + // succeeds. SecretFieldRow exists for exactly this: PasswordRow's field is + // private, so a form that closed left the typed passphrase sitting in a + // hidden TextInput for the rest of the session. + property string passphrase: "" + property string passphraseAgain: "" - TextRow { - visible: SshKeys.lastError !== "" - label: "That did not work" - detail: SshKeys.lastError - value: "" - divider: false + // The helper's own rule, mirrored so the button can be dark before the + // round trip rather than after it. If these two ever disagree the helper + // wins -- it is the one confined to ~/.ssh. + readonly property bool nameValid: /^[A-Za-z0-9_.-]{1,64}$/.test(root.newName) + readonly property bool nameTaken: SshKeys.keys.some( + key => String(key.name ?? "") === root.newName) + readonly property bool passphrasesMatch: + root.passphrase !== "" && root.passphrase === root.passphraseAgain + readonly property bool canCreate: + root.nameValid && !root.nameTaken && root.passphrasesMatch && !SshKeys.busy + + readonly property var heldKeys: SshKeys.keys.filter(key => key.loaded === true) + + function resetForm(): void { + root.showingGenerator = false; + root.newName = ""; + root.newComment = ""; + root.passphrase = ""; + root.passphraseAgain = ""; + nameField.clear(); + commentField.clear(); + passphraseField.clear(); + confirmField.clear(); } - TextRow { + Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh() + + // The form closes itself when the key it was asked for turns up in the + // snapshot. A failure leaves it open with the name still typed, because the + // usual failure is a name already taken and retyping the rest would be a + // punishment for the helper's refusal. + Connections { + target: SshKeys + + function onKeysChanged(): void { + if (!root.showingGenerator || root.newName === "") + return; + if (SshKeys.keys.some(key => String(key.name ?? "") === root.newName)) + root.resetForm(); + } + } + + // ── What went wrong ───────────────────────────────────────────────────── + + SettingsCard { + visible: SshKeys.lastError !== "" + title: "That did not work" + subtitle: SshKeys.lastError + } + + SettingsCard { visible: SshKeys.scanned && !SshKeys.available - label: "No SSH directory" - detail: "Nothing has created ~/.ssh on this machine yet." - value: "" - divider: false + title: "No SSH directory" + subtitle: "Nothing has created ~/.ssh on this machine yet. Generating a key below is one way to." } // ── Keys readable by other people ─────────────────────────────────────── + // + // ssh refuses to use a key with these permissions, so it will never be + // offered and nothing will say why. This card used to only be able to point + // at the problem; it can fix it now. SettingsCard { visible: SshKeys.overexposed.length > 0 @@ -52,55 +109,45 @@ SettingsPage { Repeater { model: SshKeys.overexposed - delegate: TextRow { + delegate: SettingRow { + id: exposedRow + required property var modelData + required property int index + width: parent.width - label: String(modelData.name ?? "") - detail: "Mode " + String(modelData.mode ?? "") + " · should be 600" - value: "" + label: String(exposedRow.modelData.name ?? "") + detail: "Mode " + String(exposedRow.modelData.mode ?? "") + " · should be 600" + controlWidth: 150 + divider: exposedRow.index < SshKeys.overexposed.length - 1 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Fix permissions" + tone: "accent" + enabled: !SshKeys.busy + onClicked: SshKeys.fixPermissions(String(exposedRow.modelData.name ?? "")) + } } } } - // ── The agent ─────────────────────────────────────────────────────────── - - SettingsCard { - title: "Agent" - subtitle: SshKeys.agent?.available === true - ? (SshKeys.agent?.kind === "gnome-keyring" - ? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh." - : "An SSH agent is holding your keys for this session.") - : String(SshKeys.agent?.detail ?? "No SSH agent is running.") - - TextRow { - label: "Holding" - detail: SshKeys.agent?.available === true - ? String(SshKeys.agent?.socket ?? "") - : "Keys will be asked for on every connection" - value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s") - divider: SshKeys.agent?.kind === "gnome-keyring" - } - - // Said plainly because it is measurable and surprising: ssh-add -d - // reports success against this agent and the key is still offered a - // moment later, because it is read back off disk. - TextRow { - visible: SshKeys.agent?.kind === "gnome-keyring" - label: "Removing a key from this agent does not stick" - detail: "It lists every key in ~/.ssh, so one removed comes straight back. Move the file out of ~/.ssh to stop it being offered." - value: "" - divider: false - } - } - // ── Keys ──────────────────────────────────────────────────────────────── SettingsCard { title: SshKeys.keys.length === 1 ? "Your key" : "Your keys" subtitle: SshKeys.keys.length === 0 - ? "No keys in " + SshKeys.directory + ? "No keys in " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") : "Public halves are safe to share; the private half never leaves this machine." + TextRow { + visible: SshKeys.scanned && SshKeys.keys.length === 0 + label: "Nothing here yet" + detail: "A key you generate below shows up here, with its fingerprint and whether the agent is holding it." + value: "" + } + Repeater { model: SshKeys.keys @@ -110,16 +157,20 @@ SettingsPage { required property var modelData required property int index + readonly property bool copied: + SshKeys.copiedKey !== "" + && SshKeys.copiedKey === String(keyRow.modelData.publicPath ?? "") + label: String(keyRow.modelData.name ?? "") detail: String(keyRow.modelData.type ?? "") + " · " + String(keyRow.modelData.fingerprint ?? "") + (String(keyRow.modelData.comment ?? "") !== "" ? " · " + keyRow.modelData.comment : "") + (keyRow.modelData.encrypted === true - ? " · passphrase protected" + ? " · passphrase set" : (keyRow.modelData.encrypted === false ? " · no passphrase" : "")) - divider: keyRow.index < SshKeys.keys.length - 1 - controlWidth: 220 + divider: true + controlWidth: 290 Row { anchors.right: parent.right @@ -128,7 +179,16 @@ SettingsPage { Text { anchors.verticalCenter: parent.verticalCenter - visible: keyRow.modelData.loaded === true + visible: keyRow.copied + text: "Copied" + color: Theme.ok + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: keyRow.modelData.loaded === true && !keyRow.copied text: "In the agent" color: Theme.fgDim font.family: Theme.fontFamily @@ -152,14 +212,175 @@ SettingsPage { } } } + + ActionRow { + label: "Generate a key" + detail: "ed25519, with a passphrase — collected here and handed to ssh-keygen over a pty, never on a command line" + action: root.showingGenerator ? "Cancel" : "Generate…" + divider: root.showingGenerator + onTriggered: { + if (root.showingGenerator) { + root.resetForm(); + return; + } + root.showingGenerator = true; + } + } + + LiveFieldRow { + id: nameField + + visible: root.showingGenerator + label: "File name" + detail: root.newName === "" + ? "Letters, numbers, dot, dash and underscore. It is written into " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") + "." + : (!root.nameValid + ? "Only letters, numbers, dot, dash and underscore, up to 64 characters." + : (root.nameTaken + ? "A key by that name is already there, and it will not be written over." + : "Written as " + root.newName + " and " + root.newName + ".pub")) + invalid: root.newName !== "" && (!root.nameValid || root.nameTaken) + placeholder: "id_ed25519_forge" + text: root.newName + maximumLength: 64 + onEdited: value => root.newName = value + } + + LiveFieldRow { + id: commentField + + visible: root.showingGenerator + label: "Comment" + detail: "Written into the public half, so a server's authorized_keys says which key this is" + placeholder: "you@machine" + text: root.newComment + maximumLength: 128 + onEdited: value => root.newComment = value + } + + SecretFieldRow { + id: passphraseField + + visible: root.showingGenerator + label: "Passphrase" + detail: "Required. A key with no passphrase is a password file that anyone reading the disk can use." + placeholder: "Passphrase" + onChanged: value => root.passphrase = value + } + + SecretFieldRow { + id: confirmField + + visible: root.showingGenerator + label: "Confirm" + detail: root.passphraseAgain === "" + ? "Type it again" + : (root.passphrasesMatch ? "Matches" : "These two do not match yet.") + placeholder: "Passphrase again" + onChanged: value => root.passphraseAgain = value + } + + ActionRow { + visible: root.showingGenerator + label: "Create key" + detail: SshKeys.generating + ? "ssh-keygen is working. It takes a moment." + : (!root.nameValid + ? "Give the key a file name first." + : (root.nameTaken + ? "That name is taken." + : (root.passphrase === "" + ? "Choose a passphrase." + : (!root.passphrasesMatch + ? "The two passphrases do not match." + : "ed25519, written to " + (SshKeys.directory !== "" ? SshKeys.directory : "~/.ssh") + "/" + root.newName)))) + action: SshKeys.generating ? "Making the key…" : "Create key" + enabled: root.canCreate + divider: false + onTriggered: { + if (!root.canCreate) + return; + SshKeys.generate(root.newName, root.newComment, root.passphrase); + // Out of the fields immediately, whatever happens next. The + // name stays so a refused write can be retried without + // retyping everything. + root.passphrase = ""; + root.passphraseAgain = ""; + passphraseField.clear(); + confirmField.clear(); + } + } + } + + // ── The agent ─────────────────────────────────────────────────────────── + + SettingsCard { + title: "Agent" + subtitle: SshKeys.agent?.available === true + ? (SshKeys.agentKind === "gnome-keyring" + ? "The login keyring is holding your keys, and offers every key it finds in ~/.ssh." + : "An SSH agent is holding your keys for this session.") + : String(SshKeys.agent?.detail ?? "No SSH agent is running.") + + TextRow { + label: "Holding" + detail: SshKeys.agent?.available === true + ? String(SshKeys.agent?.socket ?? "") + : "Keys will be asked for on every connection" + value: SshKeys.loadedCount + " key" + (SshKeys.loadedCount === 1 ? "" : "s") + } + + TextRow { + visible: SshKeys.agent?.available === true && root.heldKeys.length === 0 + label: "Nothing loaded" + detail: "Add a key above and the agent will offer it without asking for its passphrase again." + value: "" + } + + Repeater { + model: root.heldKeys + + delegate: SettingRow { + id: heldRow + + required property var modelData + + width: parent.width + label: String(heldRow.modelData.name ?? "") + detail: String(heldRow.modelData.fingerprint ?? "") + controlWidth: 110 + + SettingsButton { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + text: "Remove" + enabled: !SshKeys.busy + onClicked: SshKeys.removeFromAgent(String(heldRow.modelData.path ?? "")) + } + } + } + + // Said plainly because it is measurable and surprising: ssh-add -d + // reports success against this agent and the key is still offered a + // moment later, because it is read back off disk. The Remove button + // above stays -- the helper refuses and says this, which is a better + // answer than a button that is not there. + TextRow { + visible: SshKeys.agent?.available === true && !SshKeys.durableRemoval + label: "Removing a key from this agent does not stick" + detail: "It lists every key in ~/.ssh, so one removed comes straight back at the next sign-in. That is the keyring agent's design, not a bug — move the file out of ~/.ssh to stop it being offered." + value: "" + divider: false + } } // ── Known hosts ───────────────────────────────────────────────────────── SettingsCard { - visible: SshKeys.hosts.length > 0 title: "Known hosts" - subtitle: "Machines this one has connected to before. Forgetting one means being asked to trust it again." + subtitle: SshKeys.hosts.length === 0 + ? "Nothing yet. A host is recorded the first time you accept its key." + : "Machines this one has connected to. Forget an entry when a server legitimately changed — ssh-keygen keeps a .old copy." Repeater { model: SshKeys.hosts @@ -168,11 +389,11 @@ SettingsPage { id: hostRow required property var modelData - required property int index readonly property bool confirming: root.confirmingForget === String(hostRow.modelData.host ?? "") + width: parent.width label: hostRow.modelData.hashed === true ? hostRow.modelData.count + " hashed entries" : String(hostRow.modelData.host ?? "") @@ -181,7 +402,6 @@ SettingsPage { : (hostRow.confirming ? "You will be asked to trust this host the next time you connect." : (hostRow.modelData.types ?? []).join(", ")) - divider: hostRow.index < SshKeys.hosts.length - 1 controlWidth: 190 Row { @@ -212,5 +432,14 @@ SettingsPage { } } } + + ActionRow { + label: "Check again" + detail: "Re-reads ~/.ssh and asks the agent what it is holding" + action: SshKeys.busy ? "Reading…" : "Refresh" + enabled: !SshKeys.busy + divider: false + onTriggered: SshKeys.refresh() + } } } diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 740bef3..f38277f 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -111,6 +111,8 @@ HomeLightTile 1.0 HomeLightTile.qml DictationPage 1.0 DictationPage.qml TextEntryRow 1.0 TextEntryRow.qml PrivacyPage 1.0 PrivacyPage.qml +PrivacyLiveTile 1.0 PrivacyLiveTile.qml +SectionLabel 1.0 SectionLabel.qml RegionPage 1.0 RegionPage.qml SearchPicker 1.0 SearchPicker.qml OnlineAccountsPage 1.0 OnlineAccountsPage.qml diff --git a/config/dot/quickshell/scripts/panama-permissions b/config/dot/quickshell/scripts/panama-permissions index 46b40be..0031b1e 100755 --- a/config/dot/quickshell/scripts/panama-permissions +++ b/config/dot/quickshell/scripts/panama-permissions @@ -1,21 +1,40 @@ #!/usr/bin/env python3 -"""Which applications may use the camera and microphone. +"""Which applications the desktop portal has recorded an answer for. Read from xdg-desktop-portal's permission store, which is where an application -that asks through the portal has its answer recorded. That is the whole of what -this can control, and the limit is worth stating plainly rather than implying a -protection that does not exist: a native binary opens /dev/video0 directly and -no desktop setting stands in its way. What this covers is Flatpaks and anything -else that goes through the portal -- which on this machine is most of what would -ever ask. +that asks through the portal has its answer written down. That is the whole of +what this can control, and the limit is worth stating plainly rather than +implying a protection that does not exist: a native binary opens /dev/video0 +directly and no desktop setting stands in its way. What this covers is Flatpaks +and anything else that goes through the portal -- which on this machine is most +of what would ever ask. -Devices with no recorded application are reported as empty rather than omitted, +Six subjects, mapped onto the store's own layout rather than onto a tidier one: + + camera, microphone entries of the store's `devices` table, keyed by the + device name. Plain yes/no, so they can be toggled. + background the `background` table's single `background` entry, also + plain yes/no. This is "may run when you closed it". + screencast its own table, and remote-desktop likewise -- but their + remote-desktop ids are opaque restore tokens, one per remembered + session, and the value beside each grant is a structured + GVariant describing which monitor or which input devices + were shared. Nothing here can rebuild one of those, so + these report which application holds grants and offer + only to drop them. There is deliberately no code path + that Sets them: a toggle that cannot be honoured is worse + than no toggle. + location listed only. geoclue is absent on this machine, so the + table is normally empty and the page omits the section + rather than showing an empty one. + +A table nothing has asked for is reported as an empty list rather than omitted, so the page can say "nothing has asked" instead of showing nothing at all. panama-permissions snapshot - panama-permissions set DEVICE APP_ID allow|deny - panama-permissions forget DEVICE APP_ID + panama-permissions set TABLE APP_ID true|false + panama-permissions forget TABLE APP_ID """ from __future__ import annotations @@ -24,24 +43,58 @@ import json import re import subprocess import sys - -TABLE = "devices" - -# The devices the portal arbitrates. Listed rather than discovered so a device -# nothing has asked for still appears, which is the difference between "no -# application uses your microphone" and a page that silently omits it. -DEVICES = ( - ("camera", "Camera"), - ("microphone", "Microphone"), - ("speakers", "Speakers"), -) +from dataclasses import dataclass APP_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$") -DEVICE_ID = re.compile(r"^[a-z]+$") +TABLE_ID = re.compile(r"^[a-z][a-z-]{0,31}$") + +# The store's own ids for a remembered session are opaque tokens, so they are +# never accepted from a caller -- only discovered by listing the table. +ENTRY_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$") ALLOWED = "yes" DENIED = "no" +SIMPLE = "simple" +STRUCTURED = "structured" +READONLY = "readonly" + + +@dataclass(frozen=True) +class Table: + """One subject on the page, and where the store keeps it. + + `portal` is the store's table name, which is not always the subject's name: + camera and microphone both live in `devices`. `entries` names the store ids + to look under, or None to discover them by listing the table -- which is the + only option for screencast and remote-desktop, whose ids are restore tokens. + `writable` is the id a Set writes to, and is None for everything this + refuses to write. + """ + + name: str + portal: str + entries: tuple[str, ...] | None + writable: str | None + kind: str + + +TABLES: tuple[Table, ...] = ( + Table("camera", "devices", ("camera",), "camera", SIMPLE), + Table("microphone", "devices", ("microphone",), "microphone", SIMPLE), + Table("screencast", "screencast", None, None, STRUCTURED), + Table("remote-desktop", "remote-desktop", None, None, STRUCTURED), + Table("background", "background", None, "background", SIMPLE), + Table("location", "location", None, None, READONLY), +) + +BY_NAME = {table.name: table for table in TABLES} + +# Named once, here, so the refusal below reads as a list membership rather than +# as a chain of conditions someone could later add an exception to. +SIMPLE_TABLES = tuple(table.name for table in TABLES if table.kind == SIMPLE) +REVOKE_ONLY_TABLES = tuple(table.name for table in TABLES if table.kind == STRUCTURED) + class BoundaryError(RuntimeError): """A user-visible validation or permission-store failure.""" @@ -71,7 +124,7 @@ def portal_call(method: str, signature: str, *arguments: str) -> dict | None: ]) if result.returncode != 0: detail = (result.stderr or "").strip() - # A device nothing has ever asked for has no row at all, and the store + # An entry nothing has ever asked for has no row at all, and the store # says so as "No entry for camera". Matched on the store's actual words # rather than a guess at them. lowered = detail.lower() @@ -89,44 +142,91 @@ def available() -> bool: return "org.freedesktop.impl.portal.PermissionStore" in (result.stdout or "") -def entries_for(device: str) -> list[dict]: - payload = portal_call("Lookup", "ss", TABLE, device) +def entry_ids(table: Table) -> list[str]: + """The store ids to look under for one subject. + + Fixed for the device entries, discovered for everything else. A discovered + id that does not look like an id is dropped rather than passed back into the + store. + """ + if table.entries is not None: + return list(table.entries) + payload = portal_call("List", "s", table.portal) if not payload: return [] data = payload.get("data") or [] - if not data or not isinstance(data[0], dict): - return [] + listed = data[0] if data and isinstance(data[0], list) else [] + return [str(value) for value in listed if ENTRY_ID.match(str(value))] - found = [] - for app_id, permissions in data[0].items(): - values = [str(value) for value in (permissions or [])] - found.append({ - "app": app_id, - # Anything that is not an explicit "yes" is treated as withheld: - # guessing generously about a camera is the wrong way to be wrong. - "allowed": ALLOWED in values, - "raw": ",".join(values), - }) - found.sort(key=lambda entry: entry["app"].casefold()) - return found + +def rows_for(table: Table) -> list[dict]: + """One row per application, folded across every entry in the table. + + screencast keeps a separate entry per remembered session, so an application + that has shared its screen four times appears four times in the store. The + page is answering "may this application share your screen", which is one + question, so the rows are folded by application and `grants` says how many + stored sessions are behind the row. + """ + folded: dict[str, dict] = {} + for entry in entry_ids(table): + payload = portal_call("Lookup", "ss", table.portal, entry) + if not payload: + continue + data = payload.get("data") or [] + recorded = data[0] if data and isinstance(data[0], dict) else {} + if not isinstance(recorded, dict): + continue + for app_id, permissions in recorded.items(): + values = [str(value) for value in (permissions or [])] + row = folded.setdefault(str(app_id), { + "app": str(app_id), + # Anything that is not an explicit "yes" is treated as withheld: + # guessing generously about a camera is the wrong way to be wrong. + "allowed": False, + "grants": 0, + "raw": "", + }) + row["grants"] += 1 + if ALLOWED in values: + row["allowed"] = True + if row["raw"] == "": + row["raw"] = ",".join(values) + + rows = list(folded.values()) + rows.sort(key=lambda row: row["app"].casefold()) + return rows def snapshot() -> dict: if not available(): return { "available": False, - "devices": [], + "tables": {table.name: [] for table in TABLES}, + "simpleTables": list(SIMPLE_TABLES), + "revokeOnlyTables": list(REVOKE_ONLY_TABLES), "error": "The desktop portal's permission store is not running.", } - devices = [] - for device_id, label in DEVICES: - devices.append({ - "id": device_id, - "label": label, - "applications": entries_for(device_id), - }) - return {"available": True, "devices": devices, "error": ""} + tables: dict[str, list[dict]] = {} + problems: list[str] = [] + for table in TABLES: + # One unreadable table must not take the other five down with it: a page + # that shows nothing because location is broken is worse than a page + # that shows five subjects and says location could not be read. + try: + tables[table.name] = rows_for(table) + except BoundaryError as error: + tables[table.name] = [] + problems.append(f"{table.name}: {error}") + + return { + "available": True, + "tables": tables, + "simpleTables": list(SIMPLE_TABLES), + "revokeOnlyTables": list(REVOKE_ONLY_TABLES), + "error": "; ".join(problems), + } def require(pattern: re.Pattern[str], value: str, message: str) -> str: @@ -135,22 +235,59 @@ def require(pattern: re.Pattern[str], value: str, message: str) -> str: return value -def set_permission(device: str, app: str, allowed: bool) -> None: - require(DEVICE_ID, device, "That is not a device.") +def named(name: str) -> Table: + require(TABLE_ID, name, "That is not a permission table.") + table = BY_NAME.get(name) + if table is None: + raise BoundaryError("That is not a permission table this manages.") + return table + + +def set_permission(name: str, app: str, allowed: bool) -> None: + """Record an answer. Only for the subjects whose value is a plain yes or no. + + screencast and remote-desktop are refused here and have no other route to a + Set anywhere in this file. Their stored value is a structured description of + a session -- which monitor, which input devices -- and writing a bare "yes" + over it would leave the store holding a grant the portal cannot restore. + Dropping the grant is the honest operation, so that is the only one offered. + """ + table = named(name) require(APP_ID, app, "That is not an application.") - if not any(device == known for known, _ in DEVICES): - raise BoundaryError("That is not a device this manages.") + if table.name not in SIMPLE_TABLES or table.writable is None: + if table.kind == STRUCTURED: + raise BoundaryError( + f"A {table.name} grant describes a whole session, so it can only " + "be revoked, not switched on and off.") + raise BoundaryError(f"{table.name} permissions are shown, not changed, here.") # Permissions are an array of strings, so busctl needs the element count # before the element -- "1 yes", not "yes". - portal_call("SetPermission", "sbssas", TABLE, "true", device, app, + portal_call("SetPermission", "sbssas", table.portal, "true", table.writable, app, "1", ALLOWED if allowed else DENIED) -def forget(device: str, app: str) -> None: - """Drop the recorded answer, so the application is asked again next time.""" - require(DEVICE_ID, device, "That is not a device.") +def forget(name: str, app: str) -> None: + """Drop the recorded answer, so the application is asked again next time. + + Every entry the application appears under, because one application can hold + several remembered screencast sessions and dropping one of them would leave + the row on the page looking unchanged. + """ + table = named(name) require(APP_ID, app, "That is not an application.") - portal_call("DeletePermission", "sss", TABLE, device, app) + dropped = 0 + for entry in entry_ids(table): + payload = portal_call("Lookup", "ss", table.portal, entry) + if not payload: + continue + data = payload.get("data") or [] + recorded = data[0] if data and isinstance(data[0], dict) else {} + if not isinstance(recorded, dict) or app not in recorded: + continue + portal_call("DeletePermission", "sss", table.portal, entry, app) + dropped += 1 + if dropped == 0: + raise BoundaryError("There was no recorded answer to forget.") def main(arguments: list[str]) -> int: @@ -160,20 +297,25 @@ def main(arguments: list[str]) -> int: return 0 if len(arguments) == 4 and arguments[0] == "set": - if arguments[3] not in ("allow", "deny"): - raise BoundaryError("That is not allow or deny.") - set_permission(arguments[1], arguments[2], arguments[3] == "allow") + if arguments[3] not in ("true", "false"): + raise BoundaryError("That is not true or false.") + set_permission(arguments[1], arguments[2], arguments[3] == "true") elif len(arguments) == 3 and arguments[0] == "forget": forget(arguments[1], arguments[2]) else: raise BoundaryError( - "Usage: panama-permissions snapshot | set DEVICE APP allow|deny | " - "forget DEVICE APP") + "Usage: panama-permissions snapshot | set TABLE APP true|false | " + "forget TABLE APP") except BoundaryError as error: try: state = snapshot() except BoundaryError: - state = {"available": False, "devices": []} + state = { + "available": False, + "tables": {table.name: [] for table in TABLES}, + "simpleTables": list(SIMPLE_TABLES), + "revokeOnlyTables": list(REVOKE_ONLY_TABLES), + } state["error"] = str(error) print(json.dumps(state, separators=(",", ":"))) return 0 diff --git a/config/dot/quickshell/scripts/panama-privacy b/config/dot/quickshell/scripts/panama-privacy new file mode 100755 index 0000000..f556720 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-privacy @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 + +"""What this desktop remembers about what you opened, and how to forget it. + +Two traces, both of them a normal and useful part of a desktop rather than a +problem to be alarmed about. This tool measures them and clears them on request. +It never clears anything on its own, never runs on a schedule, and reports sizes +without ever suggesting that a number is too big -- the software that tells you +your machine is dirty is trying to sell you something. + + traces what each trace currently costs, measured now. + clear-recents the recent-files list, replaced with an empty but valid + document. Not deleted: GTK recreates the file the moment + something opens a file anyway, and an empty valid file takes + effect in every running application immediately, where a + missing one is only noticed on the next write. + clear-thumbnails the contents of the thumbnail cache. The folder stays; only + what is inside it goes, and nothing is followed out of it. + +Trash is deliberately not here. It is measured and emptied by panama-disks, and +one trash implementation is the right number to have. + +Seams, for tests that must not touch a real home directory: + + PANAMA_PRIVACY_RECENTS the recent-files document + PANAMA_PRIVACY_THUMBNAILS the thumbnail cache folder + +Both still have to resolve to somewhere inside HOME, so a test points HOME at a +scratch directory rather than pointing these at one. + + panama-privacy traces + panama-privacy clear-recents + panama-privacy clear-thumbnails +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +import time +from pathlib import Path + +# Measuring a thumbnail cache means walking it, and a machine that has browsed a +# large picture library has a lot of it. Bounded so the page cannot hang; a walk +# that runs out of time reports what it had and says it is a floor. +MEASURE_TIMEOUT_SECONDS = 20.0 + +# The document GTK keeps the recent-files list in, and the cache every file +# manager and image viewer on this desktop shares. +RECENTS_RELATIVE = "recently-used.xbel" +THUMBNAILS_RELATIVE = "thumbnails" + +# What an emptied recent-files list looks like. Byte for byte the header GTK +# writes itself, so the file this leaves behind is one GTK would have written. +EMPTY_XBEL = ( + '\n' + '\n' + '\n' +) + +BOOKMARK = re.compile(rb" Path: + return Path(os.path.expanduser("~")).resolve(strict=False) + + +def data_home() -> Path: + configured = os.environ.get("XDG_DATA_HOME") or "" + if configured: + return Path(configured) + return Path(os.path.expanduser("~")) / ".local" / "share" + + +def cache_home() -> Path: + configured = os.environ.get("XDG_CACHE_HOME") or "" + if configured: + return Path(configured) + return Path(os.path.expanduser("~")) / ".cache" + + +def recents_path() -> Path: + override = os.environ.get("PANAMA_PRIVACY_RECENTS") or "" + return Path(override) if override else data_home() / RECENTS_RELATIVE + + +def thumbnails_path() -> Path: + override = os.environ.get("PANAMA_PRIVACY_THUMBNAILS") or "" + return Path(override) if override else cache_home() / THUMBNAILS_RELATIVE + + +def confined(target: Path, description: str) -> Path: + """A path this is allowed to write to, or a refusal. + + The same guard panama-disks uses on the cache folder, and for the same + reason: this function is the whole reason the buttons on the page are safe + to press. A symlink is refused outright rather than followed, and anything + that resolves to the home directory itself or to somewhere outside it is + refused -- so an XDG variable pointing somewhere alarming, or a cache folder + someone linked to /, cannot turn one click into a deleted system. + """ + if target.is_symlink(): + raise BoundaryError(f"{description} is a link, so it will not be touched.") + resolved = target.resolve(strict=False) + root = home() + if resolved == root or root not in resolved.parents: + raise BoundaryError(f"{description} is not inside your home folder.") + return resolved + + +def measure(path: Path, budget: float) -> tuple[int, float, bool]: + """Bytes used, what is left of the budget, and whether the walk finished.""" + if not path.exists(): + return 0, budget, True + if budget <= 1.0: + return 0, budget, False + started = time.monotonic() + try: + completed = subprocess.run(["du", "-sxb", str(path)], check=False, + capture_output=True, text=True, timeout=budget) + except (OSError, subprocess.TimeoutExpired): + # A walk that ran out of time has consumed the whole budget by + # definition; the caller stops rather than starting another. + return 0, 0.0, False + left = max(budget - (time.monotonic() - started), 0.0) + if completed.returncode != 0: + return 0, left, False + match = re.match(r"^(\d+)", completed.stdout) + return (int(match.group(1)) if match else 0), left, match is not None + + +def recent_entries(path: Path) -> int: + """How many files the recent list remembers. + + Counted by scanning for the opening tag rather than parsing the document: + the answer wanted is a count, and an XML parser here would build a tree of + every path the user has opened in order to throw it away again. + """ + try: + with path.open("rb") as document: + return sum(len(BOOKMARK.findall(chunk)) + for chunk in iter(lambda: document.read(65536), b"")) + except OSError: + return 0 + + +def traces() -> dict: + budget = MEASURE_TIMEOUT_SECONDS + + recents = recents_path() + recents_present = recents.is_file() + recents_bytes = recents.stat().st_size if recents_present else 0 + + thumbnails = thumbnails_path() + thumbnails_present = thumbnails.is_dir() + thumbnails_bytes, budget, complete = measure(thumbnails, budget) + + return { + "recents": { + "bytes": int(recents_bytes), + "entries": recent_entries(recents) if recents_present else 0, + "path": str(recents), + "present": recents_present, + }, + "thumbnails": { + "bytes": int(thumbnails_bytes), + "path": str(thumbnails), + "present": thumbnails_present, + # False when the walk ran out of time, which makes the byte count a + # floor rather than an answer. The page says so instead of quoting a + # number it cannot stand behind. + "measured": complete, + }, + "error": "", + } + + +def clear_recents() -> None: + """Replace the recent-files list with an empty one. + + Never unlinked. GTK holds the path open and recreates the document on its + next write, so deleting it buys nothing an empty document does not, and an + empty document is understood by everything reading the list right now. + """ + target = recents_path() + if not target.exists(): + # Nothing remembered is the state this was asked to produce. + return + if not target.is_file(): + raise BoundaryError("The recent-files list is not a file.") + resolved = confined(target, "The recent-files list") + try: + resolved.write_text(EMPTY_XBEL, encoding="utf-8") + except OSError as error: + raise BoundaryError("The recent-files list could not be emptied.") from error + + +def clear_thumbnails() -> None: + """Delete what is inside the thumbnail cache, never following a link out. + + A thumbnail an application still has open cannot always be removed, and that + is the normal case rather than a failure -- so a partial pass succeeds, and + the freshly measured size the caller gets back says how much is left. Only a + pass that removed nothing at all is reported as a failure. + """ + target = thumbnails_path() + if not target.exists(): + return + if not target.is_dir(): + raise BoundaryError("The thumbnail cache is not a folder.") + directory = confined(target, "The thumbnail cache") + + removed = 0 + failures = 0 + with os.scandir(directory) as entries: + for entry in entries: + try: + # is_symlink first: a symlinked directory must be unlinked, not + # walked, or this deletes whatever it points at. + if entry.is_symlink() or not entry.is_dir(follow_symlinks=False): + os.unlink(entry.path) + else: + # rmtree lstats as it goes and refuses to descend a symlink. + shutil.rmtree(entry.path, ignore_errors=False) + removed += 1 + except OSError: + failures += 1 + if failures and not removed: + raise BoundaryError("The thumbnail cache is in use and nothing could be removed.") + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["traces"]: + pass + elif arguments == ["clear-recents"]: + clear_recents() + elif arguments == ["clear-thumbnails"]: + clear_thumbnails() + else: + raise BoundaryError( + "Usage: panama-privacy traces | clear-recents | clear-thumbnails") + except BoundaryError as error: + try: + state = traces() + except OSError: + state = {"recents": {"bytes": 0, "entries": 0, "path": "", "present": False}, + "thumbnails": {"bytes": 0, "path": "", "present": False, "measured": False}} + state["error"] = str(error) + print(json.dumps(state, separators=(",", ":"))) + return 0 + + print(json.dumps(traces(), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/scripts/panama-ssh-keys b/config/dot/quickshell/scripts/panama-ssh-keys index 035265f..1b5cd4b 100755 --- a/config/dot/quickshell/scripts/panama-ssh-keys +++ b/config/dot/quickshell/scripts/panama-ssh-keys @@ -8,13 +8,21 @@ ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an unencrypted key and fails for an encrypted one, and either way the only thing it can print is public material. -No passphrase passes through this tool at all. Adding an encrypted key to the -agent lets ssh-add prompt through the system's own askpass, which is where that -belongs -- a settings page collecting a passphrase and handing it on would be a -worse place for it to live, and putting one in argv would publish it to every -process on the machine. +A passphrase enters this tool in exactly one place -- creating a key -- and it +enters on standard input, which no other process can read. From there it is +typed at ssh-keygen over a pseudo-terminal, the same way a person would type it, +because the two obvious alternatives are both worse: a passphrase in argv is +published to every process on the machine, and a passphrase in a temporary file +is written to disk. It is never logged, never echoed back, and never included in +an error message. + +Adding an existing encrypted key to the agent is different: no passphrase is +collected for that at all, because ssh-add prompts through the system's own +askpass, which is where that belongs. panama-ssh-keys snapshot + panama-ssh-keys generate NAME COMMENT (passphrase on stdin) + panama-ssh-keys fix-permissions NAME panama-ssh-keys agent-add PATH | agent-remove PATH panama-ssh-keys forget-host HOST """ @@ -23,9 +31,14 @@ from __future__ import annotations import json import os +import pty import re +import select +import shutil +import signal import subprocess import sys +import time from pathlib import Path SSH_DIR = Path.home() / ".ssh" @@ -34,6 +47,23 @@ KNOWN_HOSTS = SSH_DIR / "known_hosts" # A host as it may appear in known_hosts, including [host]:port forms. HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$") +# A key file name, and nothing that could be a path. No slash is in the class, +# so a name cannot describe another directory at all -- the resolve-and-compare +# below is the second lock on the same door rather than the only one. +KEY_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,64}$") + +# A key comment. Free text, but nothing that could break out of a terminal line +# or be mistaken for one of ssh-keygen's own prompts. +KEY_COMMENT = re.compile(r"^[^\x00-\x1f\x7f]{0,128}$") + +# ssh-keygen's own floor. Checked here so the refusal arrives before a terminal +# is opened, rather than as a re-prompt nobody is there to answer. +MINIMUM_PASSPHRASE = 5 + +# Generating an ed25519 key takes milliseconds. The budget is this large only so +# that a machine starved of entropy fails with a message rather than a hang. +KEYGEN_TIMEOUT_SECONDS = 120.0 + # gnome-keyring's agent, which is what runs on this desktop. Only used when the # environment has not already named one, so an ssh-agent started by hand wins. KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh" @@ -253,6 +283,217 @@ def agent_remove(path: str) -> None: raise BoundaryError(detail[-1] if detail else "That key could not be removed.") +def resolve_new_key(name: str) -> Path: + """Where a key by this name would go, or a refusal. + + Refuses anything that already exists -- both halves, because a stray .pub + beside no private key still means ssh-keygen would be asked to overwrite, + and this tool does not overwrite keys. Losing a private key is not + recoverable and a settings page is the wrong place to learn that. + """ + if not KEY_NAME.match(name or "") or name in (".", ".."): + raise BoundaryError( + "A key name can use letters, numbers, dots, dashes and underscores.") + if name.endswith(".pub"): + raise BoundaryError("Name the key itself, not its public half.") + + try: + SSH_DIR.mkdir(mode=0o700, parents=True, exist_ok=True) + directory = SSH_DIR.resolve(strict=True) + except OSError as error: + raise BoundaryError("The SSH directory could not be opened.") from error + if not directory.is_dir(): + raise BoundaryError("The SSH directory is not a directory.") + + target = directory / name + if target.parent.resolve(strict=True) != directory: + raise BoundaryError("That key is not in the SSH directory.") + + public = Path(str(target) + ".pub") + for candidate in (target, public): + if candidate.exists() or candidate.is_symlink(): + raise BoundaryError(f"{candidate.name} already exists, so nothing was written.") + return target + + +def terminal_environment() -> dict: + """The environment ssh-keygen must run in to ask its question at the terminal. + + This desktop sets SSH_ASKPASS_REQUIRE=prefer, which makes ssh-keygen open a + graphical passphrase dialog even when it has a perfectly good terminal in + front of it -- so the first version of this hung, waiting for a prompt that + had been drawn on somebody's screen instead. The terminal is supplied + deliberately here, so the askpass route is switched off just as deliberately. + + LC_ALL is pinned so the prompts read below are the ones OpenSSH ships. + """ + environment = dict(os.environ) + environment["SSH_ASKPASS_REQUIRE"] = "never" + environment["LC_ALL"] = "C" + for name in ("SSH_ASKPASS", "DISPLAY", "WAYLAND_DISPLAY"): + environment.pop(name, None) + return environment + + +def type_at_keygen(command: list[str], passphrase: str) -> None: + """Run ssh-keygen on a pseudo-terminal and answer its prompts. + + ssh-keygen reads a passphrase through readpassphrase(), which opens + /dev/tty: a pipe on standard input is not read at all, which is why this + needs a terminal rather than a simpler subprocess call. The passphrase is + written to the terminal's master side, exactly as typing it would, and + ssh-keygen asks twice, so it is typed twice. + + Nothing about the passphrase is kept. It is not written to disk, does not + appear in the command, and is scrubbed out of anything reported back in case + a future ssh-keygen ever echoes it. + """ + try: + pid, master = pty.fork() + except OSError as error: + raise BoundaryError("A terminal could not be opened for ssh-keygen.") from error + + if pid == 0: + # The child. Nothing may return from here into the parent's code holding + # the parent's file descriptors, so a failed exec exits outright. + try: + os.execvpe(command[0], command, terminal_environment()) + except OSError: + pass + os._exit(127) + + typed = 0 + pending = "" + transcript = "" + problem = "" + deadline = time.monotonic() + KEYGEN_TIMEOUT_SECONDS + + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + problem = "ssh-keygen did not finish." + break + try: + ready, _, _ = select.select([master], [], [], min(remaining, 1.0)) + except OSError: + break + if not ready: + continue + try: + chunk = os.read(master, 4096) + except OSError: + # EIO on Linux: the child closed the terminal, which is how a pty + # reports end of output. + break + if not chunk: + break + + text = chunk.decode("utf-8", errors="replace") + pending += text + transcript += text + lowered = pending.lower() + + # Matched on ssh-keygen's whole prompt rather than one word of it: the + # passphrase prompt quotes the key's path back, and "overwrite" is a + # perfectly legal key name. + if "overwrite (y/n)" in lowered: + # Unreachable in practice -- an existing key is refused before this + # runs -- but answering anything other than "no" here would destroy + # a key, so it answers no and stops. + os.write(master, b"n\n") + problem = "That key already exists, so nothing was written." + break + if "passphrase is too short" in lowered: + problem = (f"ssh-keygen wants a passphrase of at least " + f"{MINIMUM_PASSPHRASE} characters.") + break + if "passphrases do not match" in lowered: + problem = "Those passphrases did not match." + break + if typed < 2 and "passphrase" in lowered and pending.rstrip().endswith(":"): + os.write(master, passphrase.encode("utf-8") + b"\n") + typed += 1 + pending = "" + + if problem: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + try: + os.close(master) + except OSError: + pass + try: + _, status = os.waitpid(pid, 0) + except OSError: + status = 0 + + if problem: + raise BoundaryError(problem) + if not (os.WIFEXITED(status) and os.WEXITSTATUS(status) == 0): + detail = scrubbed(transcript, passphrase) + last = [line.strip() for line in detail.splitlines() if line.strip()] + raise BoundaryError(last[-1] if last else "ssh-keygen could not create that key.") + + +def scrubbed(text: str, secret: str) -> str: + return text.replace(secret, "********") if secret else text + + +def generate(name: str, comment: str, passphrase: str, allow_empty: bool) -> None: + """Create an ed25519 key. + + ed25519 and nothing else: it is the key everything current accepts, the + choice between it and RSA is not one a settings page should make someone + make, and offering a size field for a curve that has one size would be + theatre. + """ + if not KEY_COMMENT.match(comment or ""): + raise BoundaryError("A comment cannot contain control characters.") + if "\n" in passphrase or "\r" in passphrase: + raise BoundaryError("A passphrase cannot contain a line break.") + if passphrase == "": + if not allow_empty: + raise BoundaryError( + "A passphrase is required. A key with none is usable by anyone " + "who reads the file.") + elif len(passphrase) < MINIMUM_PASSPHRASE: + raise BoundaryError( + f"ssh-keygen wants a passphrase of at least {MINIMUM_PASSPHRASE} characters.") + + if shutil.which("ssh-keygen") is None: + raise BoundaryError("ssh-keygen is not installed.") + + target = resolve_new_key(name) + command = ["ssh-keygen", "-t", "ed25519", "-f", str(target)] + if comment: + command += ["-C", comment] + type_at_keygen(command, passphrase) + + if not target.is_file() or not Path(str(target) + ".pub").is_file(): + raise BoundaryError("ssh-keygen finished but the key is not there.") + + +def fix_permissions(name: str) -> None: + """Make a private key readable only by its owner. + + ssh refuses to use a key other people can read, and says so in a message + most people meet for the first time at the worst moment. The path is + resolved and compared against the SSH directory first, so a name that is a + link to something elsewhere is refused rather than followed -- this changes + a file's mode, and that is not a thing to do to a file you have not checked. + """ + if not KEY_NAME.match(name or "") or name in (".", ".."): + raise BoundaryError("That is not a key name.") + key = resolve_key(str(SSH_DIR / name)) + try: + os.chmod(key, 0o600) + except OSError as error: + raise BoundaryError("That key's permissions could not be changed.") from error + + def forget_host(host: str) -> None: """Drop a host's keys from known_hosts. @@ -271,13 +512,42 @@ def forget_host(host: str) -> None: raise BoundaryError(detail[-1] if detail else "That host could not be removed.") +def read_passphrase() -> str: + """The passphrase, from standard input, and only from there. + + One trailing newline is dropped because the caller writes one to end the + line; anything else is taken literally, including spaces, because a + passphrase is allowed to end in one. + """ + try: + raw = sys.stdin.buffer.read().decode("utf-8") + except (OSError, UnicodeDecodeError) as error: + raise BoundaryError("The passphrase could not be read.") from error + if raw.endswith("\n"): + raw = raw[:-1] + if raw.endswith("\r"): + raw = raw[:-1] + return raw + + def main(arguments: list[str]) -> int: try: if arguments == ["snapshot"]: print(json.dumps(snapshot(), separators=(",", ":"))) return 0 - if len(arguments) == 2 and arguments[0] == "agent-add": + if arguments and arguments[0] == "generate": + # The flag exists so a machine-shaped caller can ask for a key with + # no passphrase deliberately. Panama's own page never passes it: it + # requires a passphrase and validates that both fields match. + allow_empty = "--no-passphrase" in arguments[1:] + rest = [value for value in arguments[1:] if value != "--no-passphrase"] + if len(rest) != 2: + raise BoundaryError("Usage: panama-ssh-keys generate NAME COMMENT") + generate(rest[0], rest[1], read_passphrase(), allow_empty) + elif len(arguments) == 2 and arguments[0] == "fix-permissions": + fix_permissions(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "agent-add": agent_add(arguments[1]) elif len(arguments) == 2 and arguments[0] == "agent-remove": agent_remove(arguments[1]) @@ -285,7 +555,8 @@ def main(arguments: list[str]) -> int: forget_host(arguments[1]) else: raise BoundaryError( - "Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | " + "Usage: panama-ssh-keys snapshot | generate NAME COMMENT | " + "fix-permissions NAME | agent-add PATH | agent-remove PATH | " "forget-host HOST") except BoundaryError as error: try: diff --git a/config/dot/quickshell/services/Permissions.qml b/config/dot/quickshell/services/Permissions.qml index 2737aa0..f34d3e2 100644 --- a/config/dot/quickshell/services/Permissions.qml +++ b/config/dot/quickshell/services/Permissions.qml @@ -1,12 +1,26 @@ pragma Singleton -// Which applications may use the camera and microphone. +// What the desktop portal has recorded, for the six subjects it arbitrates. // // Backed by xdg-desktop-portal's permission store, which records the answer an // application got when it asked through the portal. That is the whole of what // this controls, and the limit belongs on the page rather than in a comment: a // native binary opens /dev/video0 directly and no desktop setting stands in its // way. What this covers is Flatpaks and anything else going through the portal. +// +// Two shapes of permission, and the difference matters to the page: +// +// simple camera, microphone, background. A plain yes or no, so a switch +// can honour what it shows. +// revoke-only screencast, remote-desktop. Each grant is a remembered session +// -- which monitor, which input devices -- and nothing here can +// rebuild one, so these can be dropped and not switched. The +// helper has no code path that writes them at all; this service +// refuses before it gets there, and neither refusal is the only +// one. +// +// location is listed and nothing more. geoclue is absent on this machine, so +// the table is normally empty and the page leaves the section out entirely. import Quickshell import Quickshell.Io @@ -15,10 +29,16 @@ import QtQuick Singleton { id: root - readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-permissions" + readonly property string helperPath: Quickshell.env("PANAMA_PERMISSIONS_HELPER") + || Quickshell.shellDir + "/scripts/panama-permissions" property bool available: false - property var devices: [] + + // { camera: [{ app, allowed, grants, raw }], microphone: [...], ... } + // Every table is present even when empty, so the page can say "nothing has + // asked" rather than quietly leaving a subject out. + property var tables: ({}) + property bool scanned: false property string lastError: "" @@ -26,13 +46,47 @@ Singleton { // returns its cached value inside the handler that changes its dependency. readonly property bool busy: query.running || mutation.running - // Devices something has actually asked for. A device nothing has asked for - // is still reported, so the page can say so rather than omit it. - readonly property var recorded: root.devices.filter( - device => (device.applications ?? []).length > 0) + // The tables whose value is a plain yes or no, and the ones that can only be + // revoked. Read from the helper so the two never drift apart, with the + // helper's own answer as the fallback before the first read lands. + property var simpleTables: ["camera", "microphone", "background"] + property var revokeOnlyTables: ["screencast", "remote-desktop"] - readonly property int grantedCount: root.devices.reduce( - (total, device) => total + (device.applications ?? []).filter(app => app.allowed).length, 0) + function rowsFor(table: string): var { + const rows = root.tables[table]; + return Array.isArray(rows) ? rows : []; + } + + function countFor(table: string): int { + return root.rowsFor(table).length; + } + + function isSimple(table: string): bool { + return (root.simpleTables ?? []).indexOf(table) >= 0; + } + + function isRevokeOnly(table: string): bool { + return (root.revokeOnlyTables ?? []).indexOf(table) >= 0; + } + + readonly property int grantedCount: { + let total = 0; + for (const name in root.tables) + total += root.rowsFor(name).filter(row => row.allowed === true).length; + return total; + } + + // The devices half of the store, in the shape the applications list has + // always read it in. Kept so that "this app has a privacy rule" keeps + // working there without that page having to learn about tables. + readonly property var devices: { + const known = root.tables; + const rows = name => Array.isArray(known[name]) ? known[name] : []; + return [ + { "id": "camera", "label": "Camera", "applications": rows("camera") }, + { "id": "microphone", "label": "Microphone", "applications": rows("microphone") }, + ]; + } function refresh(): void { if (query.running) @@ -45,7 +99,11 @@ Singleton { try { const parsed = JSON.parse(text); root.available = parsed.available === true; - root.devices = Array.isArray(parsed.devices) ? parsed.devices : []; + root.tables = (parsed.tables && typeof parsed.tables === "object") ? parsed.tables : ({}); + if (Array.isArray(parsed.simpleTables)) + root.simpleTables = parsed.simpleTables; + if (Array.isArray(parsed.revokeOnlyTables)) + root.revokeOnlyTables = parsed.revokeOnlyTables; root.lastError = String(parsed.error ?? ""); } catch (error) { root.lastError = "Could not read the portal's permissions."; @@ -62,14 +120,23 @@ Singleton { mutation.running = true; } - function setAllowed(device: string, app: string, allowed: bool): void { - root.run(["set", device, app, allowed ? "allow" : "deny"]); + // Only for the tables whose stored value is a plain yes or no. A screencast + // grant reaching this is a bug in the caller, and it is refused here rather + // than forwarded, so no switch can be wired to something the portal will not + // honour. + function setPermission(table: string, app: string, allowed: bool): void { + if (!root.isSimple(table)) { + root.lastError = "That permission describes a whole session, so it can only be revoked."; + return; + } + root.run(["set", table, app, allowed ? "true" : "false"]); } // Drops the recorded answer entirely, so the application is asked again the - // next time it wants the device. - function forget(device: string, app: string): void { - root.run(["forget", device, app]); + // next time it wants this. Works for every table, including the ones a + // switch cannot touch. + function revoke(table: string, app: string): void { + root.run(["forget", table, app]); } Component.onCompleted: root.refresh() diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index b036ee3..4649af8 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -130,6 +130,12 @@ Singleton { { label: "SSH agent", detail: "Which keys are held for this session", page: "ssh-keys" }, { label: "Known hosts", detail: "Machines this one has connected to before", page: "ssh-keys" }, { label: "Public key", detail: "Copy the half you paste into a server", page: "ssh-keys" }, + // The page makes keys now rather than only listing them, so the verbs + // people arrive with — make one, unload one, fix the mode ssh refuses — + // each have a name to be found by. + { label: "Generate an SSH key", detail: "Create an ed25519 key with a passphrase, without opening a terminal", page: "ssh-keys" }, + { label: "Fix key permissions", detail: "Make a private key readable only by you, which is what ssh insists on", page: "ssh-keys" }, + { label: "Remove from agent", detail: "Stop a key being offered for the rest of this session", page: "ssh-keys" }, { label: "Podman", detail: "Running containers, images and volumes", page: "containers" }, { label: "Container logs", detail: "Follow what a container is printing", page: "containers" }, { label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" }, @@ -176,6 +182,18 @@ Singleton { { label: "Screen sharing", detail: "Which applications may capture the screen", page: "privacy" }, { label: "File history and trash", detail: "What is remembered and when it is cleared", page: "privacy" }, { label: "Device security", detail: "Secure boot and firmware protections", page: "privacy" }, + // Privacy stopped handing file history to GNOME and stopped listing two + // devices where the portal arbitrates six tables, so the subjects it + // now actually owns are findable by their own names. "Application + // permissions" appears twice on purpose: the Applications page answers + // what a Flatpak's sandbox exposes, this one answers what the portal + // recorded, and they are different questions with the same name. + { label: "Background apps", detail: "Which applications may keep running after you close them", page: "privacy" }, + { label: "Screen sharing permission", detail: "Take back a screen-capture grant an application was given", page: "privacy" }, + { label: "Remote desktop permission", detail: "Take back a grant to control this desktop's pointer and keyboard", page: "privacy" }, + { label: "Clear recent files", detail: "Empty the list of documents this desktop remembers you opening", page: "privacy" }, + { label: "Thumbnails", detail: "The cached previews of your pictures and videos, and clearing them", page: "privacy" }, + { label: "Application permissions", detail: "What the desktop portal has recorded: camera, microphone, screen, background", page: "privacy" }, { label: "Language", detail: "The system language, applied to programs started afterwards", page: "region" }, { label: "Regional formats", detail: "How dates, times, and numbers are written", page: "region" }, { label: "Online accounts", detail: "Sign in to mail, calendar, and contacts", page: "accounts" }, diff --git a/config/dot/quickshell/services/SshKeys.qml b/config/dot/quickshell/services/SshKeys.qml index 11c66e2..20d5e7b 100644 --- a/config/dot/quickshell/services/SshKeys.qml +++ b/config/dot/quickshell/services/SshKeys.qml @@ -2,10 +2,16 @@ pragma Singleton // SSH keys, the agent holding them, and the hosts this machine has met. // -// Nothing here ever sees a private key or a passphrase. Adding an encrypted key -// makes ssh-add prompt through the system's own askpass, which is where a -// passphrase belongs -- a settings page collecting one and passing it along -// would be a worse place for it to live. +// Nothing here ever sees a private key. A passphrase crosses this service in +// exactly one place -- creating a key -- and it crosses without ever being +// stored: it is held only for as long as it takes to write it to the helper's +// standard input, which is a channel no other process can read, and cleared in +// the same handler. It is never put in a command, because argv is public to +// every process on the machine, and never written to a file. The helper types it +// at ssh-keygen over a terminal it opens for the purpose. +// +// Adding an EXISTING encrypted key is different and collects nothing: ssh-add +// prompts through the system's own askpass, which is where that belongs. import Quickshell import Quickshell.Io @@ -14,7 +20,8 @@ import QtQuick Singleton { id: root - readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-ssh-keys" + readonly property string helperPath: Quickshell.env("PANAMA_SSH_KEYS_HELPER") + || Quickshell.shellDir + "/scripts/panama-ssh-keys" property bool available: false property string directory: "" @@ -24,10 +31,31 @@ Singleton { property bool scanned: false property string lastError: "" - readonly property bool busy: query.running || mutation.running + // Held between the call and the moment the helper starts, and no longer. + // Never read back out, never logged, never part of a command line. + property string pendingPassphrase: "" + + readonly property bool busy: query.running || mutation.running || generator.running + + // Generating is its own state: it can take a moment, it is the one action + // here that creates something, and a page wants to say so specifically + // rather than greying out the whole card. + readonly property bool generating: generator.running readonly property int loadedCount: root.keys.filter(key => key.loaded === true).length + // Whether removing a key from this machine's agent actually sticks. + // gnome-keyring's agent enumerates whatever it finds in ~/.ssh, so a + // removal reports success and the key is back a second later. The helper + // measures this rather than assuming it, and refuses the removal with its + // reason -- which arrives here as lastError, in the helper's own words. + readonly property bool durableRemoval: root.agent?.durableRemoval === true + readonly property string agentKind: String(root.agent?.kind ?? "") + + // Set for a moment after a public key reaches the clipboard, so the page can + // say what happened without having to know how long a clipboard lasts. + property string copiedKey: "" + // Keys readable by anyone but their owner. ssh refuses to use these, so a // page that stayed quiet about it would leave someone wondering why a key // that plainly exists is never offered. @@ -69,6 +97,28 @@ Singleton { // is allowed a long time before it is considered stuck. function addToAgent(path: string): void { root.run(["agent-add", path]); } + // Unloading a key. On an agent where that does not stick the helper refuses + // and says why, and the page shows that sentence rather than a shorter one + // of its own -- the reason is the useful part. + function removeFromAgent(path: string): void { root.run(["agent-remove", path]); } + + // ed25519, always. The passphrase goes down the helper's standard input and + // nowhere else; see the note at the top of this file. + function generate(name: string, comment: string, passphrase: string): void { + if (generator.running) + return; + root.lastError = ""; + root.pendingPassphrase = passphrase; + generator.command = [root.helperPath, "generate", name, comment]; + generator.stdinEnabled = true; + generator.running = true; + } + + // 0600, so ssh will use the key at all. Takes the key's name rather than a + // path: the helper resolves it inside ~/.ssh and refuses anything that + // resolves elsewhere, which is not a check to hand to the caller. + function fixPermissions(name: string): void { root.run(["fix-permissions", name]); } + // The public half, onto the clipboard. Safe to copy by definition -- it is // the thing you paste into a server. The path arrives as $1 rather than // being spliced into shell source, so a name with a space or a quote in it @@ -76,6 +126,7 @@ Singleton { function copyPublicKey(publicPath: string): void { if (publicPath === "" || copier.running) return; + root.copiedKey = publicPath; copier.command = ["sh", "-c", 'exec wl-copy < "$1"', "qs-ssh-keys", publicPath]; copier.running = true; } @@ -83,7 +134,39 @@ Singleton { Component.onCompleted: root.refresh() - Process { id: copier } + Process { + id: copier + onExited: exitCode => { if (exitCode !== 0) root.copiedKey = ""; } + } + + // The clipboard notice is transient, and says so by disappearing. + Timer { + running: root.copiedKey !== "" + interval: 12000 + onTriggered: root.copiedKey = "" + } + + // Its own Process because it is the only one with anything on stdin, and + // because the passphrase handover has to happen in onStarted -- there is + // nothing to write to before then. + Process { + id: generator + stdinEnabled: true + onStarted: { + generator.write(root.pendingPassphrase + "\n"); + // Held for as long as it takes to hand over, and no longer. + root.pendingPassphrase = ""; + // Closing stdin is what tells the helper the passphrase is complete. + generator.stdinEnabled = false; + } + // The helper answers with the fresh snapshot plus whatever went wrong, + // so a created key lands on the page without a second read. + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + onExited: root.pendingPassphrase = "" + } Process { id: query diff --git a/config/dot/quickshell/services/Traces.qml b/config/dot/quickshell/services/Traces.qml new file mode 100644 index 0000000..4d98378 --- /dev/null +++ b/config/dot/quickshell/services/Traces.qml @@ -0,0 +1,114 @@ +pragma Singleton + +// What this desktop remembers about what you opened. +// +// Two traces -- the recent-files list and the thumbnail cache -- both of them a +// normal and useful part of a desktop rather than a problem. This measures them +// when asked and clears them when asked, and does neither on its own. +// +// Nothing here measures on startup. Walking a thumbnail cache costs real time +// on a machine that has browsed a large picture library, and a settings page +// that has not been opened has no business spending it. `measured` says whether +// there is an answer yet, so the card can show its own state honestly instead of +// showing a confident zero. +// +// Trash is deliberately absent. It is measured and emptied by Disks, and one +// trash implementation is the right number to have. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.env("PANAMA_PRIVACY_HELPER") + || Quickshell.shellDir + "/scripts/panama-privacy" + + // real rather than int: a thumbnail cache on a machine with a large picture + // library goes past what a 32-bit int holds, and a byte count that wrapped + // negative is worse than no byte count. + property real recentsBytes: 0 + property int recentsEntries: 0 + property string recentsPath: "" + property bool recentsPresent: false + + property real thumbnailsBytes: 0 + property string thumbnailsPath: "" + property bool thumbnailsPresent: false + + // False when the walk ran out of time, which makes the byte count a floor + // rather than an answer. + property bool thumbnailsComplete: true + + // Whether there is an answer at all yet. Distinct from thumbnailsComplete, + // which is about the quality of an answer that exists. + property bool measured: false + + property string lastError: "" + + readonly property bool busy: reader.running || mutation.running + + function measure(): void { + if (reader.running) + return; + reader.command = [root.helperPath, "traces"]; + reader.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + const recents = parsed.recents ?? ({}); + const thumbnails = parsed.thumbnails ?? ({}); + root.recentsBytes = Number(recents.bytes ?? 0); + root.recentsEntries = Number(recents.entries ?? 0); + root.recentsPath = String(recents.path ?? ""); + root.recentsPresent = recents.present === true; + root.thumbnailsBytes = Number(thumbnails.bytes ?? 0); + root.thumbnailsPath = String(thumbnails.path ?? ""); + root.thumbnailsPresent = thumbnails.present === true; + root.thumbnailsComplete = thumbnails.measured !== false; + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.lastError = "Could not read what the desktop has remembered."; + console.warn("Traces: could not parse helper output:", error); + } + root.measured = true; + } + + function run(verb: string): void { + if (mutation.running) + return; + root.lastError = ""; + mutation.command = [root.helperPath, verb]; + mutation.running = true; + } + + // The list is emptied, never deleted: GTK recreates the file the moment + // anything opens a file anyway, and an empty valid document takes effect in + // every running application immediately. + function clearRecents(): void { root.run("clear-recents"); } + + // The folder stays; what is inside it goes. The helper refuses to follow a + // link out of it. + function clearThumbnails(): void { root.run("clear-thumbnails"); } + + Process { + id: reader + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } + + // Both clears answer with a freshly measured snapshot, so the card updates + // without a second read. + Process { + id: mutation + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + stderr: StdioCollector { + onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim() + } + } +} diff --git a/config/local/share/vicinae/scripts/settings-privacy b/config/local/share/vicinae/scripts/settings-privacy index 49aa7dc..7265527 100755 --- a/config/local/share/vicinae/scripts/settings-privacy +++ b/config/local/share/vicinae/scripts/settings-privacy @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open Privacy in Settings. -# @vicinae.keywords ["settings", "saved passwords", "camera and microphone", "screen sharing", "file history and trash", "device security"] +# @vicinae.keywords ["settings", "saved passwords", "camera and microphone", "screen sharing", "file history and trash", "device security", "background apps", "screen sharing permission", "remote desktop permission", "clear recent files", "thumbnails", "application permissions"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page privacy diff --git a/config/local/share/vicinae/scripts/settings-ssh-keys b/config/local/share/vicinae/scripts/settings-ssh-keys index acad41c..7b78fdd 100755 --- a/config/local/share/vicinae/scripts/settings-ssh-keys +++ b/config/local/share/vicinae/scripts/settings-ssh-keys @@ -5,6 +5,6 @@ # @vicinae.mode silent # @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg # @vicinae.description Open SSH Keys in Settings. -# @vicinae.keywords ["settings", "ssh keys", "ssh agent", "known hosts", "public key"] +# @vicinae.keywords ["settings", "ssh keys", "ssh agent", "known hosts", "public key", "generate an ssh key", "fix key permissions", "remove from agent"] exec "$HOME/.config/quickshell/scripts/panama-action" settings-page ssh-keys diff --git a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md index be945f2..5b3628f 100644 --- a/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md +++ b/docs/superpowers/specs/2026-08-23-settings-redesign-test-backlog.md @@ -1113,3 +1113,192 @@ documented"). `settings-ownership-contract`, `search-routing-contract`), then the read-only system one (`user-accounts-contract`), then the harness ones (`settings-search-contract`), and `settings-pages-contract` last, as before. + +## Phase 12 (Privacy & Security) — append below + +Spec: `2026-08-24-privacy-security-redesign.md`. Privacy stopped being a page +that read three device rows and pointed at GNOME for everything else. The portal +model went from three hardcoded devices to six tables — camera, microphone, +screencast, remote-desktop, background, location — with screencast and +remote-desktop revoke-only, because their stored value is a structured GVariant +describing a whole session and a switch cannot rebuild one. Traces became +native: a new `panama-privacy` empties the recent-files list and the thumbnail +cache, and the trash row reuses Storage's existing cleanable rather than growing +a second implementation. SSH Keys learned to make a key, with the passphrase +typed at ssh-keygen over a pty. The duplicated Screen-lock card was deleted and +replaced with a pointer. + +Three agents edited the tree concurrently. Everything below was reconciled +against the landed files at the end of the phase rather than against the spec's +pinned shapes, and four needles moved during it: `speakers` left the permissions +model, the `set` verb became `true|false` rather than `allow|deny`, the +Privacy page's confirm state became `confirmingItem` (with `confirmingRevoke` +and `confirmingTrace` beside it), and the passphrase turned out to travel +through a helper of its own (`type_at_keygen`) rather than inside `generate`. +Each is pinned where it actually landed. + +### New contracts (1) + +`quickshell/privacy-traces-contract`. The README count line moves +**172 → 173**; `setup/readme-contract` was run and passes ("173 contracts, as +documented"). + +### Run and passing + +- **`quickshell/permissions-contract` — RUN END TO END, PASS.** Rewritten: the + old version set and cleared a camera permission on the REAL portal store + under a probe application id and put it back afterwards. It now runs under + `env -i` against a recording `busctl` first on `PATH` (asserted to resolve + inside the stub directory before anything that writes runs), with + `DBUS_SESSION_BUS_ADDRESS` pointed at a socket that does not exist. What it + pins: + - **No Set path for a structured table, three ways.** By import — the helper + is loaded as a module so the table lists are read at their real values + rather than pattern-matched, because they are derived from a registry and a + contract that understood one spelling of that would be pinning the spelling. + By AST — `SetPermission` is named exactly once in the file, inside one + function, which consults a named list containing `camera`; that list must + not contain `screencast` or `remote-desktop`, and the function must not name + them either. And at runtime — `set screencast …` is refused **and** the call + log gains no `SetPermission`, which is the assertion that survives a + refactor of both of the others. + - **Revoke reaches every remembered token.** The fixture store gives one + application two screencast sessions; the snapshot has to fold them into ONE + row carrying `grants: 2`, and `forget` has to issue two `DeletePermission` + calls. Dropping the first token and leaving the row looking unchanged is the + failure this catches. + - **A path-shaped entry id from the store is dropped, not fed back in.** The + stub's `List` answers with `"../../etc/passwd"` among the tokens; it must + not appear in any subsequent call. + - **Absence is still not failure**: the stub answers `No entry for microphone` + on stderr with a non-zero exit, and the snapshot must report an empty + microphone table with `error: ""`. + - **`speakers` is gone**, pinned as an absence: no portal backend arbitrates + speaker access, so the row was a permanent "nothing has asked" for a + question nobody asks, and listing it made the page look wider than it is. + - **`Permissions.devices` survives** as the camera/microphone compat view, and + something still reads it — ApplicationsPage does, and the symptom of losing + it there is an empty list rather than an error. + - Validated table and application ids, each checked twice: refused *with a + reason*, and having reached no call. +- **`quickshell/privacy-traces-contract` — RUN END TO END, PASS.** New, and + hermetic throughout: `env -i` with `HOME`, `XDG_DATA_HOME` and + `XDG_CACHE_HOME` inside a scratch tree, and nothing that deletes runs until + the helper has reported the fixture's own numbers back (9 × 33333 bytes of + thumbnails, two recent entries). What it pins: + - **Clearing recents empties the file, never unlinks it**, and what is left + parses as XML with `` as its root and no bookmarks. A truncation to + zero bytes fails: GTK reads a zero-length file as corrupt and stops + recording history, which looks identical to success. + - **Clearing thumbnails does not follow a link out.** The fixture plants both + traps — a symlink to a file that must survive and a symlinked directory that + must be unlinked rather than descended — and separately points a whole + scratch `~/.cache/thumbnails` at a directory outside the home, which has to + be refused in words. + - **The seam is not a way around the guard**: `PANAMA_PRIVACY_THUMBNAILS` + pointed outside the home is refused exactly as a symlink is, which is what + makes the rest of this file a test rather than a way of disabling the check. + - **One trash implementation**, said three ways: the page reaches + `Disks.clean`/`Disks.cleanables`, the helper's *code* (docstrings excluded by + AST, since they are allowed to say why) never mentions the trash, and + neither does `Traces.qml`. + - **No urgency language in the Traces card**, found by what the card is wired + to rather than by its title, and read only from its string literals. +- **`quickshell/ssh-keys-contract` — RUN END TO END, PASS.** Its read-only half + is unchanged and still runs against the real configuration. The new half runs + under `env -i` with `HOME` in a scratch tree and a recording `ssh-keygen` + first on `PATH`, which logs its own `/proc/$$/cmdline` and `/proc/$$/environ` + and then plays the two passphrase prompts. What it pins: + - **The passphrase never reaches argv or the environment**, asserted against + what the kernel would have shown any other process — and it DOES arrive down + the terminal, twice, or ssh-keygen would have stalled at the second prompt. + It is also absent from the JSON the page parses and from every file under + the scratch tree. + - **By AST, for every function that carries it**, not only `generate`: the pty + write lives in `type_at_keygen`, and a rule that looked only at the caller + would have missed the place the value actually goes. No list literal, no + dict literal, no `open`/`mkstemp`/`write_text`. + - **The environment ssh-keygen runs in**, read back out of `/proc`: + `SSH_ASKPASS_REQUIRE=never` (this desktop sets `prefer`, which draws a + graphical dialog and hangs the pty until the timeout — A found this the hard + way) and `LC_ALL=C`, since the prompts are matched by their words. + - **Overwrite is refused before ssh-keygen runs**, and the existing key is + byte-identical afterwards. + - **Ten bad key names**, each refused and each having reached no ssh-keygen, + with a file outside the SSH directory left untouched and the scratch + directory still holding exactly the two files it should. + - **An empty passphrase needs `--no-passphrase`**, the flag exists, and + nothing in the shell passes it. + - **`fix-permissions` restores 0600 and is confined**: five bad names refused, + and a 0644 file outside the directory still 0644. + - **Removal from the agent is reachable now** — helper verb, service function, + page call, all three — and `ssh-add` was never invoked by the hermetic half. +- **`quickshell/secrets-contract` — RUN END TO END, PASS.** Existing pins kept. + New: **the Copy button is only ever Copy.** The page used to run one + confirmation state for the whole card and label the neighbouring button + `confirming ? "Cancel" : "Copy"`, so arming a Forget replaced the word "Copy" + in the exact place the user had just learnt to find it — and the way out of a + confirmation was to press the button that copies. Pinned from the button + block: its label is the literal `"Copy"`, its handler does not read + `confirming`, and no button both copies and forgets. Also that the copy + confirmation is tied to the row it happened on + (`Keyring.copiedPath === …itemPath`) rather than being one card-wide flag. +- **`quickshell/gnome-handoff-contract` — RUN, PASS** (8 handoffs against 39 + pages). `privacy` joins `OWNED`, with the reason: the card that carried the + door was headed "Owned by Fedora" and explained that GNOME's file-history + switches would not take effect in a Hyprland session anyway. Plus the inverse, + said from the page's side — `PrivacyPage.qml` contains no `openGnomePanel` at + all — because the generic loop can only fail on a door that exists. +- **`quickshell/settings-ownership-contract` — RUN, PASS.** The + `lockMinutes`/`lockOnSleep` mirrors onto `privacy` are gone from the expected + table and from the README loop; `modules/settings/README.md` lost the two + mirror rows with them. That was the one mirror in the table nobody had asked + for: Privacy carried a whole second Screen-lock card, so one preference had + two sliders. +- **`setup/readme-contract` — RUN, PASS** ("173 contracts, as documented"). +- **Also run and passing, unchanged by this phase but touching the two rebuilt + pages**: `qmldir-registration-contract` (185 components, including B's new + `PrivacyLiveTile` and `SectionLabel`), `search-routing-contract` (144 routed + settings), `settings-nav-contract`, `settings-buttons-contract`, + `settings-docs-contract` (172 settings documented — the schema did not + change, so no docs regeneration was needed), `settings-hardcoded-values-contract`, + `settings-jump-contract`. + +### Deferred, and why + +- **`quickshell/lock-screen-settings-contract` — NOT RUN.** Its second half + daemonizes a Quickshell preview harness and counts `hyprlock` processes, which + is exactly the kind of thing that should not happen underneath a live desktop + session. Its first half was edited and is source-only: the "Screen lock" card + must not exist on Privacy, `lockMinutes`/`lockMinutesBattery`/`lockOnSleep` + must each be bound on Power and on Privacy nowhere, and Privacy must point at + Power & Lock rather than silently dropping the card. All five were verified by + hand against the landed `PrivacyPage.qml` and `PowerPage.qml`; the harness + half was not exercised. +- **`quickshell/settings-search-contract` — NOT RUN**: it starts a Quickshell + harness. The nine new entries were checked statically — parsed out of + `SettingsSearch.qml` and confirmed to route to `privacy` and `ssh-keys`, both + of which `settings-nav-contract` confirms are leaves. +- **`quickshell/settings-pages-contract` and `settings-write-sweep-contract` — + NOT RUN**: both load the QML. Neither page has been rendered by anything in + this phase. +- **`Traces.qml` is pinned but never loaded.** Its seam + (`PANAMA_PRIVACY_HELPER`) and its lack of a trash path are checked from the + source; no contract here constructs the singleton. +- **A deliberate duplicate label in search.** "Application permissions" now + appears twice — once routing to Applications (what a Flatpak's sandbox + exposes) and once to Privacy (what the portal recorded). They are different + questions that share a name, both cards are titled that, and the dedupe key + includes the page, so both survive. Worth a look on the rendered sidebar. +- **Seams pinned by name**: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`, + `PANAMA_SSH_KEYS_HELPER`, `PANAMA_PRIVACY_RECENTS`, + `PANAMA_PRIVACY_THUMBNAILS`. Both privacy seams re-confine against `HOME`, so + a hermetic run points `HOME` at a scratch tree rather than pointing the seams + out of it; `panama-ssh-keys` reads `Path.home()`, so `HOME` alone redirects it. +- Run order for this phase: the hermetic ones first (`permissions-contract`, + `privacy-traces-contract`, `ssh-keys-contract`), then the source-only ones + (`gnome-handoff-contract`, `settings-ownership-contract`, + `setup/readme-contract`, `search-routing-contract`), then the read-only system + one (`secrets-contract`, which lists the live keyring), then the harness ones + (`settings-search-contract`, `lock-screen-settings-contract`), and + `settings-pages-contract` last, as before. diff --git a/docs/superpowers/specs/2026-08-24-privacy-security-redesign.md b/docs/superpowers/specs/2026-08-24-privacy-security-redesign.md new file mode 100644 index 0000000..55d0205 --- /dev/null +++ b/docs/superpowers/specs/2026-08-24-privacy-security-redesign.md @@ -0,0 +1,157 @@ +# Privacy & Security redesign — all eight tables + +Approved mock: `home-mocks/privacy.html` (scratchpad, :8642). Spec wins over mock on conflict. + +## Goals + +1. **One subject for eyes and ears**: live PipeWire tiles + portal permission rows unified — + camera, microphone, screen sharing (screencast table), remote desktop — plus the background + table (7 live rows) as its own card. The sandboxing honesty note stays. +2. **Traces clear natively**: recent files, thumbnails, trash (reusing Disks) — the + `openGnomePanel("privacy")` punt dies and privacy becomes Panama-OWNED. +3. **Secrets polish**: per-action confirm state (Copy stops doubling as Cancel), copy feedback, + friendly item descriptions. +4. **SSH Keys complete**: generation with a pty-fed passphrase, agent remove wired (with the + keyring-agent honesty as prose), Fix-permissions action, copy feedback, visible empty states, + refresh rows on both pages. +5. The duplicated Screen-lock card is replaced by an "Elsewhere" pointer card (Power & Lock, + Notifications). + +Non-goals: location (geoclue absent — render the section only if the table has entries), +notifications portal table (NotificationsPage owns the subject), documents-portal grants, +telemetry/USB-protection gsettings (inert without their GNOME daemons — the trap the page's own +header warns about), known-hosts hashed-entry removal, keyring collection management. + +## Helpers (pinned) + +**`scripts/panama-permissions`** — generalized from 3 hardcoded devices to tables: +- `snapshot` → `{ tables: { camera: [...], microphone: [...], screencast: [...], + "remote-desktop": [...], background: [...], location: [...] }, available, error }`; each row + `{ app, allowed }`. camera/microphone stay the `devices` table's simple yes/no; screencast / + remote-desktop values are structured GVariants — those rows report presence only and support + **revoke only** (`DeletePermission`), never Set (pinned: the page must not offer a toggle it + cannot honor). background is plain yes/no (toggleable). location read-only listing. +- `set TABLE APP true|false` (only for simple-valued tables: camera, microphone, background), + `forget TABLE APP` (all tables). Table and app ids validated. + +**NEW `scripts/panama-privacy`** — traces: +- `traces` → `{ recents: { bytes, entries }, thumbnails: { bytes }, error }` (du-based, + budgeted). +- `clear-recents` — truncate `~/.local/share/recently-used.xbel` to an empty valid xbel + document (not delete — GTK recreates but an empty valid file takes effect instantly). +- `clear-thumbnails` — guarded removal inside `~/.cache/thumbnails` only (resolve, refuse + symlink escape — the panama-disks guard pattern). +- Trash is NOT here — the page reuses `Disks.clean("trash")` / its cleanable byte count. + +**`scripts/panama-ssh-keys`**: +- `generate NAME COMMENT` — ed25519 only; NAME validated `^[A-Za-z0-9_.-]{1,64}$`, confined to + `~/.ssh`, refuses overwrite; **passphrase read from stdin by the helper, handed to ssh-keygen + over a pty — never argv, never a temp file** (empty passphrase allowed but the UI requires + non-empty; helper accepts empty only with an explicit `--no-passphrase` flag the UI never + passes). Returns the fresh snapshot. +- `fix-permissions NAME` — chmod 600, same confinement, returns fresh snapshot. +- `agent-remove` exists; unchanged. + +## Services (A) + +- **Permissions.qml**: `tables` model per the snapshot; `setPermission(table, app, allowed)`, + `revoke(table, app)`; per-table helpers the UI needs (`simpleTables`, `revokeOnlyTables`). +- **NEW `Traces.qml`**: `recentsBytes/entries`, `thumbnailsBytes`, `measured`, `measure()`, + `clearRecents()`, `clearThumbnails()`, `busy/lastError`; seam `PANAMA_PRIVACY_HELPER`. +- **SshKeys.qml**: `generate(name, comment, passphrase)` (passphrase via Process stdin), + `removeFromAgent(path)` (wires the existing verb; surfaces the durableRemoval refusal + message), `fixPermissions(name)`, copy feedback (`copiedKey` cleared by a timer, the Keyring + `copiedPath` pattern), `refresh()` exposed for a page row. +- **Keyring.qml**: no changes expected; the confirm-state fix is page-side. + +## UI (B) + +**PrivacyPage.qml** rebuilt (gains `objectName: "privacy"`): unified Camera/mic/screen card +(live tiles from PrivacyState with the in-use warn tone; grouped sections per table with +uppercase labels + counts; camera/mic rows Ask-again + toggle; screencast/remote-desktop rows +detail-explained with two-stage Revoke; empty-section honesty lines; the "not sandboxed and +never ask" note); Run in the background card (all rows, toggles); Saved passwords & secrets +(unlock state row + lazy Saved-items with per-row copy feedback and per-row confirm state — +separate `confirmingItem` from the copy path); Traces card (recents/thumbnails from Traces, +trash from Disks cleanables with "the same Trash Storage cleans" detail, clipboard-history +pointer row); Device security card + "Check again" refresh row; Elsewhere card (Power & Lock, +Notifications pointers). Location section only when the table is non-empty. The Screen-lock +card is DELETED. No `openGnomePanel` calls remain. + +**SshKeysPage.qml** rebuilt: error rows into cards; Your keys card (rows + copy feedback + +generate flow — name/comment LiveFieldRows, two SecretFieldRows with match validation, +Create key disabled until valid + matching); Agent card (held keys with Remove, the +design-not-a-bug prose when gnome-keyring); over-permissive warning card gains Fix +permissions; Known hosts card always visible with an empty state; a refresh row. + +## Search & docs (C) + +New entries: Background apps, Screen sharing permission, Remote desktop permission, Clear +recent files, Thumbnails, Application permissions (privacy) → privacy; Generate an SSH key, +Fix key permissions, Remove from agent → ssh-keys. Docs regen only if schema changes (none — +verify). + +## Contracts (C — write; hermetic runs only) + +- `permissions-contract`: tables model; the revoke-only rule for structured tables (no Set path + for screencast/remote-desktop anywhere — AST pin); the page never claims more than the portal + enforces (kept); validated table/app ids. +- NEW `privacy-traces-contract`: hermetic — clear-recents writes a valid empty xbel (never + deletes), clear-thumbnails guarded (symlink escape refused), no urgency language in the + Traces card copy (the anti-racket stance), trash reuses Disks (no second trash + implementation — grep pin). +- `secrets-contract`: extend — per-row confirm state separated from copy (the collision pin), + copy feedback present; all existing pins kept. +- `ssh-keys-contract`: extend — generate's passphrase never in argv (AST + runtime with a + recording stub), pty usage pinned, name confinement + overwrite refusal, fix-permissions + confinement, agent-remove reachable from QML now + the honesty prose, copy feedback; all + existing pins kept (private keys never read, passphrase rule). +- `gnome-handoff-contract`: `privacy` becomes OWNED; verify no page hands off to it. +- `lock-screen-settings-contract`: reconcile with the lock card's removal from PrivacyPage. +- Backlog Phase 12; README count line (172 → 173 expected). + +## Agent ownership (parallel) + +- **A**: `scripts/panama-permissions`, NEW `scripts/panama-privacy`, `scripts/panama-ssh-keys`, + `services/Permissions.qml`, NEW `services/Traces.qml`, `services/SshKeys.qml`. +- **B**: `modules/settings/PrivacyPage.qml`, `SshKeysPage.qml`, new components (+ qmldir). +- **C**: `services/SettingsSearch.qml`, contracts above, backlog, README count line. + +## As built (A) — refinements to the pinned APIs + +Read the real permission store before finalizing, and two things there were not +what the spec assumed: + +- **screencast / remote-desktop ids are opaque restore tokens**, one per + remembered session, not the table name. So the helper `List`s the table, + `Lookup`s each token, and folds the result **by application** — one row per + app, with `grants` counting the stored sessions behind it. `forget TABLE APP` + drops every one of them, which keeps the pinned `revoke(table, app)` + signature honest. +- **`speakers` is gone.** The spec names six tables and speakers is not among + them; the old three-device model is replaced wholesale. + +Additive to the pinned shapes (nothing removed): + +- `snapshot` rows carry `grants` (int) and `raw` (string) beside `app`/`allowed`; + the payload carries `simpleTables` and `revokeOnlyTables` so the service never + hardcodes a list the helper could change. +- `set TABLE APP true|false` (not `allow|deny` — the old CLI's words). +- `traces` sub-objects carry `path`, `present`, and (thumbnails) `measured`, + which is false when the walk hit its budget and the byte count is a floor. +- `Traces` does **not** measure on startup. `measured` is false until the page + calls `measure()`. +- `Permissions.devices` survives as a derived camera/microphone view, because + ApplicationsPage reads it to answer "does this app have a privacy rule". +- `panama-ssh-keys generate` runs ssh-keygen with `SSH_ASKPASS_REQUIRE=never` + and no `DISPLAY`: this desktop sets `SSH_ASKPASS_REQUIRE=prefer`, which made + ssh-keygen open a graphical dialog and ignore the terminal entirely. +- Helper seams: `PANAMA_PRIVACY_HELPER`, `PANAMA_PERMISSIONS_HELPER`, + `PANAMA_SSH_KEYS_HELPER`. Fixture seams: `PANAMA_PRIVACY_RECENTS`, + `PANAMA_PRIVACY_THUMBNAILS` (both still confined to `HOME`, so a hermetic run + points `HOME` at a scratch directory). + +Hard rules: NO live mutations — no portal Set/Delete, no keyring writes, no ssh-keygen runs +against the real ~/.ssh, no chmod, no truncating the real recents, no thumbnail deletion. +Read-only probes and hermetic stubs only. Valid QML/Python at every save. B programs against +the pinned APIs; A updates this spec before changing them. diff --git a/tests/quickshell/gnome-handoff-contract b/tests/quickshell/gnome-handoff-contract index 032dd38..2b8e0ef 100755 --- a/tests/quickshell/gnome-handoff-contract +++ b/tests/quickshell/gnome-handoff-contract @@ -47,6 +47,14 @@ fail() { # "online-accounts" is listed because Panama has an Online Accounts page -- but # adding an account still has to go through GOA's own dialog, so that one # exception is named explicitly below. +# "privacy" joined the list when Privacy & Security stopped being a page that +# read the portal and pointed at GNOME for everything else. It now clears the +# recent-files list and the thumbnail cache itself, empties the trash through +# Storage's own cleanable, and revokes portal grants for six tables rather than +# three devices. The card that used to carry the door -- headed "Owned by +# Fedora", explaining that GNOME's file-history switches would not take effect +# in a Hyprland session anyway -- is gone, because the switches it was +# apologizing for are now buttons that work. declare -A OWNED=( [network]=connectivity [wifi]=connectivity @@ -55,6 +63,7 @@ declare -A OWNED=( [sharing]=sharing [users]=users [system\ users]=users + [privacy]=privacy ) # Handoffs that are correct despite naming an owned panel, with the reason. @@ -109,6 +118,15 @@ done < <(grep -rno --include='*.qml' -E 'openGnomePanel\("[^"]*"(, *"[^"]*")?\)' (( checked > 0 )) || fail 'no handoffs were examined, so this proves nothing' +# The inverse, for the page that just stopped handing anything over. The loop +# above can only fail on a door that exists; said this way it also fails if the +# door comes back under a panel name nobody thought to list. +if grep -q 'openGnomePanel' "$settings_dir/PrivacyPage.qml"; then + printf 'gnome handoff contract: PrivacyPage still opens a GNOME panel:\n' >&2 + grep -n 'openGnomePanel' "$settings_dir/PrivacyPage.qml" >&2 + violations=$((violations + 1)) +fi + if (( violations > 0 )); then printf 'Each of these sends someone to GNOME for a page this app already has.\n' >&2 exit 1 diff --git a/tests/quickshell/lock-screen-settings-contract b/tests/quickshell/lock-screen-settings-contract index f7992f4..a9a2072 100755 --- a/tests/quickshell/lock-screen-settings-contract +++ b/tests/quickshell/lock-screen-settings-contract @@ -46,6 +46,24 @@ for key in lockBackgroundMode lockBlurLevel lockShowClock lockShowDate lockShowU || fail "$key was duplicated onto Power or Privacy" done +# ── One home for the lock timings, and a signpost where the copy used to be ── +# +# Privacy carried a whole second "Screen lock" card: the same idle timings, the +# same preferences, bound twice. Two sliders writing one value is not a +# convenience -- it is a page where the number you are looking at may not be the +# number you last set. The card is gone, and what replaced it is a pointer, +# because deleting a duplicate without saying where the original lives just +# moves the confusion. +! rg -Fq 'title: "Screen lock"' "$privacy" \ + || fail 'the duplicated Screen lock card is back on the Privacy page' +for key in lockMinutes lockMinutesBattery lockOnSleep; do + rg -Fq "setting: \"$key\"" "$power" || fail "Power does not own $key" + ! rg -Fq "setting: \"$key\"" "$privacy" \ + || fail "$key is bound on Privacy as well as Power, so one preference has two controls" +done +rg -Fq 'ShellState.openSettings("power")' "$privacy" \ + || fail 'Privacy dropped the lock card without pointing anywhere, so the settings look deleted' + # The lock screen is the third card of the Background tab, after the still and # video wallpaper cards. It belongs there because it is a picture of the # desktop: the background is what it blurs, and the card above is what it diff --git a/tests/quickshell/permissions-contract b/tests/quickshell/permissions-contract index 57479cc..7bee8dc 100755 --- a/tests/quickshell/permissions-contract +++ b/tests/quickshell/permissions-contract @@ -8,19 +8,29 @@ # /dev/video0 directly, and a settings page implying otherwise is worse # than one that says nothing -- so the limit is stated on the page, not # buried in a comment. -# 2. A device nothing has asked for is reported empty, not omitted. "No +# 2. A table nothing has asked for is reported empty, not omitted. "No # application uses your microphone" and a page that quietly leaves the # microphone out look identical and mean very different things. # 3. Absence is not failure. The store answers "No entry for microphone" for a -# device nobody has requested; treating that as an error would make the +# table nobody has requested; treating that as an error would make the # whole page fail because one device is unused. # 4. Anything that is not an explicit "yes" is withheld. Guessing generously # about a camera is the wrong way to be wrong. # 5. A refusal states its reason. +# 6. THE NEW ONE. screencast and remote-desktop do not hold yes/no. Their +# permissions are structured GVariants -- which monitors, whether the +# pointer is included, how long the grant lasts -- and there is no honest +# way to reconstruct one from a switch. So those two tables are +# revoke-only: DeletePermission exists for them and SetPermission does not, +# anywhere, because a toggle that writes a plausible-looking variant would +# silently rewrite a grant the user never described. # -# The write path is exercised against an application id that does not exist, so -# no real application's camera access is changed. What is on this machine -- -# OBS Studio and GNOME Snapshot -- is read, never written. +# SAFETY: this file makes NO live portal writes. The earlier version set and +# cleared a camera permission on the real permission store under a probe +# application id and put it back afterwards; the write path is now exercised +# against a recording `busctl` stub under `env -i`, with the session bus +# address pointed at a socket that does not exist. Nothing here can reach the +# store this desktop is actually using. set -uo pipefail @@ -28,8 +38,7 @@ repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" helper="$repo_dir/config/dot/quickshell/scripts/panama-permissions" service="$repo_dir/config/dot/quickshell/services/Permissions.qml" page="$repo_dir/config/dot/quickshell/modules/settings/PrivacyPage.qml" - -probe="org.panama.ContractProbe" +qml_dir="$repo_dir/config/dot/quickshell" fail() { printf 'permissions contract: %s\n' "$1" >&2 @@ -40,75 +49,416 @@ for path in "$helper" "$service" "$page"; do [[ -r "$path" ]] || fail "missing $path" done [[ -x "$helper" ]] || fail 'panama-permissions is not executable' +command -v jq >/dev/null 2>&1 || { printf 'permissions contract: SKIP (no jq)\n'; exit 0; } -field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } - -state="$("$helper" snapshot)" || fail 'snapshot failed' - -if [[ "$(printf '%s' "$state" | field "['available']")" != "True" ]]; then - printf 'permissions contract: skipped (the portal permission store is not running)\n' - exit 0 -fi +work="$(mktemp -d /tmp/panama-permissions.XXXXXX)" +trap 'rm -rf "$work"' EXIT # ── 1. The page states the limit ──────────────────────────────────────────── grep -q 'directly' "$page" \ || fail 'the page does not say that programs outside the portal reach these devices anyway' -# ── 2 & 3. Unused devices are present and empty, not an error ─────────────── +# ── 6a. No Set path for the structured tables, by AST ──────────────────────── +# +# Read from the syntax tree rather than by grepping for the word, because the +# thing being pinned is reachability: which table names can arrive at the one +# call that writes. A second settable list, or a settable list that quietly +# grew "screencast", is exactly the change this has to catch. -printf '%s' "$state" | python3 -c " -import json, sys -state = json.load(sys.stdin) -if state['error']: - raise SystemExit(f\"snapshot reported an error: {state['error']}\") -names = [d['id'] for d in state['devices']] -for required in ('camera', 'microphone', 'speakers'): - if required not in names: - raise SystemExit(f'{required} is missing from the snapshot entirely') -" || fail 'a device with no recorded application was dropped or reported as an error' +python3 - "$helper" <<'PY' || fail 'the helper can write a permission for a table whose values it cannot construct' +import ast +import importlib.machinery +import importlib.util +import sys -# ── 4 & 5. The write path, on an application that does not exist ──────────── +path = sys.argv[1] +source = open(path, encoding="utf-8").read() +tree = ast.parse(source) -before="$(printf '%s' "$state" | field "['devices']")" +# Imported rather than pattern-matched, so the lists are read at their real +# values. They are derived from a table registry rather than typed out, and a +# contract that only understood one spelling of that would be pinning the +# spelling. Importing is safe: the module does its work under __main__. +loader = importlib.machinery.SourceFileLoader("panama_permissions", path) +module = importlib.util.module_from_spec(importlib.util.spec_from_loader(loader.name, loader)) +# Registered before it runs: a dataclass declared inside it looks its own module +# up by name while the decorator is running. +sys.modules[loader.name] = module +loader.exec_module(module) -denied="$("$helper" set camera "$probe" deny)" || fail 'set deny failed' -reason="$(printf '%s' "$denied" | field "['error']")" -[[ -z "$reason" ]] || fail "denying refused a valid write: $reason" -printf '%s' "$denied" | python3 -c " -import json, sys -for device in json.load(sys.stdin)['devices']: - for app in device['applications']: - if app['app'] == '$probe': - if app['allowed']: - raise SystemExit('a denied application was reported as allowed') - raise SystemExit(0) -raise SystemExit('the denied application was not written at all') -" || fail 'deny did not take effect -- the write path is not doing anything' +structured = {"screencast", "remote-desktop"} -allowed="$("$helper" set camera "$probe" allow)" || fail 'set allow failed' -printf '%s' "$allowed" | python3 -c " -import json, sys -for device in json.load(sys.stdin)['devices']: - for app in device['applications']: - if app['app'] == '$probe' and app['allowed']: - raise SystemExit(0) -raise SystemExit('allow did not take effect') -" || fail 'allow did not take effect' -# Refusals name their reason rather than merely failing. -reason="$(printf '%s' "$("$helper" set camera "$probe" maybe)" | field "['error']")" -[[ "$reason" == *"allow or deny"* ]] \ - || fail "an invalid decision was not refused with a reason (got: $reason)" +def as_names(value): + if isinstance(value, dict): + value = list(value) + if isinstance(value, (list, tuple, set, frozenset)): + names = [item for item in value if isinstance(item, str)] + return set(names) if len(names) == len(list(value)) else set() + return set() -reason="$(printf '%s' "$("$helper" set nonsense "$probe" allow)" | field "['error']")" -[[ -n "$reason" ]] || fail 'an unknown device was accepted' -# ── Put it back ──────────────────────────────────────────────────────────── +collections = {name: as_names(getattr(module, name)) + for name in dir(module) if not name.startswith("_")} +collections = {name: value for name, value in collections.items() if value} -"$helper" forget camera "$probe" >/dev/null || fail 'forget failed' -after="$("$helper" snapshot | field "['devices']")" -[[ "$before" == "$after" ]] \ - || fail 'the contract changed recorded permissions and did not restore them' +known = {name for name, value in collections.items() if structured <= value} +if not known: + raise SystemExit("nothing in the helper names screencast and remote-desktop as tables, " + "so it does not know they exist") -printf 'permissions contract: ok\n' +# Exactly one place writes. More than one is two policies, and the second one +# is the one that falls behind. +writers = [] +deleters = [] +for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef): + literals = [inner.value for inner in ast.walk(node) + if isinstance(inner, ast.Constant) and isinstance(inner.value, str)] + if "SetPermission" in literals: + writers.append(node) + if "DeletePermission" in literals: + deleters.append(node) + +if len(writers) != 1: + raise SystemExit(f"expected exactly one function calling SetPermission, found " + f"{[node.name for node in writers]}") +if not deleters: + raise SystemExit("nothing calls DeletePermission, so a grant can never be revoked") + +# One call site, not merely one function. A second Set inside the same function, +# reached by a different branch, would satisfy the count above and defeat the +# membership check below it. +call_sites = sum(1 for node in ast.walk(tree) + if isinstance(node, ast.Constant) and node.value == "SetPermission") +if call_sites != 1: + raise SystemExit(f"SetPermission is named {call_sites} times; there is one honest " + "place to write a permission and it is not two") + +writer = writers[0] +writer_names = {inner.id for inner in ast.walk(writer) if isinstance(inner, ast.Name)} + +# The gate: a named list of table names, consulted by the one function that +# writes. A chain of conditions would do the same job today and grow an +# exception later; a list membership is one place to look. +gate = {name for name in writer_names & set(collections) if "camera" in collections[name]} +if not gate: + raise SystemExit(f"{writer.name} writes without consulting any list of the tables that " + "MAY be written, so refusing screencast is a runtime accident rather " + "than a rule") + +# The way this breaks is not by deleting the list; it is by somebody adding +# "screencast" to it because the page wanted a switch there. +for name in gate: + if structured & collections[name]: + raise SystemExit(f"{name}, which {writer.name} consults before writing, lists " + f"{sorted(structured & collections[name])} alongside the tables " + "that carry a plain yes/no") + +writer_literals = {inner.value for inner in ast.walk(writer) + if isinstance(inner, ast.Constant) and isinstance(inner.value, str)} +leaked = structured & writer_literals +if leaked: + raise SystemExit(f"{writer.name} mentions {sorted(leaked)} by name, next to the one call " + "that writes a permission") +PY + +# ── 6b. Nothing in the shell asks to set a structured table ───────────────── +# +# The rule said from the QML side: no page and no service may hand a +# revoke-only table to the write path, whatever the helper would do with it. +offenders="$(grep -rn --include='*.qml' -E 'setPermission\([^)]*(screencast|remote-desktop)' "$qml_dir" || true)" +[[ -z "$offenders" ]] \ + || fail "something offers to set a permission it cannot construct: $offenders" + +grep -q 'revoke' "$service" \ + || fail 'the Permissions service has no revoke path, so a structured grant is permanent' +grep -qE 'revokeOnlyTables|revokeOnly' "$service" \ + || fail 'the service does not name which tables are revoke-only, so the page has to guess' +grep -qE 'simpleTables|simpleValued' "$service" \ + || fail 'the service does not name which tables carry a plain yes/no' +grep -q 'tables' "$service" \ + || fail 'the service does not expose the per-table model the page is built from' + +# The Applications page reads the camera/microphone half of this service in the +# older `devices` shape. Generalizing the model to six tables kept that view +# rather than rewriting a second page's bindings, and something has to notice if +# it disappears -- the symptom there is an empty list, not an error. +grep -q 'property var devices' "$service" \ + || fail 'the devices view is gone; ApplicationsPage reads it and would quietly show nothing' +consumers="$(grep -rln --include='*.qml' 'Permissions\.devices' "$qml_dir" || true)" +[[ -n "$consumers" ]] \ + || fail 'nothing reads Permissions.devices any more, so the compatibility view is dead weight' + +# ── The hermetic bus ──────────────────────────────────────────────────────── +# +# A recording `busctl` first on PATH, answering from a fixture. The proof that +# it is the one being called is the fixture's own application ids coming back +# out of `snapshot` -- asserted before anything that writes runs. + +mkdir -p "$work/bin" +export PANAMA_PERMISSIONS_CALL_LOG="$work/calls" +: >"$PANAMA_PERMISSIONS_CALL_LOG" + +cat >"$work/bin/busctl" <<'STUB' +#!/usr/bin/env bash +# A permission store that exists only inside this contract. +# +# The method and its arguments are read positionally rather than by pattern, so +# `List s screencast` and `Lookup ss screencast ` answer with the shapes +# the real store answers with -- an array of ids for one, a dictionary of +# applications for the other. Answering both with the same shape would let a +# helper that never lists a table pass anyway. +printf '%s\n' "$*" >>"$PANAMA_PERMISSIONS_CALL_LOG" + +method="" +rest=() +collecting=0 +for argument in "$@"; do + if (( collecting )); then + rest+=("$argument") + continue + fi + case "$argument" in + List|Lookup|SetPermission|DeletePermission|GetPermission) + method="$argument" + collecting=1 ;; + esac +done + +if [[ -z "$method" ]]; then + # `busctl --user list`, which is how the helper asks whether the store is + # running at all. + printf 'org.freedesktop.impl.portal.PermissionStore 1234 - - - -\n' + exit 0 +fi + +table="${rest[1]:-}" +entry="${rest[2]:-}" + +ids() { printf '{"type":"as","data":[[%s]]}\n' "$1"; } +row() { printf '{"type":"a{sas}v","data":[%s,{"type":"s","data":""}]}\n' "$1"; } +absent() { printf 'No entry for %s\n' "$1" >&2; exit 1; } + +case "$method" in + SetPermission|DeletePermission) + printf '{"type":"","data":[]}\n' + exit 0 ;; + List) + case "$table" in + # Two remembered sessions for one application, which is the normal + # case: the page asks one question and the store holds four answers. + # The third id is deliberately not an id -- a store that handed back + # something path-shaped must not have it fed straight back in. + screencast) ids '"session-a","session-b","../../etc/passwd"' ;; + remote-desktop) ids '"session-r"' ;; + background) ids '"background"' ;; + location) ids '' ;; + *) ids '' ;; + esac + exit 0 ;; + Lookup) + case "$table/$entry" in + devices/camera) + row '{"org.panama.FixtureCam":["yes"],"org.panama.FixtureBlocked":["no"]}' ;; + devices/microphone) + # Nothing has ever asked. The store says so by having no row. + absent microphone ;; + screencast/session-a|screencast/session-b) + row '{"org.panama.FixtureCast":["1","screen","0"]}' ;; + remote-desktop/session-r) + row '{"org.panama.FixtureRemote":["1","keyboard,pointer","0"]}' ;; + background/background) + row '{"org.panama.FixtureBackground":["yes"],"org.panama.FixtureQuiet":["no"]}' ;; + *) + absent "${entry:-$table}" ;; + esac + exit 0 ;; +esac +printf '{"type":"","data":[]}\n' +STUB +chmod +x "$work/bin/busctl" + +runh() { + env -i \ + PATH="$work/bin:/usr/bin:/bin" \ + HOME="$work/home" \ + XDG_RUNTIME_DIR="$work/run" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=$work/no-such-bus" \ + PANAMA_PERMISSIONS_CALL_LOG="$PANAMA_PERMISSIONS_CALL_LOG" \ + LANG=C LC_ALL=C \ + "$helper" "$@" +} +mkdir -p "$work/home" "$work/run" + +# The helper has to find busctl on PATH for the stub to mean anything. +grep -qE '/usr/bin/busctl|/bin/busctl' "$helper" \ + && fail 'the helper names an absolute busctl, so it cannot be pointed at a fixture' + +resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v busctl)" +[[ "$resolved" == "$work/bin/busctl" ]] \ + || fail "busctl resolves to $resolved, not the stub; refusing to run anything that writes" + +calls() { cat "$PANAMA_PERMISSIONS_CALL_LOG"; } +wrote() { grep -c 'SetPermission' "$PANAMA_PERMISSIONS_CALL_LOG"; } +deleted() { grep -c 'DeletePermission' "$PANAMA_PERMISSIONS_CALL_LOG"; } + +state="$(runh snapshot)" || fail 'snapshot failed against the fixture bus' + +# ── 2, 3 & 4. The shape, and the fixture proving this ran against the stub ── + +jq -e '.available == true and (.tables | type == "object") and .error == ""' <<<"$state" >/dev/null \ + || fail "snapshot is not the tables shape the page is built from: $state" + +for table in camera microphone screencast remote-desktop background; do + jq -e --arg t "$table" '.tables | has($t)' <<<"$state" >/dev/null \ + || fail "the $table table is missing from the snapshot entirely" + jq -e --arg t "$table" '[.tables[$t][] | has("app") and has("allowed")] | all' <<<"$state" >/dev/null \ + || fail "a $table row is not { app, allowed }" + # `grants` is how a folded row says how many stored answers are behind it, + # and `raw` is the store's own value -- the page explains a structured grant + # rather than reducing it to a switch, and cannot do that from a boolean. + jq -e --arg t "$table" '[.tables[$t][] | has("grants") and has("raw")] | all' <<<"$state" >/dev/null \ + || fail "a $table row carries no grant count, so a folded row cannot say what it folded" +done + +# The tables the model dropped. "speakers" was in the devices list because the +# portal has an entry for it, not because anything ever asks: no portal backend +# arbitrates speaker access, so the row was a permanent "nothing has asked" for +# a question nobody is asking. Listing it made the page look like it covered +# more than it does. +jq -e '.tables | has("speakers") | not' <<<"$state" >/dev/null \ + || fail 'speakers is back in the permissions model, where nothing ever asks' + +# The proof of the fixture: these ids exist nowhere but in this file. +jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureCam" and .allowed == true)] | length == 1' \ + <<<"$state" >/dev/null \ + || fail "snapshot did not read the fixture's camera table; refusing to go on: $state" +jq -e '[.tables.camera[] | select(.app == "org.panama.FixtureBlocked" and .allowed == false)] | length == 1' \ + <<<"$state" >/dev/null \ + || fail 'a permission that is not an explicit yes was reported as allowed' + +# Rule 3, from the store's own words: "No entry for microphone" is a table +# nobody has asked about, not a failure of the page. +jq -e '.tables.microphone == []' <<<"$state" >/dev/null \ + || fail "an unused table was not reported as empty: $(jq -c .tables.microphone <<<"$state")" +jq -e '.error == ""' <<<"$state" >/dev/null \ + || fail 'an unused table was reported as an error, which fails the whole page over one unused device' + +jq -e '[.tables["remote-desktop"][] | select(.app == "org.panama.FixtureRemote")] | length == 1' \ + <<<"$state" >/dev/null \ + || fail 'a structured grant was dropped rather than listed for revoking' + +# One application, two remembered screencast sessions, ONE row. The page asks +# "may this application share your screen", which is one question; a store that +# holds four answers to it must not become four rows. +jq -e '[.tables.screencast[] | select(.app == "org.panama.FixtureCast")] | length == 1' \ + <<<"$state" >/dev/null \ + || fail "one application's several remembered sessions became several rows: $(jq -c .tables.screencast <<<"$state")" + +# An id the store handed back that is not an id shape is dropped, not fed +# straight back into a Lookup. +grep -qF '../../etc/passwd' "$PANAMA_PERMISSIONS_CALL_LOG" \ + && fail 'a path-shaped entry id from the store was passed back into the permission store' + +# The two lists the page builds itself from, and they do not overlap. +jq -e '(.simpleTables | index("screencast")) == null + and (.simpleTables | index("remote-desktop")) == null + and (.revokeOnlyTables | index("screencast")) != null + and (.revokeOnlyTables | index("remote-desktop")) != null' <<<"$state" >/dev/null \ + || fail "the page is told the wrong tables are toggleable: $(jq -c '{simpleTables,revokeOnlyTables}' <<<"$state")" + +# ── 6c. The write path, at runtime ────────────────────────────────────────── +# +# Which vocabulary the helper takes is read from the helper rather than +# assumed, so this pins the rule and not the spelling. + +probe="org.panama.ContractProbe" +verb="" +for candidate in true allow; do + : >"$PANAMA_PERMISSIONS_CALL_LOG" + if [[ "$(runh set camera "$probe" "$candidate" | jq -r '.error')" == "" ]]; then + verb="$candidate" + break + fi +done +[[ -n "$verb" ]] || fail 'the helper accepted neither `set camera APP true` nor `set camera APP allow`' +(( "$(wrote)" >= 1 )) \ + || fail "setting a simple table never reached SetPermission: $(calls)" + +# Background is the other toggleable one, and the one people actually come to +# this page for. +: >"$PANAMA_PERMISSIONS_CALL_LOG" +reason="$(runh set background "$probe" "$verb" | jq -r '.error')" +[[ -z "$reason" ]] || fail "the background table refused a plain yes/no write: $reason" +(( "$(wrote)" >= 1 )) || fail "setting background never reached SetPermission: $(calls)" + +# The applications the fixture store actually holds a grant for -- forgetting +# something nobody granted is a different case, checked below. +declare -A GRANT_HOLDER=( + [screencast]=org.panama.FixtureCast + ["remote-desktop"]=org.panama.FixtureRemote +) + +for table in screencast remote-desktop; do + holder="${GRANT_HOLDER[$table]}" + + : >"$PANAMA_PERMISSIONS_CALL_LOG" + reason="$(runh set "$table" "$holder" "$verb" | jq -r '.error')" + [[ -n "$reason" ]] \ + || fail "setting $table was accepted, but its value cannot be honestly constructed" + (( "$(wrote)" == 0 )) \ + || fail "setting $table was refused in words but still wrote to the store: $(calls)" + + # Revoking, which is the one thing these tables do support. + : >"$PANAMA_PERMISSIONS_CALL_LOG" + reason="$(runh forget "$table" "$holder" | jq -r '.error')" + [[ -z "$reason" ]] || fail "revoking a $table grant was refused: $reason" + (( "$(deleted)" >= 1 )) \ + || fail "revoking $table never reached DeletePermission: $(calls)" +done + +# Every remembered session, not the first one. An application with two stored +# screencast grants whose row still says "allowed" after Revoke is the failure +# this catches. +: >"$PANAMA_PERMISSIONS_CALL_LOG" +runh forget screencast org.panama.FixtureCast >/dev/null +(( "$(deleted)" == 2 )) \ + || fail "revoking dropped $(deleted) of the fixture's two remembered screencast sessions" + +# Forgetting something nobody granted says so rather than reporting success. +: >"$PANAMA_PERMISSIONS_CALL_LOG" +reason="$(runh forget screencast "$probe" | jq -r '.error')" +[[ -n "$reason" ]] || fail 'revoking a grant that does not exist reported success' +(( "$(deleted)" == 0 )) || fail 'revoking a grant that does not exist still deleted something' + +# ── 5. Refusals name their reason, and reach nothing ──────────────────────── + +: >"$PANAMA_PERMISSIONS_CALL_LOG" +reason="$(runh set camera "$probe" maybe | jq -r '.error')" +[[ -n "$reason" ]] || fail 'an invalid decision was accepted' +(( "$(wrote)" == 0 )) || fail 'an invalid decision still wrote to the store' + +for bad_table in nonsense ../devices 'devices camera' ''; do + : >"$PANAMA_PERMISSIONS_CALL_LOG" + reason="$(runh set "$bad_table" "$probe" "$verb" | jq -r '.error')" + [[ -n "$reason" ]] || fail "the table id '$bad_table' was accepted" + (( "$(wrote)" == 0 )) || fail "the table id '$bad_table' reached the store" + reason="$(runh forget "$bad_table" "$probe" | jq -r '.error')" + [[ -n "$reason" ]] || fail "forgetting the table id '$bad_table' was accepted" +done + +for bad_app in '../../etc/passwd' 'not an app' '' '-'; do + : >"$PANAMA_PERMISSIONS_CALL_LOG" + reason="$(runh set camera "$bad_app" "$verb" | jq -r '.error')" + [[ -n "$reason" ]] || fail "the application id '$bad_app' was accepted" + (( "$(wrote)" == 0 )) || fail "the application id '$bad_app' reached the store" +done + +# A refusal answers in the page's own shape rather than collapsing: the page +# reads .tables off every reply, including the ones that say no. +jq -e '.tables | type == "object"' <<<"$(runh set nonsense "$probe" "$verb")" >/dev/null \ + || fail 'a refusal came back without the tables the page is bound to' + +printf 'permissions contract: PASS (tables read from a fixture bus, no live portal write)\n' diff --git a/tests/quickshell/privacy-traces-contract b/tests/quickshell/privacy-traces-contract new file mode 100755 index 0000000..de9e9d3 --- /dev/null +++ b/tests/quickshell/privacy-traces-contract @@ -0,0 +1,354 @@ +#!/usr/bin/env bash + +# Clearing the traces this desktop keeps of you: recent files, thumbnails, and +# the trash. +# +# Privacy used to hand all three to GNOME's panel, which is not running in a +# Hyprland session, so the card that offered them changed nothing. Doing it +# natively means writing to two paths in the user's home, which is where this +# stops being a display concern and starts being a thing that can destroy +# somebody's afternoon. Four rules: +# +# 1. Clearing recent files EMPTIES the file, it does not delete it. GTK +# recreates a missing recently-used.xbel, but not until something writes a +# recent entry -- so a deleted file reads as "cleared" and then repopulates +# from whatever GTK still had in memory. An empty, valid xbel document +# takes effect at once and stays. It also has to remain valid XML: GTK +# given a truncated file writes nothing there again, and file history +# silently stops working. +# 2. Clearing thumbnails stays inside the thumbnail cache. That directory +# collects symlinks, and an rm that follows one deletes whatever it points +# at. The fixture plants exactly that trap. +# 3. The trash has one implementation. Storage already itemizes it, confirms +# it, and empties it; a second one on this page would be a second set of +# guards to keep in step, and the one that falls behind is the one nobody +# is looking at. +# 4. The card does not sell anything. Every "clean my PC" product manufactures +# urgency, and the only difference between this card and those is the copy. +# +# SAFETY: every run of the helper is under `env -i` with HOME, XDG_DATA_HOME and +# XDG_CACHE_HOME inside a scratch tree this file created, and the run that +# deletes anything happens only after the helper has reported the fixture's own +# byte counts back. The real recently-used.xbel and the real thumbnail cache are +# never opened. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +shell_dir="$repo_dir/config/dot/quickshell" +helper="$shell_dir/scripts/panama-privacy" +service="$shell_dir/services/Traces.qml" +page="$shell_dir/modules/settings/PrivacyPage.qml" + +fail() { + printf 'privacy traces contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-privacy is not executable' +command -v jq >/dev/null 2>&1 || { printf 'privacy traces contract: SKIP (no jq)\n'; exit 0; } + +work="$(mktemp -d /tmp/panama-traces.XXXXXX)" +trap 'rm -rf "$work"' EXIT + +# ── The helper can be pointed somewhere else at all ───────────────────────── +# +# Everything below rests on this: a hardcoded /home/… would mean the run that +# deletes is deleting from the real home. +grep -n '"/home/' "$helper" \ + && fail 'the helper hardcodes a path under /home, so it cannot be pointed at a fixture' + +for verb in traces clear-recents clear-thumbnails; do + grep -q -- "$verb" "$helper" || fail "the helper has no $verb command" +done + +# Trash is deliberately absent from this helper. Rule 3, said where it would be +# broken first. Read past the docstrings, which are allowed to say the word -- +# and in fact should, since "the trash is Storage's" is the reason. +python3 - "$helper" <<'PY' || fail 'panama-privacy has grown a trash implementation; Storage already has one' +import ast +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) + +# Drop every docstring before looking at what is left. +for node in ast.walk(tree): + if isinstance(node, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + body = getattr(node, "body", []) + if body and isinstance(body[0], ast.Expr) and isinstance(body[0].value, ast.Constant) \ + and isinstance(body[0].value.value, str): + node.body = body[1:] + +offenders = sorted({inner.value for inner in ast.walk(tree) + if isinstance(inner, ast.Constant) and isinstance(inner.value, str) + and "trash" in inner.value.lower()}) +offenders += sorted({inner.id for inner in ast.walk(tree) + if isinstance(inner, ast.Name) and "trash" in inner.id.lower()}) +if offenders: + raise SystemExit(f"the helper's code mentions the trash: {offenders}") +PY + +# ── The fixture home ──────────────────────────────────────────────────────── + +home="$work/home" +data="$home/.local/share" +cache="$home/.cache" +recents="$data/recently-used.xbel" +thumbs="$cache/thumbnails" +mkdir -p "$data" "$thumbs/normal" "$thumbs/large" "$work/outside" + +# A recents file with two entries, of a size that could not be confused with a +# real one. +cat >"$recents" <<'XBEL' + + + + + + + + + + + + + +XBEL + +# 9 thumbnails of 33333 bytes: ~300 KB, a number that appears nowhere else. +for index in $(seq 1 9); do + head -c 33333 /dev/zero >"$thumbs/normal/fixture-$index.png" +done + +# The trap. A symlink out of the thumbnail cache to a file that must survive, +# and a symlinked subdirectory, which has to be unlinked rather than descended. +printf 'this file is not a thumbnail and must survive\n' >"$work/outside/precious" +ln -s "$work/outside/precious" "$thumbs/escape-file" +ln -s "$work/outside" "$thumbs/escape-dir" + +runh() { + env -i \ + PATH="/usr/bin:/bin" \ + HOME="$home" \ + XDG_DATA_HOME="$data" \ + XDG_CACHE_HOME="$cache" \ + XDG_CONFIG_HOME="$home/.config" \ + LANG=C LC_ALL=C \ + "$helper" "$@" +} + +# ── The measurement, and the proof it is the fixture's ────────────────────── + +state="$(runh traces)" || fail 'traces failed against the fixture' +jq -e '(.recents | has("bytes") and has("entries")) and (.thumbnails | has("bytes")) + and (.error == "" or .error == null)' <<<"$state" >/dev/null \ + || fail "traces is not the shape the card is bound to: $state" + +# Nothing that deletes runs until these two hold. 9 x 33333 = 299997 bytes of +# thumbnails, and two recent entries. +jq -e '.thumbnails.bytes > 250000 and .thumbnails.bytes < 400000' <<<"$state" >/dev/null \ + || fail "the thumbnail measurement is not the fixture's ($(jq -r .thumbnails.bytes <<<"$state") bytes); refusing to go on" +jq -e '.recents.entries == 2' <<<"$state" >/dev/null \ + || fail "the recents count is not the fixture's ($(jq -r .recents.entries <<<"$state")); refusing to go on" +jq -e '.recents.bytes > 0' <<<"$state" >/dev/null \ + || fail 'the recents file was measured as empty when it plainly is not' + +# ── 1. Clearing recents empties the file, and leaves valid XML ────────────── + +before_inode="$(stat -c '%i' "$recents")" +runh clear-recents >/dev/null || fail 'clear-recents failed against the fixture' + +[[ -f "$recents" ]] \ + || fail 'clear-recents deleted recently-used.xbel; GTK will not recreate it until something writes a recent entry, so file history stops working until then' +[[ ! -L "$recents" ]] || fail 'recently-used.xbel was replaced by a symlink' + +python3 - "$recents" <<'PY' || fail 'clear-recents left something that is not a valid, empty xbel document' +import sys +import xml.etree.ElementTree as ET + +path = sys.argv[1] +raw = open(path, "rb").read() +if not raw.strip(): + raise SystemExit("the file was truncated to nothing rather than written as an empty xbel; " + "GTK treats a zero-length file as corrupt and stops recording history") +try: + root = ET.fromstring(raw) +except ET.ParseError as error: + raise SystemExit(f"what is left is not parseable XML: {error}") +if root.tag != "xbel": + raise SystemExit(f"the root element is <{root.tag}>, not ") +bookmarks = root.findall("bookmark") +if bookmarks: + raise SystemExit(f"{len(bookmarks)} bookmark(s) survived clearing") +PY + +after="$(runh traces)" +jq -e '.recents.entries == 0' <<<"$after" >/dev/null \ + || fail "recents still reports $(jq -r .recents.entries <<<"$after") entries after clearing" + +# Rewritten in place or replaced atomically -- either is fine; unlinking and +# leaving nothing is what rule 1 forbids, and that is already covered above. +# What is checked here is that a second clear on an already-empty file is not +# an error, because the row stays pressable. +runh clear-recents >/dev/null || fail 'clearing an already-empty recents file failed' +[[ -f "$recents" ]] || fail 'the second clear removed the file' +: "$before_inode" + +# ── 2. Clearing thumbnails stays inside the thumbnail cache ───────────────── + +runh clear-thumbnails >/dev/null || fail 'clear-thumbnails failed against the fixture' + +[[ -f "$work/outside/precious" ]] \ + || fail 'clearing thumbnails followed a symlink out of the cache and deleted a file elsewhere' +[[ -d "$work/outside" ]] \ + || fail 'clearing thumbnails deleted a directory outside the cache' +[[ -d "$thumbs" ]] \ + || fail 'the thumbnail directory itself was removed; the thumbnailer expects it to exist' +remaining="$(find "$thumbs" -type f | wc -l)" +[[ "$remaining" == "0" ]] \ + || fail "clearing thumbnails left $remaining file(s) behind, so it did not do what it said" + +# The escape hatch itself is gone -- the link is inside the cache, so removing +# it is correct; what must not have happened is following it. +[[ ! -e "$thumbs/escape-file" ]] \ + || fail 'the symlink inside the cache was left behind' + +# ── The symlinked cache root, refused rather than followed ────────────────── +# +# The other half of the same trap: not a link inside the cache, but a cache +# that IS a link. Resolving to somewhere outside the home has to be refused +# with a reason rather than emptied. + +escaped_home="$work/escaped" +mkdir -p "$escaped_home/.cache" "$work/victim" +printf 'not a thumbnail\n' >"$work/victim/keepme" +ln -s "$work/victim" "$escaped_home/.cache/thumbnails" + +escaped_output="$(env -i PATH="/usr/bin:/bin" HOME="$escaped_home" \ + XDG_CACHE_HOME="$escaped_home/.cache" XDG_DATA_HOME="$escaped_home/.local/share" \ + LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)" +escaped_status=$? + +[[ -f "$work/victim/keepme" ]] \ + || fail 'a symlinked thumbnail directory was emptied through the link' +[[ -d "$work/victim" ]] \ + || fail 'the directory a symlinked thumbnail cache pointed at was removed' + +# Refused in words. Either shape counts -- an error field on the payload the +# card reads, or a non-zero exit -- but silence does not: a row that reports +# success while the cache is untouched is the failure mode. +reason="$(jq -r '.error // ""' <<<"$escaped_output" 2>/dev/null || printf '')" +[[ -n "$reason" || "$escaped_status" -ne 0 ]] \ + || fail 'a thumbnail cache that resolves outside the home was accepted silently' + +# ── The seam is not a way around the guard ────────────────────────────────── +# +# A test seam that skips the confinement would make everything above theatre. +# Pointed at a directory outside the home, it has to be refused exactly as a +# symlinked one is. + +mkdir -p "$work/elsewhere" +printf 'also not a thumbnail\n' >"$work/elsewhere/keepme" +seam_output="$(env -i PATH="/usr/bin:/bin" HOME="$home" \ + XDG_DATA_HOME="$data" XDG_CACHE_HOME="$cache" \ + PANAMA_PRIVACY_THUMBNAILS="$work/elsewhere" \ + LANG=C LC_ALL=C "$helper" clear-thumbnails 2>&1)" +seam_status=$? +[[ -f "$work/elsewhere/keepme" ]] \ + || fail 'the thumbnail seam pointed outside the home was emptied anyway' +reason="$(jq -r '.error // ""' <<<"$seam_output" 2>/dev/null || printf '')" +[[ -n "$reason" || "$seam_status" -ne 0 ]] \ + || fail 'a thumbnail path outside the home was accepted through the test seam' + +# ── 3. One trash implementation ───────────────────────────────────────────── + +grep -q 'Disks' "$page" \ + || fail 'the Traces card does not reach the Storage service, so its trash row is a second implementation' +grep -qE 'Disks\.(clean|cleanables)' "$page" \ + || fail 'the page does not empty the trash through the cleanable Storage already has' + +# The two ways a second one would appear: the page doing it itself, or the +# Traces service growing the verb. +grep -vE '^\s*//' "$page" | grep -qE 'gio +trash|"trash-empty"|rm -rf.*Trash|\.local/share/Trash' \ + && fail 'the page empties the trash itself rather than through Storage' +grep -vE '^\s*//' "$service" | grep -qiE 'trash' \ + && fail 'the Traces service has grown a trash path; Storage owns that one' + +# The row says so, rather than leaving two identical buttons in two places +# looking like two different things. +grep -q 'Trash' "$page" \ + || fail 'the Traces card has no trash row at all' + +# ── The seams, so this contract can exist ─────────────────────────────────── +# +# Both paths are named seams AND both are re-confined against HOME, which is +# what makes pointing HOME at a scratch tree a real test rather than a way of +# disabling the guard. + +for seam in PANAMA_PRIVACY_RECENTS PANAMA_PRIVACY_THUMBNAILS; do + grep -q "$seam" "$helper" \ + || fail "the helper has no $seam seam, so nothing can exercise it without the real home" +done +grep -qE 'PANAMA_PRIVACY|helperPath' "$service" \ + || fail 'the Traces service does not name the helper it runs' + +# ── 4. The card does not sell anything ────────────────────────────────────── + +python3 - "$page" <<'PY' || fail 'the Traces card uses the language of a cleaner racket' +import re +import sys + +source = open(sys.argv[1], encoding="utf-8").read() +text = "\n".join(line for line in source.splitlines() if not line.strip().startswith("//")) + + +def block_at(start: int) -> str: + depth = 0 + for index in range(text.find("{", start), len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start:index + 1] + return "" + + +# Found by what it is wired to rather than by its title: the card that reads +# the Traces service is the card under test, whatever it ends up being called. +cards = [block for block in (block_at(match.start()) + for match in re.finditer(r"SettingsCard \{", text)) + if re.search(r"\bTraces\.", block)] +if not cards: + raise SystemExit("no card on the Privacy page is wired to the Traces service") +card = min(cards, key=len) + +# Only what a person reads. QML is full of exclamation marks and none of them +# are shouting at anybody. +copy = " ".join(re.findall(r'"([^"\n]*)"', card)).lower() + +PRESSURE = [ + "running out", "running low", "act now", "recommended", "we recommend", + "urgent", "boost", "speed up", "optimize", "optimise", "reclaim now", + "free up now", "clean now", "junk", "safe to remove", "you should", + "needs attention", "protect yourself", "at risk", "exposed", "!", +] +found = [phrase for phrase in PRESSURE if phrase in copy] +if found: + raise SystemExit(f"the Traces card says: {found}") + +# Nor one button that clears the lot: each trace is a separate thing to lose, +# and losing all three because one of them was worth clearing is not a choice +# anybody made. +if re.search(r'"(Clear (everything|all)|Erase everything|Wipe)"', card): + raise SystemExit("the card offers a single button that clears every trace at once") +PY + +printf 'privacy traces contract: PASS (recents emptied as valid xbel, thumbnails confined, trash still Storage\047s)\n' diff --git a/tests/quickshell/secrets-contract b/tests/quickshell/secrets-contract index d89d63b..cb045e9 100755 --- a/tests/quickshell/secrets-contract +++ b/tests/quickshell/secrets-contract @@ -69,7 +69,7 @@ grep -qiE 'property (string|var) (secret|password|value)\b' "$service" \ && fail 'the Keyring service declares a property that would hold a secret value' # ── 4. Forgetting is confirmed ─────────────────────────────────────────────── -grep -q 'confirmingPath' "$page" \ +grep -q 'confirmingItem' "$page" \ || fail 'the page deletes a stored secret without a confirmation step' grep -q 'Keyring.forget(' "$page" \ || fail 'the page cannot forget a secret at all' @@ -79,9 +79,78 @@ grep -q 'Keyring.forget(' "$page" \ # for confirmation rather than a deletion. grep -q 'if (!secretRow.confirming)' "$page" \ || fail 'the first press on Forget is not turned into a confirmation step' -grep -q 'root.confirmingPath = secretRow.itemPath;' "$page" \ +grep -q 'root.confirmingItem = secretRow.itemPath;' "$page" \ || fail 'nothing records which item is awaiting confirmation' +# ── 5. Confirming a Forget does not move the Copy button ───────────────────── +# +# The page used to run one confirmation state for the whole card, and the row's +# other button read `confirming ? "Cancel" : "Copy"`. So arming Forget on a row +# replaced the word "Copy" -- in the exact place the user had just learnt to +# find it -- with "Cancel", and the way out of a confirmation was to press the +# button that copies. Two states that happen to be about the same row are still +# two states. +python3 - "$page" <<'PY' || fail 'the copy button doubles as something else' +import re +import sys + +text = "\n".join(line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() + if not line.strip().startswith("//")) + + +def block_at(start: int) -> str: + depth = 0 + for index in range(text.find("{", start), len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start:index + 1] + return "" + + +buttons = [block_at(match.start()) for match in re.finditer(r"SettingsButton \{", text)] +copiers = [block for block in buttons if "Keyring.copy(" in block] +if not copiers: + raise SystemExit("nothing on the page copies a stored secret") +for block in copiers: + label = re.search(r'text:\s*(.+)', block) + if label is None: + raise SystemExit("the copy button has no label") + if label.group(1).strip().rstrip(";") != '"Copy"': + raise SystemExit(f"the copy button's label is conditional: {label.group(1).strip()}") + + # ...and its press does one thing. The old Cancel behaviour lived in the + # handler as well as the label: pressing Copy while a Forget was armed + # cleared the confirmation instead of copying. + handler = re.search(r'onClicked:\s*(\{.*?\n\s*\}|[^\n]+)', block, re.S) + if handler is None: + raise SystemExit("the copy button does nothing when pressed") + if re.search(r'confirming', handler.group(1)): + raise SystemExit(f"pressing Copy reads the confirmation state: {handler.group(1).strip()}") + +forgetters = [block for block in buttons if "Keyring.forget(" in block] +if not forgetters: + raise SystemExit("nothing on the page forgets a stored secret") +for block in forgetters: + if "Keyring.copy(" in block: + raise SystemExit("one button both copies and forgets") +PY + +# ── 6. A copy says it happened, on its own row ─────────────────────────────── +# +# Putting something on the clipboard is invisible. The confirmation has to be +# tied to the row it happened on, or a card of six identical Copy buttons says +# only that A copy happened. +grep -qE 'Keyring\.copiedPath === [A-Za-z_][A-Za-z0-9_]*\.itemPath' "$page" \ + || fail 'the copy confirmation is not tied to the row that was copied' +grep -q 'copiedPath' "$service" \ + || fail 'the service records nothing about a copy, so no row can confirm one' +# ...and the confirmation is not the same state as the Forget confirmation. +grep -qE 'confirmingItem[^=]*=[^=].*copiedPath|copiedPath.*=.*confirmingItem' "$page" \ + && fail 'the confirm state and the copy state are the same value' + # Opening the page must not enumerate anyone's passwords as a side effect. grep -qE 'Component.onCompleted:.*Keyring.list\(\)' "$page" \ && fail 'the page lists stored secrets when it opens rather than when asked' diff --git a/tests/quickshell/settings-ownership-contract b/tests/quickshell/settings-ownership-contract index 68cac00..fb6f3c3 100755 --- a/tests/quickshell/settings-ownership-contract +++ b/tests/quickshell/settings-ownership-contract @@ -40,8 +40,12 @@ expected = { "cursorInactiveTimeout": {"owner": "mouse", "mirrors": {"accessibility"}}, "cursorSize": {"owner": "accessibility", "mirrors": {"mouse"}}, "inactiveOpacity": {"owner": "appearance", "mirrors": {"accessibility"}}, - "lockMinutes": {"owner": "power", "mirrors": {"privacy"}}, - "lockOnSleep": {"owner": "power", "mirrors": {"privacy"}}, + # lockMinutes and lockOnSleep used to be mirrored onto Privacy, which was + # the one mirror in this table that nobody had asked for: Privacy carried a + # whole second Screen-lock card, so the same preference had two sliders and + # the page you were looking at might not be the one you last set. The card + # is gone and Privacy points at Power & Lock instead, so the mirror went + # with it. Power owns them outright now. } @@ -103,8 +107,6 @@ for needle in \ '`cursorInactiveTimeout`' \ '`cursorSize`' \ '`inactiveOpacity`' \ - '`lockMinutes`' \ - '`lockOnSleep`' \ 'scheme-relative role' \ 'mode, scale, rotation, arrangement, and primary role'; do rg -Fq "$needle" "$readme" || fail "README is missing $needle" diff --git a/tests/quickshell/ssh-keys-contract b/tests/quickshell/ssh-keys-contract index 77e28c8..b2fd07b 100755 --- a/tests/quickshell/ssh-keys-contract +++ b/tests/quickshell/ssh-keys-contract @@ -1,32 +1,45 @@ #!/usr/bin/env bash -# SSH keys, and the two things this page must never do. +# SSH keys, and the things this page must never do. # # The rules: # # 1. A private key is never read for its contents and never leaves the # machine's disk. Fingerprints and comments come from the .pub file. -# 2. No passphrase passes through this tool. Adding an encrypted key lets -# ssh-add prompt through the system's own askpass; collecting one here and -# handing it on would be a worse place for it to live, and putting one in -# argv would publish it to every process on the machine. +# 2. A passphrase never reaches argv, an environment variable, or a temporary +# file. /proc publishes argv and the environment to every process on this +# machine, and a file on disk outlives the moment it was needed for. The +# page can now MAKE a key, which means it collects one -- so the rule +# stopped being "no passphrase exists here" and became "the passphrase +# goes down a pty to ssh-keygen and nowhere else". `ssh-keygen -N` is the +# easy way to do this wrong and is forbidden outright. Adding an existing +# encrypted key to the agent still collects nothing: ssh-add prompts +# through the system's own askpass. # 3. Key paths are confined to ~/.ssh, resolved and compared, so a name cannot -# walk out of the directory. +# walk out of the directory -- and generation refuses to overwrite, because +# the one thing worse than not making a key is replacing one whose public +# half is already installed on servers you can no longer reach. # 4. A control that cannot do what it says is not offered. gnome-keyring's # agent lists every key it finds in ~/.ssh, so `ssh-add -d` reports # "Identity removed" and the key is still offered a second later. Measured # on this machine: a plain ssh-agent removes durably, that one does not. -# 5. Copying a public key never splices a path into shell source. +# The button exists now, and says so. +# 5. Copying a public key never splices a path into shell source, and says +# that it happened. # -# Read-only against the real configuration. Nothing here adds, removes or -# rewrites a key, an agent entry, or a known host. +# SAFETY: the first half is read-only against the real configuration -- nothing +# adds, removes or rewrites a key, an agent entry, or a known host. The second +# half runs the helper under `env -i` with HOME inside a scratch tree and a +# recording `ssh-keygen` first on PATH, so every key it creates, refuses or +# chmods is one this file made. `~/.ssh` is never the directory under test. set -uo pipefail repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -helper="$repo_dir/config/dot/quickshell/scripts/panama-ssh-keys" -service="$repo_dir/config/dot/quickshell/services/SshKeys.qml" -page="$repo_dir/config/dot/quickshell/modules/settings/SshKeysPage.qml" +shell_dir="$repo_dir/config/dot/quickshell" +helper="$shell_dir/scripts/panama-ssh-keys" +service="$shell_dir/services/SshKeys.qml" +page="$shell_dir/modules/settings/SshKeysPage.qml" fail() { printf 'ssh keys contract: %s\n' "$1" >&2 @@ -65,12 +78,98 @@ for key in state['keys']: grep -q 'read_text' "$helper" && ! grep -q 'KNOWN_HOSTS.read_text' "$helper" \ && fail 'something reads a file directly that is not known_hosts' -# ── 2. No passphrase anywhere ─────────────────────────────────────────────── +# ── 2. The passphrase, statically ─────────────────────────────────────────── grep -qE '\-N["'"'"' ]' "$helper" \ && fail 'ssh-keygen -N appears, which would put a passphrase in argv' -grep -qi 'passphrase' "$service" && ! grep -qi 'never\|prompt' "$service" \ - && fail 'the service mentions passphrases without saying it does not handle them' + +grep -qE '\bimport pty\b|openpty' "$helper" \ + || fail 'nothing opens a pty, so ssh-keygen has no terminal to read a passphrase from' + +# Where it must not go, read from the syntax tree rather than by eye: the +# parameter carrying the passphrase may not appear inside any command list, any +# environment dict, or any call that opens a file. +python3 - "$helper" <<'PY' || fail 'the passphrase can reach somewhere other than the pty' +import ast +import sys + +tree = ast.parse(open(sys.argv[1], encoding="utf-8").read()) + +functions = [node for node in ast.walk(tree) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))] +if not any("generate" in node.name for node in functions): + raise SystemExit("the helper has no generate function") + +# Every function that is handed the passphrase, not only the one named +# generate: the pty write lives in a helper of its own, and a rule that only +# looked at the caller would miss the place the value actually goes. +carriers = [] +for node in functions: + arguments = node.args + names = [argument.arg for argument in + arguments.posonlyargs + arguments.args + arguments.kwonlyargs] + for name in names: + if "pass" in name.lower() or "secret" in name.lower(): + carriers.append((node, name)) + +if not carriers: + raise SystemExit("no function in the helper takes a passphrase, so nothing collects one " + "and the page cannot make an encrypted key") + + +def mentions(node, secret) -> bool: + return any(isinstance(inner, ast.Name) and inner.id == secret + for inner in ast.walk(node)) + + +for function, secret in carriers: + where = f"{function.name}()" + for node in ast.walk(function): + if isinstance(node, (ast.List, ast.Tuple)) and mentions(node, secret): + raise SystemExit(f"{secret} appears inside a list literal in {where}, " + "which is how it gets into argv") + if isinstance(node, ast.Dict) and mentions(node, secret): + raise SystemExit(f"{secret} appears inside a dict literal in {where}, " + "which is how it gets into the environment") + if isinstance(node, ast.Call): + called = node.func + label = getattr(called, "id", None) or getattr(called, "attr", None) or "" + if label in {"open", "NamedTemporaryFile", "mkstemp", "write_text", "write_bytes"} \ + and any(mentions(argument, secret) for argument in node.args): + raise SystemExit(f"{secret} is handed to {label}() in {where}, " + "which puts it on disk") + if label in {"putenv", "setenv"} and mentions(node, secret): + raise SystemExit(f"{secret} is put into the environment in {where}, " + "which /proc publishes") + +# The empty-passphrase escape hatch exists, is explicit, and is not the default. +flags = {node.value for node in ast.walk(tree) + if isinstance(node, ast.Constant) and isinstance(node.value, str)} +if "--no-passphrase" not in flags: + raise SystemExit("there is no explicit --no-passphrase flag, so an empty passphrase " + "is either impossible or silent") +PY + +# ...and nothing in the shell ever passes that flag. An unencrypted key is a +# decision someone makes at a terminal, not one a settings page makes quietly. +offenders="$(grep -rn --include='*.qml' -- '--no-passphrase' "$shell_dir" || true)" +[[ -z "$offenders" ]] \ + || fail "the shell passes --no-passphrase, so the page can make an unencrypted key: $offenders" + +# The service holds one for exactly as long as it takes to write it down the +# pipe, and never assembles it into a command. +grep -q 'stdinEnabled' "$service" \ + || fail 'the service has no stdin path, so a passphrase would have to travel some other way' +python3 - "$service" <<'PY' || fail 'the service puts a passphrase into a command' +import re +import sys + +text = "\n".join(line for line in open(sys.argv[1], encoding="utf-8").read().splitlines() + if not line.strip().startswith("//")) +for match in re.finditer(r'command\s*[:=]\s*\[[^\]]*\]', text, re.S): + if re.search(r'passphrase', match.group(0), re.I): + raise SystemExit(f"a passphrase is spliced into a command: {match.group(0)!r}") +PY # ── 3. Paths are confined ─────────────────────────────────────────────────── @@ -94,13 +193,23 @@ reason="$(printf '%s' "$("$helper" forget-host 'not a host name')" | field "['er grep -q 'durableRemoval' "$helper" \ || fail 'the helper does not record whether removal from this agent sticks' +# Removal is reachable now, rather than being a verb the helper had and nothing +# called. All three links have to exist or the button is decoration. +grep -q 'agent-remove' "$service" \ + || fail 'the service never invokes agent-remove, so the helper verb is unreachable' +grep -qE 'function removeFromAgent' "$service" \ + || fail 'the service has no removeFromAgent, so the page has nothing to call' +grep -q 'removeFromAgent(' "$page" \ + || fail 'the page never removes a key from the agent, so the verb is still unreachable' + kind="$(printf '%s' "$state" | field "['agent'].get('kind','')")" if [[ "$kind" == "gnome-keyring" ]]; then # Whichever key this machine actually has. This used to hardcode # id_ed25519, which asserted the author's machine: any other key name # earned "That key no longer exists" instead of the refusal under test. # No key at all means the property cannot be exercised here, not that it - # failed. + # failed. Nothing is removed either way -- the refusal happens before + # ssh-add is invoked. real_key="$(compgen -G "$HOME/.ssh/id_*.pub" | head -1)" real_key="${real_key%.pub}" if [[ -n "$real_key" ]]; then @@ -112,9 +221,249 @@ if [[ "$kind" == "gnome-keyring" ]]; then || fail 'the page does not say that removing a key from this agent has no effect' fi -# ── 5. Copying does not build shell source from a path ────────────────────── +# The refusal is prose on the page, not a silent no-op: someone pressing Remove +# and watching the key stay is owed the reason. +grep -qE 'lastError|durableRemoval' "$page" \ + || fail 'the page surfaces neither the refusal nor the reason for it' + +# ── 5. Copying does not build shell source from a path, and says it happened ─ grep -q 'exec wl-copy < "\$1"' "$service" \ || fail 'the public key copy does not pass its path as an argument' +grep -q 'copiedKey' "$service" \ + || fail 'the service records nothing about a copy, so the button cannot confirm it happened' +grep -qE 'Timer' "$service" \ + || fail 'the copy confirmation is never cleared, so the page stays stuck saying Copied' +grep -q 'copiedKey' "$page" \ + || fail 'the page does not show that a public key was copied' -printf 'ssh keys contract: ok\n' +# The page can ask for the state again rather than only at startup. +grep -qE 'SshKeys\.refresh\(\)' "$page" \ + || fail 'the page cannot refresh, so a key made in a terminal never appears' + +# ══ The hermetic half ═══════════════════════════════════════════════════════ +# +# Everything above reads. Everything below writes -- into a scratch home, with +# a recording ssh-keygen, and never anywhere near ~/.ssh. + +command -v jq >/dev/null 2>&1 || { printf 'ssh keys contract: SKIP hermetic half (no jq)\n'; exit 0; } + +work="$(mktemp -d /tmp/panama-ssh-keys.XXXXXX)" +trap 'rm -rf "$work"' EXIT + +scratch="$work/home" +ssh_dir="$scratch/.ssh" +mkdir -p "$ssh_dir" "$work/bin" "$work/outside" +chmod 700 "$ssh_dir" + +log="$work/keygen.log" +: >"$log" + +# The recording ssh-keygen. It logs its own argv and its own environment, +# straight out of /proc, so the passphrase check is made against what the +# kernel would show any other process rather than against what the stub was +# told. Then it plays the prompts and reads the answers off its terminal. +cat >"$work/bin/ssh-keygen" <>"\$log" + +mode="" +keyfile="" +comment="" +previous="" +for argument in "\$@"; do + case "\$argument" in + -t) mode=generate ;; + -y) mode=derive ;; + -l) mode=fingerprint ;; + -R) mode=forget ;; + esac + case "\$previous" in + -f) keyfile="\$argument" ;; + -C) comment="\$argument" ;; + esac + previous="\$argument" +done + +case "\$mode" in + generate) + printf 'Enter passphrase (empty for no passphrase): ' + IFS= read -r first + printf '\nEnter same passphrase again: ' + IFS= read -r second + printf '\n' + printf 'pty-read: %s\n' "\$first" >>"\$log" + printf 'pty-read: %s\n' "\$second" >>"\$log" + [[ -n "\$keyfile" ]] || exit 1 + printf 'fixture private key, not real material\n' >"\$keyfile" + chmod 600 "\$keyfile" + printf 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFIXTURE %s\n' "\$comment" >"\$keyfile.pub" + printf 'Your identification has been saved in %s\n' "\$keyfile" + exit 0 ;; + derive) + # An encrypted key: deriving the public half with an empty passphrase + # fails, which is how the helper answers "is this one encrypted". + printf 'Load key "%s": incorrect passphrase supplied to decrypt private key\n' \ + "\$keyfile" >&2 + exit 1 ;; + fingerprint) + printf '256 SHA256:FIXTUREFINGERPRINTAAAAAAAAAAAAAAAAAAAAAAAAA fixture (ED25519)\n' + exit 0 ;; + forget) + exit 0 ;; +esac +exit 0 +STUB +chmod +x "$work/bin/ssh-keygen" + +# ssh-add must never be reached here. If something calls it, that is the +# failure, so the stub records and refuses rather than doing anything. +cat >"$work/bin/ssh-add" <>"$log" +exit 2 +STUB +chmod +x "$work/bin/ssh-add" + +resolved="$(env -i PATH="$work/bin:/usr/bin:/bin" command -v ssh-keygen)" +[[ "$resolved" == "$work/bin/ssh-keygen" ]] \ + || fail "ssh-keygen resolves to $resolved, not the stub; refusing to generate anything" + +runh() { + env -i \ + PATH="$work/bin:/usr/bin:/bin" \ + HOME="$scratch" \ + XDG_RUNTIME_DIR="$work/run" \ + LANG=C LC_ALL=C \ + "$helper" "$@" +} +mkdir -p "$work/run" + +# The proof that the helper is looking at the scratch home before anything is +# written into it. +[[ "$(runh snapshot | jq -r '.directory')" == "$ssh_dir" ]] \ + || fail "the helper reports $(runh snapshot | jq -r '.directory') as its SSH directory, not the scratch one; refusing to go on" + +# Deliberately does not contain the word this contract greps for in prompts: +# the pty echoes what is typed, and a value that reads like a prompt would make +# the transcript ambiguous. +PASSPHRASE='Contract-Secret-9c1f-do-not-log-me' + +generate() { + printf '%s\n' "$PASSPHRASE" | runh generate "$@" +} + +# ── Generation, and where the passphrase went ─────────────────────────────── + +: >"$log" +result="$(generate contractkey 'panama contract fixture')" \ + || fail 'generate failed against the scratch home' +reason="$(jq -r '.error // ""' <<<"$result")" +[[ -z "$reason" ]] || fail "generating a key was refused: $reason" + +[[ -f "$ssh_dir/contractkey" ]] || fail 'generate reported success and made no private key' +[[ -f "$ssh_dir/contractkey.pub" ]] || fail 'generate made no public key' +mode="$(stat -c '%a' "$ssh_dir/contractkey")" +[[ "$mode" == "600" ]] || fail "a freshly made private key is mode $mode, which ssh refuses to use" + +grep -q 'argv: .*ed25519' "$log" || fail "ssh-keygen was not asked for an ed25519 key: $(cat "$log")" + +# The environment ssh-keygen runs in, read back out of /proc rather than out of +# the source. Two things have to be true there, and both were found the hard +# way: +# +# * SSH_ASKPASS_REQUIRE=never. This desktop sets it to "prefer", which makes +# ssh-keygen draw a graphical passphrase dialog even with a perfectly good +# terminal in front of it -- so the prompt appears on somebody's screen, the +# pty sees nothing, and generation hangs until the timeout. +# * LC_ALL=C. The prompts are matched by their words; a translated ssh-keygen +# would never be answered. +grep -q 'environ: .*SSH_ASKPASS_REQUIRE=never' "$log" \ + || fail "ssh-keygen was not told to ignore askpass, so a graphical prompt can steal the passphrase question: $(grep '^environ: ' "$log" | head -1)" +grep -q 'environ: .*LC_ALL=C' "$log" \ + || fail 'ssh-keygen was not pinned to the C locale, so its prompts may not be the ones being matched' + +# THE rule. Read from what /proc showed the process, both halves. +if grep -E '^(argv|environ): ' "$log" | grep -qF "$PASSPHRASE"; then + fail 'the passphrase appeared in ssh-keygen argv or environment, where every process on this machine can read it' +fi +grep -qF "pty-read: $PASSPHRASE" "$log" \ + || fail "the passphrase never arrived down the terminal, so ssh-keygen cannot have used it: $(cat "$log")" +[[ "$(grep -c "pty-read: $PASSPHRASE" "$log")" == "2" ]] \ + || fail 'the passphrase was not confirmed to ssh-keygen twice, so generation would have stalled at the second prompt' + +# It is not left lying around in the answer the page reads, either. +grep -qF "$PASSPHRASE" <<<"$result" \ + && fail 'the passphrase came back in the JSON the page parses' + +# Nor on disk anywhere under the scratch tree except inside the key ssh-keygen +# itself wrote. +found="$(grep -rlF "$PASSPHRASE" "$scratch" "$work/run" 2>/dev/null || true)" +[[ -z "$found" ]] || fail "the passphrase was written to disk: $found" + +# The snapshot comes back with the new key in it, so the page does not need a +# second round trip to see what it just made. +jq -e '[.keys[] | select(.name == "contractkey")] | length == 1' <<<"$result" >/dev/null \ + || fail "generate did not return a snapshot containing the new key: $result" + +# ── Overwrite is refused ──────────────────────────────────────────────────── + +original="$(cat "$ssh_dir/contractkey")" +: >"$log" +reason="$(generate contractkey 'a second time' | jq -r '.error // ""')" +[[ -n "$reason" ]] || fail 'generating over an existing key was accepted' +[[ "$(cat "$ssh_dir/contractkey")" == "$original" ]] \ + || fail 'the existing private key was overwritten; its public half is on servers that are now unreachable' +grep -q 'argv: .*ed25519' "$log" \ + && fail 'the overwrite was refused only after ssh-keygen had already run' + +# ── Names are confined ────────────────────────────────────────────────────── + +printf 'do not touch me\n' >"$work/outside/target" + +for bad in '../outside/target' '../../outside/target' 'sub/dir' '.' '..' '' \ + 'name with spaces' 'name;rm -rf' '/etc/hostname' \ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; do + : >"$log" + reason="$(generate "$bad" 'confinement probe' | jq -r '.error // ""')" + [[ -n "$reason" ]] || fail "generate accepted the key name '$bad'" + grep -q 'argv: .*ed25519' "$log" \ + && fail "the key name '$bad' reached ssh-keygen before being refused" +done +[[ "$(cat "$work/outside/target")" == "do not touch me" ]] \ + || fail 'a key name walked out of the SSH directory and overwrote a file' +[[ "$(find "$ssh_dir" -maxdepth 1 -type f | wc -l)" == "2" ]] \ + || fail "the scratch SSH directory holds $(find "$ssh_dir" -maxdepth 1 -type f | wc -l) files; a refused name made one anyway" + +# ── An empty passphrase needs the explicit flag ───────────────────────────── + +reason="$(printf '\n' | runh generate emptypass 'no passphrase' | jq -r '.error // ""')" +[[ -n "$reason" ]] || fail 'an empty passphrase was accepted without --no-passphrase' +[[ ! -f "$ssh_dir/emptypass" ]] || fail 'a key was made with no passphrase and no explicit flag' + +# ── fix-permissions, confined and effective ───────────────────────────────── + +chmod 644 "$ssh_dir/contractkey" +reason="$(runh fix-permissions contractkey | jq -r '.error // ""')" +[[ -z "$reason" ]] || fail "fixing a key's permissions was refused: $reason" +mode="$(stat -c '%a' "$ssh_dir/contractkey")" +[[ "$mode" == "600" ]] || fail "fix-permissions left the key at $mode rather than 600" + +chmod 644 "$work/outside/target" +for bad in '../outside/target' '/etc/hostname' '../../outside/target' 'sub/dir' ''; do + reason="$(runh fix-permissions "$bad" | jq -r '.error // ""')" + [[ -n "$reason" ]] || fail "fix-permissions accepted '$bad'" +done +[[ "$(stat -c '%a' "$work/outside/target")" == "644" ]] \ + || fail 'fix-permissions chmodded a file outside the SSH directory' + +# ── Nothing reached the agent ─────────────────────────────────────────────── + +grep -q '^ssh-add: ' "$log" \ + && fail "the hermetic half invoked ssh-add: $(grep '^ssh-add: ' "$log")" + +printf 'ssh keys contract: PASS (passphrase over a pty only, names confined, nothing written to ~/.ssh)\n'