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