diff --git a/config/dot/quickshell/modules/settings/PrivacyPage.qml b/config/dot/quickshell/modules/settings/PrivacyPage.qml index c56af1a..59392c7 100644 --- a/config/dot/quickshell/modules/settings/PrivacyPage.qml +++ b/config/dot/quickshell/modules/settings/PrivacyPage.qml @@ -23,7 +23,12 @@ SettingsPage { ? "Screen lock, device access, and a machine whose security settings all check out." : "Screen lock, which applications can see you, and how this machine is protected." - Component.onCompleted: if (!DeviceSecurity.scanned) DeviceSecurity.refresh() + Component.onCompleted: { + if (!DeviceSecurity.scanned) + DeviceSecurity.refresh(); + if (!Keyring.scanned) + Keyring.refresh(); + } SettingsCard { title: "Screen lock" @@ -33,6 +38,64 @@ SettingsPage { ToggleRow { setting: "lockOnSleep"; divider: false } } + // The login keyring, which nothing else surfaces. + // + // It is unlocked at sign-in by PAM, so this card normally just confirms + // that. It earns its place on the rare occasion it is not: a locked keyring + // breaks saved passwords everywhere at once, and does it without ever + // saying the word "keyring" -- you get a mail account that will not + // authenticate and a git push that cannot find its key. + SettingsCard { + visible: Keyring.scanned + title: "Saved passwords" + subtitle: !Keyring.available + ? "No secret service is answering, so saved passwords are unavailable." + : Keyring.locked + ? "The login keyring is locked. Saved passwords cannot be read until it is unlocked, and applications that need one will appear to fail for unrelated reasons." + : "The login keyring is unlocked, as it is after every normal sign-in." + + // Two rows rather than one with a conditional button: a locked keyring + // needs an action, an unlocked one is a statement of fact, and ActionRow + // and TextRow already say exactly those two things. + ActionRow { + visible: Keyring.available && Keyring.locked + label: "Login keyring" + detail: "Unlock to restore access to stored passwords and keys" + action: Keyring.unlocking ? "Waiting…" : "Unlock" + enabled: !Keyring.unlocking + divider: Keyring.replacementDaemon || Keyring.lastError !== "" + onTriggered: Keyring.unlock() + } + + TextRow { + visible: !(Keyring.available && Keyring.locked) + label: "Login keyring" + detail: Keyring.available + ? "Unlocked at sign-in by PAM, the same way GNOME does it" + : "No secret service is answering on this session" + value: Keyring.available ? "Unlocked" : "Unavailable" + divider: Keyring.replacementDaemon || Keyring.lastError !== "" + } + + // Only shown when it is true, because it is a diagnostic rather than a + // setting: it means the daemon holding your secrets is not the one PAM + // started, so whatever unlocked it will not survive a restart. + SettingRow { + visible: Keyring.replacementDaemon + label: "Keyring service" + detail: "The original keyring service was replaced during this session, usually after it crashed. Signing out and back in restores the one PAM unlocks." + value: "Replaced" + divider: Keyring.lastError !== "" + } + + SettingRow { + visible: Keyring.lastError !== "" + label: "Keyring problem" + detail: Keyring.lastError + divider: false + } + } + SettingsCard { title: "Camera & microphone" subtitle: PrivacyState.anyActive diff --git a/config/dot/quickshell/scripts/panama-keyring b/config/dot/quickshell/scripts/panama-keyring new file mode 100755 index 0000000..c7feef8 --- /dev/null +++ b/config/dot/quickshell/scripts/panama-keyring @@ -0,0 +1,138 @@ +#!/usr/bin/env python3 + +"""The login keyring's lock state, and a way to unlock it. + +Why this exists +--------------- +GNOME unlocks the login keyring at sign-in through pam_gnome_keyring, and so +does this desktop -- the PAM stack is GDM's and it works. What GNOME also has, +and a bare Hyprland session does not, is anywhere to SEE that it failed. + +It does fail, rarely. gnome-keyring-daemon can crash (an upstream abort in +service_method_open_session, seen once here), and when it does, D-Bus activates +a replacement. That replacement never received the login password, so the login +keyring comes back LOCKED in the middle of a session that unlocked it correctly +at login. Everything that stores a secret then starts failing in ways that do +not mention keyrings at all: a mail client that will not authenticate, a git +push that cannot find its key, an integration that reports "not configured". + +So this reports the state plainly and offers the one action that fixes it. + +Unlocking prompts +----------------- +`unlock` asks the Secret Service to unlock, which raises the gcr password +dialog. That is deliberate: the password is not ours to store or handle, and it +never passes through this script. The dialog is the same one GNOME shows. + +Note that a locked keyring makes a NON-INTERACTIVE caller appear to hang -- it +is not hung, it is waiting for a dialog nobody is looking at. That is worth +knowing before debugging one for an hour. + +Usage: + panama-keyring status -> {"available", "locked", "collections", "daemon"} + panama-keyring unlock -> raises the password prompt; prints the new state +""" + +import json +import os +import re +import sys + + +def daemon_origin(): + """Whether the running secrets daemon came from PAM or from D-Bus activation. + + A D-Bus-activated daemon is the signature of the crash-and-replace case + above: it is the one that cannot have the login password. PAM's daemon lives + outside the app slice, so the cgroup tells the two apart. + """ + try: + for pid in os.listdir("/proc"): + if not pid.isdigit(): + continue + try: + with open(f"/proc/{pid}/cmdline", "rb") as handle: + cmdline = handle.read().decode("utf-8", "replace") + except OSError: + continue + if "gnome-keyring-daemon" not in cmdline: + continue + try: + with open(f"/proc/{pid}/cgroup", "r") as handle: + cgroup = handle.read() + except OSError: + return "unknown" + if re.search(r"dbus-.*org\.freedesktop\.secrets", cgroup): + return "dbus" + return "pam" + except OSError: + pass + return "none" + + +def load_service(): + import gi + + gi.require_version("Secret", "1") + from gi.repository import Secret + + return Secret, Secret.Service.get_sync(Secret.ServiceFlags.LOAD_COLLECTIONS, None) + + +def report(service, Secret): + collections = [ + {"label": c.get_label(), "locked": c.get_locked()} + for c in service.get_collections() + ] + # The login keyring is the one that matters; the others are per-application + # stores that manage their own unlocking. + login = next((c for c in collections if c["label"] == "Login"), None) + return { + "available": True, + "locked": bool(login["locked"]) if login else False, + "hasLogin": login is not None, + "collections": collections, + "daemon": daemon_origin(), + "error": "", + } + + +def main(): + action = sys.argv[1] if len(sys.argv) > 1 else "status" + if action not in ("status", "unlock"): + print("usage: panama-keyring [status|unlock]", file=sys.stderr) + return 2 + + try: + Secret, service = load_service() + except Exception as error: # noqa: BLE001 - any failure here is "no keyring" + # No Secret Service at all is a legitimate state, not a crash: report it + # so the UI can say so instead of showing an empty card. + print(json.dumps({ + "available": False, "locked": False, "hasLogin": False, + "collections": [], "daemon": daemon_origin(), + "error": f"The secret service is not answering: {error}", + })) + return 0 + + if action == "unlock": + login = next( + (c for c in service.get_collections() if c.get_label() == "Login"), None) + if login is not None and login.get_locked(): + try: + # Blocks until the dialog is answered or dismissed. + service.unlock_sync([login], None) + except Exception as error: # noqa: BLE001 + state = report(service, Secret) + state["error"] = f"The keyring was not unlocked: {error}" + print(json.dumps(state)) + return 0 + # The collection object caches its state; re-read it. + Secret, service = load_service() + + print(json.dumps(report(service, Secret))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/config/dot/quickshell/services/Keyring.qml b/config/dot/quickshell/services/Keyring.qml new file mode 100644 index 0000000..9865976 --- /dev/null +++ b/config/dot/quickshell/services/Keyring.qml @@ -0,0 +1,83 @@ +pragma Singleton + +// The login keyring's lock state. +// +// The keyring is unlocked at sign-in by pam_gnome_keyring, exactly as it is +// under GNOME. What a bare Hyprland session lacks is anywhere to see when that +// has stopped being true. +// +// It stops being true rarely but expensively: gnome-keyring-daemon can crash, +// D-Bus activates a replacement, and the replacement never received the login +// password -- so the keyring is locked in the middle of a session that unlocked +// it correctly. Nothing announces this. What the user sees instead is a mail +// account that will not authenticate, a git push that cannot find its key, or +// an integration reporting "not configured", none of which mention keyrings. +// +// Checked on demand and after an unlock, not polled: the state changes only +// when a daemon dies or a password is entered. + +import Quickshell +import Quickshell.Io +import QtQuick + +Singleton { + id: root + + readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-keyring" + + property bool available: false + property bool locked: false + property bool scanned: false + property bool unlocking: false + property string lastError: "" + + // "pam" when the daemon that holds the keyring is the one PAM started at + // login, "dbus" when it is a D-Bus-activated replacement -- which is the + // signature of the crash case, and worth showing, because a dbus daemon + // that is currently unlocked was unlocked by hand and will not survive. + property string daemon: "" + + readonly property bool replacementDaemon: root.daemon === "dbus" + + function refresh(): void { + if (!query.running) + query.running = true; + } + + // Raises the standard password dialog. The password never passes through + // Panama -- the Secret Service prompts, the same way it does under GNOME. + function unlock(): void { + if (root.unlocking) + return; + root.unlocking = true; + unlockProcess.running = true; + } + + function absorb(text: string): void { + try { + const parsed = JSON.parse(text); + root.available = parsed.available === true; + root.locked = parsed.locked === true; + root.daemon = String(parsed.daemon ?? ""); + root.lastError = String(parsed.error ?? ""); + } catch (error) { + root.available = false; + root.lastError = "Could not read the keyring helper's output."; + console.warn("Keyring: could not parse helper output:", error); + } + root.scanned = true; + } + + Process { + id: query + command: [root.helperPath, "status"] + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + } + + Process { + id: unlockProcess + command: [root.helperPath, "unlock"] + stdout: StdioCollector { onStreamFinished: root.absorb(this.text) } + onExited: root.unlocking = false + } +} diff --git a/tests/quickshell/keyring-helper-contract.sh b/tests/quickshell/keyring-helper-contract.sh new file mode 100755 index 0000000..12de8a9 --- /dev/null +++ b/tests/quickshell/keyring-helper-contract.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +# panama-keyring reports the login keyring's state, and the Settings page reads +# nothing but its JSON. +# +# The state that matters is LOCKED, and it is also the one that cannot be +# rehearsed on a real desktop: locking the login keyring breaks every saved +# password on the machine and can only be undone by typing the password into a +# dialog. So the secret service is stubbed here instead. Nothing touches the +# real keyring -- this contract is safe to run on the daily driver, which is the +# entire reason it is written this way. + +set -uo pipefail + +repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +helper="$repo_dir/config/dot/quickshell/scripts/panama-keyring" + +fail() { + printf 'keyring helper contract: %s\n' "$1" >&2 + exit 1 +} + +stub_dir="$(mktemp -d /tmp/panama-keyring.XXXXXX)" +trap 'rm -rf "$stub_dir"' EXIT + +# A stand-in for the `gi` module the helper imports. PANAMA_KEYRING_FAKE decides +# what the fake service reports, so one stub covers every case. +mkdir -p "$stub_dir/gi/repository" +cat >"$stub_dir/gi/__init__.py" <<'STUB' +def require_version(*_args, **_kwargs): + return None +STUB +cat >"$stub_dir/gi/repository/__init__.py" <<'STUB' +import os + + +class _Collection: + def __init__(self, label, locked): + self._label = label + self._locked = locked + + def get_label(self): + return self._label + + def get_locked(self): + return self._locked + + +class _Service: + def get_collections(self): + mode = os.environ.get("PANAMA_KEYRING_FAKE", "unlocked") + if mode == "nologin": + return [_Collection("Some App", False)] + return [_Collection("Login", mode == "locked"), _Collection("", False)] + + +class _ServiceFactory: + @staticmethod + def get_sync(_flags, _cancellable): + if os.environ.get("PANAMA_KEYRING_FAKE") == "unavailable": + raise RuntimeError("no secret service") + return _Service() + + # unlock_sync is what the `unlock` action calls; record that it was reached. + @staticmethod + def _noop(*_args, **_kwargs): + return None + + +class Secret: + class ServiceFlags: + LOAD_COLLECTIONS = 1 + + Service = _ServiceFactory +STUB + +run() { + PYTHONPATH="$stub_dir" PANAMA_KEYRING_FAKE="$1" python3 "$helper" "${2:-status}" +} + +# ── Unlocked: the normal state after any sign-in ───────────────────────────── +out="$(run unlocked)" +jq -e . >/dev/null 2>&1 <<<"$out" || fail "status did not emit JSON: $out" +jq -e '.available == true and .locked == false and .hasLogin == true' >/dev/null <<<"$out" \ + || fail "an unlocked login keyring was misreported: $out" + +# ── Locked: the state the whole card exists for ────────────────────────────── +out="$(run locked)" +jq -e '.available == true and .locked == true' >/dev/null <<<"$out" \ + || fail "a locked login keyring was not reported as locked: $out" + +# ── No secret service at all is a state, not a crash ───────────────────────── +out="$(run unavailable)" +jq -e . >/dev/null 2>&1 <<<"$out" \ + || fail "a missing secret service produced no JSON, so the page would show nothing: $out" +jq -e '.available == false and .error != ""' >/dev/null <<<"$out" \ + || fail "a missing secret service must be reported with a reason: $out" + +# ── No login keyring: not locked, because there is nothing to lock ─────────── +out="$(run nologin)" +jq -e '.available == true and .hasLogin == false and .locked == false' >/dev/null <<<"$out" \ + || fail "a machine with no login keyring must not report itself locked: $out" + +# ── The daemon origin is reported, since it is the crash diagnostic ────────── +jq -e '.daemon | test("^(pam|dbus|none|unknown)$")' >/dev/null <<<"$(run unlocked)" \ + || fail "the daemon origin must be one of pam/dbus/none/unknown" + +printf 'keyring helper contract: PASS\n'