// SSH keys, and what this machine can reach with them. // // 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 import qs.config import qs.services SettingsPage { id: root objectName: "ssh-keys" title: "SSH Keys" lede: "The keys this machine signs in with. Private keys are never read — only their public halves and their locks." property string confirmingForget: "" // ── 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: "" // 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) // Known hosts, folded at the house cap. A machine that has been used for a // year has dozens of these, and an unbounded list turns the card below the // keys into most of the page -- the same reason the Wi-Fi list folds. property bool showAllHosts: false readonly property int hostCap: 6 readonly property var shownHosts: { const list = SshKeys.hosts; if (root.showAllHosts || list.length <= root.hostCap) return list; return list.slice(0, root.hostCap); } readonly property int hiddenHostCount: SshKeys.hosts.length - root.shownHosts.length function resetForm(): void { root.showingGenerator = false; root.newName = ""; root.newComment = ""; root.passphrase = ""; root.passphraseAgain = ""; nameField.clear(); commentField.clear(); passphraseField.clear(); confirmField.clear(); } 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 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 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: SettingRow { id: exposedRow required property var modelData required property int index width: parent.width 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 ?? "")) } } } } // ── Keys ──────────────────────────────────────────────────────────────── SettingsCard { title: SshKeys.keys.length === 1 ? "Your key" : "Your keys" subtitle: SshKeys.keys.length === 0 ? "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 delegate: SettingRow { id: keyRow 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 set" : (keyRow.modelData.encrypted === false ? " · no passphrase" : "")) divider: true controlWidth: 290 Row { anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter 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 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)) } } } } 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 ?? "") // Armed, the row stops showing the fingerprint and says what // the confirming press will actually do -- including the case // where it will do nothing, which is owed BEFORE the press // rather than as an error afterwards. detail: removeHeld.armed ? (SshKeys.durableRemoval ? "The agent stops offering this key until it is added again. The key file in ~/.ssh is untouched." : "This agent lists every key in ~/.ssh, so it will refuse: removal here does not stick. Move the key out of ~/.ssh instead.") : String(heldRow.modelData.fingerprint ?? "") controlWidth: 190 ConfirmAction { id: removeHeld anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter actionId: "agent-remove:" + String(heldRow.modelData.path ?? "") armText: "Remove…" confirmText: "Remove it" enabled: !SshKeys.busy onConfirmed: 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 { title: "Known hosts" 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: root.shownHosts delegate: SettingRow { id: hostRow required property var modelData 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 ?? "") 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(", ")) 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) } } } } SettingRow { visible: root.hiddenHostCount > 0 || (root.showAllHosts && SshKeys.hosts.length > root.hostCap) label: root.showAllHosts ? "Show fewer hosts" : root.hiddenHostCount + (root.hiddenHostCount === 1 ? " more host" : " more hosts") detail: root.showAllHosts ? "" : "Folded to keep the list short — every one of them is still in known_hosts" activatable: true onActivated: root.showAllHosts = !root.showAllHosts } 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() } } }