Files

187 lines
7.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. A passphrase crosses this service in
// exactly one place -- creating a key -- and it crosses without ever being
// stored: it is held only for as long as it takes to write it to the helper's
// standard input, which is a channel no other process can read, and cleared in
// the same handler. It is never put in a command, because argv is public to
// every process on the machine, and never written to a file. The helper types it
// at ssh-keygen over a terminal it opens for the purpose.
//
// Adding an EXISTING encrypted key is different and collects nothing: ssh-add
// prompts through the system's own askpass, which is where that belongs.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
readonly property string helperPath: Quickshell.env("PANAMA_SSH_KEYS_HELPER")
|| 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: ""
// Held between the call and the moment the helper starts, and no longer.
// Never read back out, never logged, never part of a command line.
property string pendingPassphrase: ""
readonly property bool busy: query.running || mutation.running || generator.running
// Generating is its own state: it can take a moment, it is the one action
// here that creates something, and a page wants to say so specifically
// rather than greying out the whole card.
readonly property bool generating: generator.running
readonly property int loadedCount: root.keys.filter(key => key.loaded === true).length
// Whether removing a key from this machine's agent actually sticks.
// gnome-keyring's agent enumerates whatever it finds in ~/.ssh, so a
// removal reports success and the key is back a second later. The helper
// measures this rather than assuming it, and refuses the removal with its
// reason -- which arrives here as lastError, in the helper's own words.
readonly property bool durableRemoval: root.agent?.durableRemoval === true
readonly property string agentKind: String(root.agent?.kind ?? "")
// Set for a moment after a public key reaches the clipboard, so the page can
// say what happened without having to know how long a clipboard lasts.
property string copiedKey: ""
// 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]); }
// Unloading a key. On an agent where that does not stick the helper refuses
// and says why, and the page shows that sentence rather than a shorter one
// of its own -- the reason is the useful part.
function removeFromAgent(path: string): void { root.run(["agent-remove", path]); }
// ed25519, always. The passphrase goes down the helper's standard input and
// nowhere else; see the note at the top of this file.
function generate(name: string, comment: string, passphrase: string): void {
if (generator.running)
return;
root.lastError = "";
root.pendingPassphrase = passphrase;
generator.command = [root.helperPath, "generate", name, comment];
generator.stdinEnabled = true;
generator.running = true;
}
// 0600, so ssh will use the key at all. Takes the key's name rather than a
// path: the helper resolves it inside ~/.ssh and refuses anything that
// resolves elsewhere, which is not a check to hand to the caller.
function fixPermissions(name: string): void { root.run(["fix-permissions", name]); }
// 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;
root.copiedKey = publicPath;
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
onExited: exitCode => { if (exitCode !== 0) root.copiedKey = ""; }
}
// The clipboard notice is transient, and says so by disappearing.
Timer {
running: root.copiedKey !== ""
interval: 12000
onTriggered: root.copiedKey = ""
}
// Its own Process because it is the only one with anything on stdin, and
// because the passphrase handover has to happen in onStarted -- there is
// nothing to write to before then.
Process {
id: generator
stdinEnabled: true
onStarted: {
generator.write(root.pendingPassphrase + "\n");
// Held for as long as it takes to hand over, and no longer.
root.pendingPassphrase = "";
// Closing stdin is what tells the helper the passphrase is complete.
generator.stdinEnabled = false;
}
// The helper answers with the fresh snapshot plus whatever went wrong,
// so a created key lands on the page without a second read.
stdout: StdioCollector { onStreamFinished: root.absorb(this.text) }
stderr: StdioCollector {
onStreamFinished: if (this.text.trim() !== "") root.lastError = this.text.trim()
}
onExited: root.pendingPassphrase = ""
}
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()
}
}
}