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() } } }