Surface the login keyring, and offer to unlock it

The keyring is already unlocked at sign-in exactly as GNOME does it --
pam_gnome_keyring is in GDM's stack and the journal confirms it works
("gnome-keyring-daemon started properly and unlocked keyring"). So there
was no configuration bug to fix. What a bare Hyprland session lacks is
anywhere to see when that has stopped being true.

It stops being true rarely and expensively. gnome-keyring-daemon crashed
once on this machine -- an upstream abort in service_method_open_session,
with a core dump -- and D-Bus then activated a replacement. That
replacement never received the login password, so the keyring was locked
in the middle of a session that had unlocked it correctly at login.
Nothing announces this. What you see 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. That is the
same root cause as the Home Assistant token failure earlier.

Privacy & Security now shows the state, offers an Unlock action that
raises the standard password dialog, and reports when the daemon holding
your secrets is a D-Bus replacement rather than PAM's -- because a
replacement that is currently unlocked was unlocked by hand and will not
survive a restart. The password never passes through Panama.

The contract stubs the secret service rather than touching the real one:
locking the login keyring breaks every saved password on the machine and
can only be undone by typing the password into a dialog, so it is not
something a test suite may do to a daily driver. Verified it catches a
helper that misreports locked as unlocked, and one that crashes instead
of reporting a missing service.

Worth recording: a locked keyring makes a NON-INTERACTIVE caller appear
to hang. It is not hung -- it is waiting on a dialog nobody is looking
at, which is exactly how the earlier secret-tool investigation lost an
hour.

Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
Gabriel Brown
2026-08-18 10:34:41 -04:00
parent f0321432b4
commit e4409ed6aa
4 changed files with 393 additions and 1 deletions
@@ -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
+138
View File
@@ -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())
@@ -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
}
}