Show every answer the portal remembers, and give SSH keys their missing half

Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
Gabriel Brown
2026-08-24 19:26:56 -04:00
parent 4ec8bd94d9
commit 6f0ce639d9
25 changed files with 3622 additions and 408 deletions
+1 -1
View File
@@ -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
@@ -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
}
}
}
@@ -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")
}
}
}
@@ -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
@@ -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
}
}
}
@@ -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()
}
}
}
@@ -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
+205 -63
View File
@@ -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
+269
View File
@@ -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 = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<xbel version="1.0"\n'
' xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"\n'
' xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info"\n'
'>\n'
'</xbel>\n'
)
BOOKMARK = re.compile(rb"<bookmark\b")
class BoundaryError(RuntimeError):
"""A user-visible validation or filesystem failure."""
def home() -> 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:]))
+278 -7
View File
@@ -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:
+82 -15
View File
@@ -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()
@@ -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" },
+90 -7
View File
@@ -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
+114
View File
@@ -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()
}
}
}
@@ -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
@@ -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
@@ -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 `<xbel>` 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.
@@ -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.
+18
View File
@@ -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
@@ -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
+412 -62
View File
@@ -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 <token>` 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'
+354
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<xbel version="1.0"
xmlns:bookmark="http://www.freedesktop.org/standards/desktop-bookmarks"
xmlns:mime="http://www.freedesktop.org/standards/shared-mime-info">
<bookmark href="file:///home/fixture/one.txt" added="2026-01-01T00:00:00Z"
modified="2026-01-01T00:00:00Z" visited="2026-01-01T00:00:00Z">
<info><metadata owner="http://freedesktop.org">
<mime:mime-type type="text/plain"/>
</metadata></info>
</bookmark>
<bookmark href="file:///home/fixture/two.txt" added="2026-01-02T00:00:00Z"
modified="2026-01-02T00:00:00Z" visited="2026-01-02T00:00:00Z">
<info><metadata owner="http://freedesktop.org">
<mime:mime-type type="text/plain"/>
</metadata></info>
</bookmark>
</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 <xbel>")
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'
+71 -2
View File
@@ -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'
+6 -4
View File
@@ -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"
+367 -18
View File
@@ -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" <<STUB
#!/usr/bin/env bash
log="$log"
{
printf 'argv: %s\n' "\$(tr '\\0' ' ' </proc/\$\$/cmdline)"
printf 'environ: %s\n' "\$(tr '\\0' ' ' </proc/\$\$/environ)"
} >>"\$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" <<STUB
#!/usr/bin/env bash
printf 'ssh-add: %s\n' "\$*" >>"$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'