Files

486 lines
20 KiB
QML

// 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.
Item { width: 1; height: agentNote.visible ? 10 : 0 }
SettingsNote {
id: agentNote
visible: SshKeys.agent?.available === true && !SshKeys.durableRemoval
headline: "Removing a key from this agent does not stick"
body: "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."
}
}
// ── 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()
}
}
}