Show every answer the portal remembers, and give SSH keys their missing half
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -1,12 +1,16 @@
|
||||
// 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.
|
||||
// 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
|
||||
@@ -18,29 +22,82 @@ SettingsPage {
|
||||
|
||||
objectName: "ssh-keys"
|
||||
title: "SSH Keys"
|
||||
lede: "The keys this machine signs in with, and the hosts it has met."
|
||||
lede: "The keys this machine signs in with. Private keys are never read — only their public halves and their locks."
|
||||
|
||||
property string confirmingForget: ""
|
||||
|
||||
Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh()
|
||||
// ── 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: ""
|
||||
|
||||
TextRow {
|
||||
visible: SshKeys.lastError !== ""
|
||||
label: "That did not work"
|
||||
detail: SshKeys.lastError
|
||||
value: ""
|
||||
divider: false
|
||||
// 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();
|
||||
}
|
||||
|
||||
TextRow {
|
||||
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
|
||||
label: "No SSH directory"
|
||||
detail: "Nothing has created ~/.ssh on this machine yet."
|
||||
value: ""
|
||||
divider: false
|
||||
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
|
||||
@@ -52,55 +109,45 @@ SettingsPage {
|
||||
Repeater {
|
||||
model: SshKeys.overexposed
|
||||
|
||||
delegate: TextRow {
|
||||
delegate: SettingRow {
|
||||
id: exposedRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
width: parent.width
|
||||
label: String(modelData.name ?? "")
|
||||
detail: "Mode " + String(modelData.mode ?? "") + " · should be 600"
|
||||
value: ""
|
||||
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 ?? ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
? "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
|
||||
|
||||
@@ -110,16 +157,20 @@ SettingsPage {
|
||||
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 protected"
|
||||
? " · passphrase set"
|
||||
: (keyRow.modelData.encrypted === false ? " · no passphrase" : ""))
|
||||
divider: keyRow.index < SshKeys.keys.length - 1
|
||||
controlWidth: 220
|
||||
divider: true
|
||||
controlWidth: 290
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
@@ -128,7 +179,16 @@ SettingsPage {
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: keyRow.modelData.loaded === true
|
||||
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
|
||||
@@ -152,14 +212,175 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 ?? "")
|
||||
detail: String(heldRow.modelData.fingerprint ?? "")
|
||||
controlWidth: 110
|
||||
|
||||
SettingsButton {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Remove"
|
||||
enabled: !SshKeys.busy
|
||||
onClicked: 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 {
|
||||
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."
|
||||
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
|
||||
@@ -168,11 +389,11 @@ SettingsPage {
|
||||
id: hostRow
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
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 ?? "")
|
||||
@@ -181,7 +402,6 @@ SettingsPage {
|
||||
: (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 {
|
||||
@@ -212,5 +432,14 @@ SettingsPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user