Files
Panama/config/dot/quickshell/services/SshKeys.qml
T
Gabriel Brown 52e2a83a78 Add an SSH Keys page, and refuse the one control that would lie
The page shows which keys exist, what the agent is holding, and the hosts this
machine has met, with a two-press forget for a host whose key has changed.

Nothing here reads private key material. Fingerprints and comments come from the
.pub file, and "does this key need a passphrase" is answered by asking
ssh-keygen to derive the public half with an empty one -- it succeeds for an
unencrypted key and fails for an encrypted one, and either way the only thing it
can emit is public. The contract checks that against the payload that actually
reaches the page rather than against the source, because what the code intends
and what it ships are different claims.

Unloading a key from the agent is refused, with its reason. On this desktop
`ssh-add -d` prints "Identity removed" and the key is still offered a second
later: gnome-keyring's agent lists every key it finds in ~/.ssh, so a removed
one comes straight back off disk. That was measured rather than assumed -- a
plain ssh-agent removes durably, this one does not -- and a button reporting
success while changing nothing is worse than no button. The page says so and
names the thing that does work: move the file out of ~/.ssh.

SSH_AUTH_SOCK is not set in a normal shell here, so a naive check reports "no
agent" while one is plainly running. The helper falls back to the keyring
socket, and an agent started by hand still wins. That gap is the same one that
made reaching these servers awkward in the first place.

Generating a key is deliberately absent. A passphrase cannot reach ssh-keygen
without going somewhere it should not -- -N puts it in argv, which every process
on the machine can read -- and driving the prompt over a pty did not work.
Offering to generate an unencrypted key instead would be a downgrade dressed as
a feature, so the page does not offer to generate at all.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
2026-08-20 10:17:33 -04:00

104 lines
3.7 KiB
QML

pragma Singleton
// SSH keys, the agent holding them, and the hosts this machine has met.
//
// Nothing here ever sees a private key or a passphrase. Adding an encrypted key
// makes ssh-add prompt through the system's own askpass, which is where a
// passphrase belongs -- a settings page collecting one and passing it along
// would be a worse place for it to live.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-ssh-keys"
property bool available: false
property string directory: ""
property var agent: ({})
property var keys: []
property var hosts: []
property bool scanned: false
property string lastError: ""
readonly property bool busy: query.running || mutation.running
readonly property int loadedCount: root.keys.filter(key => key.loaded === true).length
// Keys readable by anyone but their owner. ssh refuses to use these, so a
// page that stayed quiet about it would leave someone wondering why a key
// that plainly exists is never offered.
readonly property var overexposed: root.keys.filter(key =>
key.mode !== "" && key.mode !== "600" && key.mode !== "400")
function refresh(): void {
if (query.running)
return;
query.command = [root.helperPath, "snapshot"];
query.running = true;
}
function absorb(text: string): void {
try {
const parsed = JSON.parse(text);
root.available = parsed.available === true;
root.directory = String(parsed.directory ?? "");
root.agent = parsed.agent ?? ({});
root.keys = Array.isArray(parsed.keys) ? parsed.keys : [];
root.hosts = Array.isArray(parsed.hosts) ? parsed.hosts : [];
root.lastError = String(parsed.error ?? "");
} catch (error) {
root.lastError = "Could not read the SSH configuration.";
console.warn("SshKeys: could not parse helper output:", error);
}
root.scanned = true;
}
function run(arguments: var): void {
if (mutation.running)
return;
root.lastError = "";
mutation.command = [root.helperPath].concat(arguments);
mutation.running = true;
}
// Adding an encrypted key prompts, and the prompt is the system's, so this
// is allowed a long time before it is considered stuck.
function addToAgent(path: string): void { root.run(["agent-add", path]); }
// The public half, onto the clipboard. Safe to copy by definition -- it is
// the thing you paste into a server. The path arrives as $1 rather than
// being spliced into shell source, so a name with a space or a quote in it
// cannot become part of the command.
function copyPublicKey(publicPath: string): void {
if (publicPath === "" || copier.running)
return;
copier.command = ["sh", "-c", 'exec wl-copy < "$1"', "qs-ssh-keys", publicPath];
copier.running = true;
}
function forgetHost(host: string): void { root.run(["forget-host", host]); }
Component.onCompleted: root.refresh()
Process { id: copier }
Process {
id: query
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
Process {
id: mutation
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
}
}