// 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) } } } } } }