187 lines
7.7 KiB
QML
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()
|
|
}
|
|
}
|
|
}
|