diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index 1d9099a..41e2bb5 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -129,6 +129,7 @@ Rectangle { case "firewall": return firewallPage; case "printers": return printersPage; case "containers": return containersPage; + case "ssh-keys": return sshKeysPage; case "services": return healthPage; case "about": return aboutPage; default: return homePage; @@ -174,6 +175,7 @@ Rectangle { Component { id: sharingPage; SharingPage {} } Component { id: firewallPage; FirewallPage {} } Component { id: containersPage; ContainersPage {} } + Component { id: sshKeysPage; SshKeysPage {} } Component { id: printersPage; PrintersPage {} } Component { id: accessibilityPage; AccessibilityPage {} } Component { id: powerPage; PowerPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index f7c30d0..31758b7 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -30,6 +30,7 @@ Rectangle { { page: "firewall", label: "Firewall", icon: "\u{F0483}" }, { page: "printers", label: "Printers", icon: "\u{F042A}" }, { page: "containers", label: "Containers", icon: "\u{F0868}" }, + { page: "ssh-keys", label: "SSH Keys", icon: "\u{F0306}" }, { page: "home-phone", label: "Home & Phone", icon: "\u{F02DC}" }, { page: "desktop", label: "Desktop & Dock", icon: "\u{F04A4}" }, { page: "sound", label: "Sound", icon: "\u{F057E}" }, diff --git a/config/dot/quickshell/modules/settings/SshKeysPage.qml b/config/dot/quickshell/modules/settings/SshKeysPage.qml new file mode 100644 index 0000000..ddcbb9c --- /dev/null +++ b/config/dot/quickshell/modules/settings/SshKeysPage.qml @@ -0,0 +1,216 @@ +// 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. + +import Quickshell +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + objectName: "ssh-keys" + title: "SSH Keys" + lede: "The keys this machine signs in with, and the hosts it has met." + + property string confirmingForget: "" + + Component.onCompleted: if (!SshKeys.scanned) SshKeys.refresh() + + TextRow { + visible: SshKeys.lastError !== "" + label: "That did not work" + detail: SshKeys.lastError + value: "" + divider: false + } + + TextRow { + visible: SshKeys.scanned && !SshKeys.available + label: "No SSH directory" + detail: "Nothing has created ~/.ssh on this machine yet." + value: "" + divider: false + } + + // ── Keys readable by other people ─────────────────────────────────────── + + SettingsCard { + visible: SshKeys.overexposed.length > 0 + title: SshKeys.overexposed.length === 1 + ? "A private key is readable by other accounts" + : "Private keys are readable by other accounts" + subtitle: "ssh refuses to use a key with these permissions, so it will never be offered." + + Repeater { + model: SshKeys.overexposed + + delegate: TextRow { + required property var modelData + width: parent.width + label: String(modelData.name ?? "") + detail: "Mode " + String(modelData.mode ?? "") + " · should be 600" + value: "" + } + } + } + + // ── 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 + : "Public halves are safe to share; the private half never leaves this machine." + + Repeater { + model: SshKeys.keys + + delegate: SettingRow { + id: keyRow + + required property var modelData + required property int index + + 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" + : (keyRow.modelData.encrypted === false ? " · no passphrase" : "")) + divider: keyRow.index < SshKeys.keys.length - 1 + controlWidth: 220 + + Row { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: keyRow.modelData.loaded === true + text: "In the agent" + color: Theme.fgDim + font.family: Theme.fontFamily + font.pixelSize: Theme.fontSizeSmall + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: keyRow.modelData.loaded !== true + && SshKeys.agent?.available === true + text: "Add to agent" + enabled: !SshKeys.busy + onClicked: SshKeys.addToAgent(String(keyRow.modelData.path)) + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + text: "Copy public key" + onClicked: SshKeys.copyPublicKey(String(keyRow.modelData.publicPath)) + } + } + } + } + } + + // ── 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." + + Repeater { + model: SshKeys.hosts + + delegate: SettingRow { + id: hostRow + + required property var modelData + required property int index + + readonly property bool confirming: + root.confirmingForget === String(hostRow.modelData.host ?? "") + + label: hostRow.modelData.hashed === true + ? hostRow.modelData.count + " hashed entries" + : String(hostRow.modelData.host ?? "") + detail: hostRow.modelData.hashed === true + ? "Hashed on purpose, so the names cannot be read from the file" + : (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 { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + spacing: 8 + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: hostRow.confirming + text: "Forget it" + tone: "danger" + enabled: !SshKeys.busy + onClicked: { + root.confirmingForget = ""; + SshKeys.forgetHost(String(hostRow.modelData.host)); + } + } + + SettingsButton { + anchors.verticalCenter: parent.verticalCenter + visible: hostRow.modelData.hashed !== true + text: hostRow.confirming ? "Keep" : "Forget…" + enabled: !SshKeys.busy + onClicked: root.confirmingForget = hostRow.confirming + ? "" : String(hostRow.modelData.host) + } + } + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 3ef5dac..3275adf 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -5,6 +5,7 @@ AvatarPicker 1.0 AvatarPicker.qml ConnectivityPage 1.0 ConnectivityPage.qml FirewallPage 1.0 FirewallPage.qml ContainersPage 1.0 ContainersPage.qml +SshKeysPage 1.0 SshKeysPage.qml AvatarCropper 1.0 AvatarCropper.qml PickerRow 1.0 PickerRow.qml SettingsTabs 1.0 SettingsTabs.qml diff --git a/config/dot/quickshell/scripts/panama-ssh-keys b/config/dot/quickshell/scripts/panama-ssh-keys new file mode 100755 index 0000000..035265f --- /dev/null +++ b/config/dot/quickshell/scripts/panama-ssh-keys @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 + +"""SSH keys, the agent holding them, and the hosts this machine has met. + +Nothing here ever reads a private key. Fingerprints and comments come from the +matching .pub file, and whether a key is encrypted is answered by asking +ssh-keygen to derive the PUBLIC key with an empty passphrase: it succeeds for an +unencrypted key and fails for an encrypted one, and either way the only thing it +can print is public material. + +No passphrase passes through this tool at all. Adding an encrypted key to the +agent lets ssh-add prompt through the system's own askpass, which is where that +belongs -- a settings page collecting a passphrase and handing it on would be a +worse place for it to live, and putting one in argv would publish it to every +process on the machine. + + panama-ssh-keys snapshot + panama-ssh-keys agent-add PATH | agent-remove PATH + panama-ssh-keys forget-host HOST +""" + +from __future__ import annotations + +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +SSH_DIR = Path.home() / ".ssh" +KNOWN_HOSTS = SSH_DIR / "known_hosts" + +# A host as it may appear in known_hosts, including [host]:port forms. +HOST = re.compile(r"^[A-Za-z0-9._:\[\]-]{1,253}$") + +# gnome-keyring's agent, which is what runs on this desktop. Only used when the +# environment has not already named one, so an ssh-agent started by hand wins. +KEYRING_SOCKET = Path(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000")) / "keyring" / "ssh" + + +class BoundaryError(RuntimeError): + """A user-visible validation or ssh failure.""" + + +def run(command: list[str], timeout: float = 15.0, env: dict | None = None): + try: + return subprocess.run(command, capture_output=True, text=True, + timeout=timeout, env=env) + except FileNotFoundError as error: + raise BoundaryError(f"{command[0]} is not installed.") from error + except subprocess.TimeoutExpired as error: + raise BoundaryError(f"{command[0]} did not respond.") from error + + +def agent_environment() -> dict: + """The environment an ssh-add call should run in. + + A settings window inherits whatever the shell was started with, which on + this desktop does not include SSH_AUTH_SOCK -- so without this the page + would report "no agent" while one is plainly running. + """ + environment = dict(os.environ) + if not environment.get("SSH_AUTH_SOCK") and KEYRING_SOCKET.is_socket(): + environment["SSH_AUTH_SOCK"] = str(KEYRING_SOCKET) + return environment + + +def agent_state() -> dict: + environment = agent_environment() + socket = environment.get("SSH_AUTH_SOCK", "") + if not socket: + return {"available": False, "socket": "", "kind": "", "durableRemoval": False, + "fingerprints": [], "detail": "No SSH agent is running."} + + result = run(["ssh-add", "-l"], env=environment) + # ssh-add exits 1 for "no identities" and 2 for "cannot connect", which are + # very different things to report. + if result.returncode == 2: + return {"available": False, "socket": socket, "kind": "", "durableRemoval": False, + "fingerprints": [], "detail": "An agent socket exists but could not be reached."} + + fingerprints = [] + for line in (result.stdout or "").splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1].startswith("SHA256:"): + fingerprints.append(parts[1]) + # gnome-keyring's agent enumerates whatever keys it finds in ~/.ssh, so + # `ssh-add -d` reports "Identity removed" and the key is still listed a + # second later -- it comes straight back from disk. A plain ssh-agent + # removes durably. Measured on this machine rather than assumed, because an + # Unload button that reports success and changes nothing is worse than no + # button at all. + keyring = "/keyring/" in socket + return { + "available": True, + "socket": socket, + "kind": "gnome-keyring" if keyring else "ssh-agent", + "durableRemoval": not keyring, + "fingerprints": fingerprints, + "detail": "" if fingerprints else "The agent is running but holds no keys.", + } + + +def encrypted(private: Path) -> bool | None: + """Whether a private key needs a passphrase. + + Asked by deriving the public key with an empty passphrase. That reads the + file, but the only thing it can ever emit is the public half, and the answer + is not obtainable any other way without parsing key material directly. + """ + result = run(["ssh-keygen", "-y", "-P", "", "-f", str(private)], timeout=10.0) + if result.returncode == 0: + return False + detail = (result.stderr or "").lower() + if "incorrect passphrase" in detail or "load failed" in detail: + return True + return None + + +def keys(agent: dict) -> list[dict]: + if not SSH_DIR.is_dir(): + return [] + + held = set(agent.get("fingerprints") or []) + found = [] + for public in sorted(SSH_DIR.glob("*.pub")): + private = public.with_suffix("") + described = run(["ssh-keygen", "-l", "-f", str(public)], timeout=10.0) + if described.returncode != 0: + continue + parts = (described.stdout or "").split() + if len(parts) < 3: + continue + bits, fingerprint = parts[0], parts[1] + kind = parts[-1].strip("()") + comment = " ".join(parts[2:-1]).strip() + + found.append({ + "name": private.name, + "path": str(private), + "publicPath": str(public), + "type": kind, + "bits": int(bits) if bits.isdigit() else 0, + "fingerprint": fingerprint, + "comment": comment if comment != "no" else "", + "hasPrivate": private.is_file(), + "encrypted": encrypted(private) if private.is_file() else None, + "loaded": fingerprint in held, + # Read so the page can say when a key is readable by other people; + # a private key must be 0600. + "mode": oct(private.stat().st_mode & 0o777)[2:] if private.is_file() else "", + }) + return found + + +def hosts() -> list[dict]: + """Hosts in known_hosts, grouped by name. + + A hashed known_hosts cannot be listed -- that is the entire point of hashing + it -- so that is reported rather than shown as an empty list. + """ + if not KNOWN_HOSTS.is_file(): + return [] + + grouped: dict[str, dict] = {} + try: + lines = KNOWN_HOSTS.read_text(encoding="utf-8", errors="replace").splitlines() + except OSError as error: + raise BoundaryError("known_hosts could not be read.") from error + + for line in lines: + line = line.strip() + if not line or line.startswith("#"): + continue + parts = line.split() + if len(parts) < 3: + continue + names, kind = parts[0], parts[1] + if names.startswith("|1|"): + entry = grouped.setdefault("", {"host": "", "hashed": True, "types": [], "count": 0}) + entry["count"] += 1 + if kind not in entry["types"]: + entry["types"].append(kind) + continue + for name in names.split(","): + entry = grouped.setdefault(name, {"host": name, "hashed": False, "types": [], "count": 0}) + entry["count"] += 1 + if kind not in entry["types"]: + entry["types"].append(kind) + + ordered = [entry for key, entry in sorted(grouped.items()) if key != ""] + if "" in grouped: + ordered.append(grouped[""]) + return ordered + + +def snapshot() -> dict: + agent = agent_state() + return { + "available": SSH_DIR.is_dir(), + "directory": str(SSH_DIR), + "agent": agent, + "keys": keys(agent), + "hosts": hosts(), + "error": "", + } + + +def resolve_key(path: str) -> Path: + """A key path, confined to ~/.ssh. + + Resolved and compared against the directory so that a name cannot walk out + of it, and refused if it is not a file this tool put there. + """ + candidate = Path(path) + try: + resolved = candidate.resolve(strict=True) + except OSError as error: + raise BoundaryError("That key no longer exists.") from error + if resolved.parent != SSH_DIR.resolve(strict=False): + raise BoundaryError("That key is not in the SSH directory.") + if not resolved.is_file(): + raise BoundaryError("That is not a key file.") + return resolved + + +def agent_add(path: str) -> None: + key = resolve_key(path) + environment = agent_environment() + if not environment.get("SSH_AUTH_SOCK"): + raise BoundaryError("No SSH agent is running.") + # No passphrase is supplied here on purpose. An encrypted key makes ssh-add + # prompt through the system's askpass, which is the right place for it. + result = run(["ssh-add", str(key)], timeout=120.0, env=environment) + if result.returncode != 0: + detail = (result.stderr or "").strip().splitlines() + raise BoundaryError(detail[-1] if detail else "That key could not be added.") + + +def agent_remove(path: str) -> None: + key = resolve_key(path) + environment = agent_environment() + if not environment.get("SSH_AUTH_SOCK"): + raise BoundaryError("No SSH agent is running.") + if not agent_state().get("durableRemoval", True): + raise BoundaryError( + "This desktop's agent lists every key in ~/.ssh, so removing one " + "does not stick. Move the key out of ~/.ssh to stop it being offered.") + result = run(["ssh-add", "-d", str(key)], env=environment) + if result.returncode != 0: + detail = (result.stderr or "").strip().splitlines() + raise BoundaryError(detail[-1] if detail else "That key could not be removed.") + + +def forget_host(host: str) -> None: + """Drop a host's keys from known_hosts. + + The reason anyone reaches for this is a host key that changed, which is + either a rebuilt machine or something worth being alarmed about -- so the + page says which before offering the button. ssh-keygen -R rewrites the file + and keeps a .old copy itself. + """ + if not HOST.match(host or ""): + raise BoundaryError("That is not a host name.") + if not KNOWN_HOSTS.is_file(): + raise BoundaryError("There is no known_hosts file.") + result = run(["ssh-keygen", "-R", host, "-f", str(KNOWN_HOSTS)], timeout=20.0) + if result.returncode != 0: + detail = (result.stderr or "").strip().splitlines() + raise BoundaryError(detail[-1] if detail else "That host could not be removed.") + + +def main(arguments: list[str]) -> int: + try: + if arguments == ["snapshot"]: + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + if len(arguments) == 2 and arguments[0] == "agent-add": + agent_add(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "agent-remove": + agent_remove(arguments[1]) + elif len(arguments) == 2 and arguments[0] == "forget-host": + forget_host(arguments[1]) + else: + raise BoundaryError( + "Usage: panama-ssh-keys snapshot | agent-add PATH | agent-remove PATH | " + "forget-host HOST") + except BoundaryError as error: + try: + state = snapshot() + except BoundaryError: + state = {"available": False, "directory": str(SSH_DIR), + "agent": {"available": False, "socket": "", "fingerprints": [], "detail": ""}, + "keys": [], "hosts": []} + state["error"] = str(error) + print(json.dumps(state, separators=(",", ":"))) + return 0 + + print(json.dumps(snapshot(), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index c0fedc7..553a257 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -80,6 +80,10 @@ Singleton { { label: "Firewall zones", detail: "Which rules apply to each network connection", page: "firewall" }, { label: "Exposed services", detail: "What is listening and reachable from the network", page: "firewall" }, { label: "Containers", detail: "Rootless podman stacks you run for development", page: "containers" }, + { label: "SSH keys", detail: "The keys this machine signs in with", page: "ssh-keys" }, + { label: "SSH agent", detail: "Which keys are held for this session", page: "ssh-keys" }, + { label: "Known hosts", detail: "Machines this one has connected to before", page: "ssh-keys" }, + { label: "Public key", detail: "Copy the half you paste into a server", page: "ssh-keys" }, { label: "Podman", detail: "Running containers, images and volumes", page: "containers" }, { label: "Container logs", detail: "Follow what a container is printing", page: "containers" }, { label: "Reclaim container space", detail: "Remove images and volumes nothing uses", page: "containers" }, diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index b05ea15..e55fbc0 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -116,7 +116,7 @@ Singleton { } function showSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "gaming", "notifications", "screen-intelligence", "shortcuts", "mouse", "privacy", "region", "accounts", "accessibility", "power", "datetime", "applications", "updates", "storage", "snapshots", "users", "sharing", "firewall", "printers", "containers", "ssh-keys", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true; diff --git a/config/dot/quickshell/services/SshKeys.qml b/config/dot/quickshell/services/SshKeys.qml new file mode 100644 index 0000000..11c66e2 --- /dev/null +++ b/config/dot/quickshell/services/SshKeys.qml @@ -0,0 +1,103 @@ +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() + } + } +} diff --git a/config/local/share/vicinae/scripts/settings-ssh-keys.sh b/config/local/share/vicinae/scripts/settings-ssh-keys.sh new file mode 100755 index 0000000..acad41c --- /dev/null +++ b/config/local/share/vicinae/scripts/settings-ssh-keys.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Generated by scripts/panama-settings-commands -- do not edit by hand. +# @vicinae.schemaVersion 1 +# @vicinae.title Settings: SSH Keys +# @vicinae.mode silent +# @vicinae.icon ../../icons/hicolor/scalable/apps/panama-settings.svg +# @vicinae.description Open SSH Keys in Settings. +# @vicinae.keywords ["settings", "ssh keys", "ssh agent", "known hosts", "public key"] + +exec "$HOME/.config/quickshell/scripts/panama-action" settings-page ssh-keys diff --git a/tests/quickshell/ssh-keys-contract.sh b/tests/quickshell/ssh-keys-contract.sh new file mode 100755 index 0000000..60a2896 --- /dev/null +++ b/tests/quickshell/ssh-keys-contract.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash + +# SSH keys, and the two things this page must never do. +# +# The rules: +# +# 1. A private key is never read for its contents and never leaves the +# machine's disk. Fingerprints and comments come from the .pub file. +# 2. No passphrase passes through this tool. Adding an encrypted key lets +# ssh-add prompt through the system's own askpass; collecting one here and +# handing it on would be a worse place for it to live, and putting one in +# argv would publish it to every process on the machine. +# 3. Key paths are confined to ~/.ssh, resolved and compared, so a name cannot +# walk out of the directory. +# 4. A control that cannot do what it says is not offered. gnome-keyring's +# agent lists every key it finds in ~/.ssh, so `ssh-add -d` reports +# "Identity removed" and the key is still offered a second later. Measured +# on this machine: a plain ssh-agent removes durably, that one does not. +# 5. Copying a public key never splices a path into shell source. +# +# Read-only against the real configuration. Nothing here adds, removes or +# rewrites a key, an agent entry, or a known host. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-ssh-keys" +service="$repo_dir/config/dot/quickshell/services/SshKeys.qml" +page="$repo_dir/config/dot/quickshell/modules/settings/SshKeysPage.qml" + +fail() { + printf 'ssh keys contract: %s\n' "$1" >&2 + exit 1 +} + +for path in "$helper" "$service" "$page"; do + [[ -r "$path" ]] || fail "missing $path" +done +[[ -x "$helper" ]] || fail 'panama-ssh-keys is not executable' + +field() { python3 -c "import json,sys; print(json.load(sys.stdin)$1)"; } + +state="$("$helper" snapshot)" || fail 'snapshot failed' + +# ── 1. Private key material never surfaces ────────────────────────────────── +# +# Checked against the payload rather than the source: whatever the code intends, +# what actually reaches the page must not contain key material. + +printf '%s' "$state" | python3 -c " +import json, sys +raw = sys.stdin.read() +for marker in ('BEGIN OPENSSH PRIVATE KEY', 'BEGIN RSA PRIVATE KEY', 'BEGIN EC PRIVATE KEY'): + if marker in raw: + raise SystemExit(f'the snapshot contains {marker}') +state = json.loads(raw) +for key in state['keys']: + for name, value in key.items(): + if isinstance(value, str) and len(value) > 200: + raise SystemExit(f'{name} is long enough to be key material') +" || fail 'private key material reached the snapshot' + +# The helper must never read a private key for its bytes. The one place it opens +# one is ssh-keygen -y, which can only ever emit the public half. +grep -q 'read_text' "$helper" && ! grep -q 'KNOWN_HOSTS.read_text' "$helper" \ + && fail 'something reads a file directly that is not known_hosts' + +# ── 2. No passphrase anywhere ─────────────────────────────────────────────── + +grep -qE '\-N["'"'"' ]' "$helper" \ + && fail 'ssh-keygen -N appears, which would put a passphrase in argv' +grep -qi 'passphrase' "$service" && ! grep -qi 'never\|prompt' "$service" \ + && fail 'the service mentions passphrases without saying it does not handle them' + +# ── 3. Paths are confined ─────────────────────────────────────────────────── + +grep -q 'def resolve_key' "$helper" || fail 'key paths are not resolved before use' +grep -q 'resolved.parent != SSH_DIR' "$helper" \ + || fail 'a key path is not compared against the SSH directory, so it could escape' + +reason="$(printf '%s' "$("$helper" agent-add /etc/hostname)" | field "['error']")" +[[ "$reason" == *"not in the SSH directory"* ]] \ + || fail "a path outside ~/.ssh was not refused with a reason (got: $reason)" + +reason="$(printf '%s' "$("$helper" agent-add /home/nonexistent/.ssh/nope)" | field "['error']")" +[[ -n "$reason" ]] || fail 'a missing key was accepted' + +reason="$(printf '%s' "$("$helper" forget-host 'not a host name')" | field "['error']")" +[[ "$reason" == *"not a host name"* ]] \ + || fail "an invalid host was not refused with a reason (got: $reason)" + +# ── 4. A control that cannot deliver is not offered ───────────────────────── + +grep -q 'durableRemoval' "$helper" \ + || fail 'the helper does not record whether removal from this agent sticks' + +kind="$(printf '%s' "$state" | field "['agent'].get('kind','')")" +if [[ "$kind" == "gnome-keyring" ]]; then + reason="$(printf '%s' "$("$helper" agent-remove "$HOME/.ssh/id_ed25519")" | field "['error']")" + [[ "$reason" == *"does not stick"* ]] \ + || fail "removal against a keyring agent was not refused with its reason (got: $reason)" + grep -q 'does not stick' "$page" \ + || fail 'the page does not say that removing a key from this agent has no effect' +fi + +# ── 5. Copying does not build shell source from a path ────────────────────── + +grep -q 'exec wl-copy < "\$1"' "$service" \ + || fail 'the public key copy does not pass its path as an argument' + +printf 'ssh keys contract: ok\n'