458 lines
19 KiB
QML
458 lines
19 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)
|
|
|
|
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: SshKeys.hosts
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|