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
@@ -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()
}
}
}