Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this machine has met, with a two-press forget for a host whose key has changed. Nothing here reads private key material. Fingerprints and comments come from the .pub file, and "does this key need a passphrase" is answered by asking ssh-keygen to derive the public half with an empty one -- it succeeds for an unencrypted key and fails for an encrypted one, and either way the only thing it can emit is public. The contract checks that against the payload that actually reaches the page rather than against the source, because what the code intends and what it ships are different claims. Unloading a key from the agent is refused, with its reason. On this desktop `ssh-add -d` prints "Identity removed" and the key is still offered a second later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed one comes straight back off disk. That was measured rather than assumed -- a plain ssh-agent removes durably, this one does not -- and a button reporting success while changing nothing is worse than no button. The page says so and names the thing that does work: move the file out of ~/.ssh. SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no agent" while one is plainly running. The helper falls back to the keyring socket, and an agent started by hand still wins. That gap is the same one that made reaching these servers awkward in the first place. Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen without going somewhere it should not -- -N puts it in argv, which every process on the machine can read -- and driving the prompt over a pty did not work. Offering to generate an unencrypted key instead would be a downgrade dressed as a feature, so the page does not offer to generate at all. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -129,6 +129,7 @@ Rectangle {
|
||||
case "firewall": return firewallPage;
|
||||
case "printers": return printersPage;
|
||||
case "containers": return containersPage;
|
||||
case "ssh-keys": return sshKeysPage;
|
||||
case "services": return healthPage;
|
||||
case "about": return aboutPage;
|
||||
default: return homePage;
|
||||
@@ -174,6 +175,7 @@ Rectangle {
|
||||
Component { id: sharingPage; SharingPage {} }
|
||||
Component { id: firewallPage; FirewallPage {} }
|
||||
Component { id: containersPage; ContainersPage {} }
|
||||
Component { id: sshKeysPage; SshKeysPage {} }
|
||||
Component { id: printersPage; PrintersPage {} }
|
||||
Component { id: accessibilityPage; AccessibilityPage {} }
|
||||
Component { id: powerPage; PowerPage {} }
|
||||
|
||||
@@ -30,6 +30,7 @@ Rectangle {
|
||||
{ page: "firewall", label: "Firewall", icon: "\u{F0483}" },
|
||||
{ page: "printers", label: "Printers", icon: "\u{F042A}" },
|
||||
{ page: "containers", label: "Containers", icon: "\u{F0868}" },
|
||||
{ page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" },
|
||||
{ page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" },
|
||||
{ page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" },
|
||||
{ page: "sound", label: "Sound", icon: "\u{F057E}" },
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
// 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.
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
|
||||
objectName: "ssh-keys"
|
||||
title: "SSH Keys"
|
||||
lede: "The keys this machine signs in with, and the hosts it has met."
|
||||
|
||||
property string confirmingForget: ""
|
||||
|
||||
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: SshKeys.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.scanned && !SshKeys.available
|
||||
label: "No SSH directory"
|
||||
detail: "Nothing has created ~/.ssh on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
}
|
||||
|
||||
// ── Keys readable by other people ───────────────────────────────────────
|
||||
|
||||
SettingsCard {
|
||||
visible: SshKeys.overexposed.length > 0
|
||||
title: SshKeys.overexposed.length === 1
|
||||
? "A private key is readable by other accounts"
|
||||
: "Private keys are readable by other accounts"
|
||||
subtitle: "ssh refuses to use a key with these permissions, so it will never be offered."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.overexposed
|
||||
|
||||
delegate: TextRow {
|
||||
required property var modelData
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
|
||||
value: ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
: "Public halves are safe to share; the private half never leaves this machine."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.keys
|
||||
|
||||
delegate: SettingRow {
|
||||
id: keyRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
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"
|
||||
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
|
||||
divider: keyRow.index < SshKeys.keys.length - 1
|
||||
controlWidth: 220
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded === true
|
||||
text: "In the agent"
|
||||
color: Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSizeSmall
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded !== true
|
||||
&& SshKeys.agent?.available === true
|
||||
text: "Add to agent"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: SshKeys.addToAgent(String(keyRow.modelData.path))
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Copy public key"
|
||||
onClicked: SshKeys.copyPublicKey(String(keyRow.modelData.publicPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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."
|
||||
|
||||
Repeater {
|
||||
model: SshKeys.hosts
|
||||
|
||||
delegate: SettingRow {
|
||||
id: hostRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
readonly property bool confirming:
|
||||
root.confirmingForget === String(hostRow.modelData.host ?? "")
|
||||
|
||||
label: hostRow.modelData.hashed === true
|
||||
? hostRow.modelData.count + " hashed entries"
|
||||
: String(hostRow.modelData.host ?? "")
|
||||
detail: hostRow.modelData.hashed === true
|
||||
? "Hashed on purpose, so the names cannot be read from the file"
|
||||
: (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 {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
spacing: 8
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: hostRow.confirming
|
||||
text: "Forget it"
|
||||
tone: "danger"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: {
|
||||
root.confirmingForget = "";
|
||||
SshKeys.forgetHost(String(hostRow.modelData.host));
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: hostRow.modelData.hashed !== true
|
||||
text: hostRow.confirming ? "Keep" : "Forget…"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: root.confirmingForget = hostRow.confirming
|
||||
? "" : String(hostRow.modelData.host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ AvatarPicker 1.0 AvatarPicker.qml
|
||||
ConnectivityPage 1.0 ConnectivityPage.qml
|
||||
FirewallPage 1.0 FirewallPage.qml
|
||||
ContainersPage 1.0 ContainersPage.qml
|
||||
SshKeysPage 1.0 SshKeysPage.qml
|
||||
AvatarCropper 1.0 AvatarCropper.qml
|
||||
PickerRow 1.0 PickerRow.qml
|
||||
SettingsTabs 1.0 SettingsTabs.qml
|
||||
|
||||
Reference in New Issue
Block a user