login greeter
git clone https://git.lucas.co/cce-display-manager.git
Retire the KeePassXC unlock chain for a TPM-sealed gnome-keyring
The old chain unlocked on 76 of 102 attempts over two weeks (~75%), taking
12-60s when it did work. Three causes, two of them structural:
- It raced itself. cce-keepassxc.service's ExecStartPost and
cce-keyring-unlock.service both invoked cce-keyring-unlock, and KeePassXC
*drops* concurrent unlock requests ("Ignoring unlock request ... because of
running unlock action") rather than queueing them. Each requester's 12s
timeout then counted as a failed attempt and retried, colliding again.
- The vault lives on Dropbox, which autostarts at login and syncs the file
while Argon2 is hashing it: "openDatabase did not return within 15s".
- A Qt/Wayland GUI app was in the login critical path at all. No amount of
retry tuning fixes that, which is why this kept getting re-tuned.
Replaced by gnome-keyring: headless, socket-activated, no Wayland, no Dropbox,
started already unlocked from a password sealed to the TPM. Measured ~1.3s.
Login here is by fingerprint, so PAM never sees a password and
pam_gnome_keyring cannot unlock anything -- hence sealing rather than PAM.
The D-Bus activation file is retargeted rather than deleted. Dropping it would
fall back to the stock file's bare `gnome-keyring-daemon --start`, which spawns
a daemon OUTSIDE the unit -- missing the drop-in that feeds it the password, so
a locked keyring plus a second daemon racing for the bus name. That is the same
two-provider nondeterminism this commit exists to remove.
Three traps are recorded in the README because each one broke an attempt:
systemd-creds cannot be used (non-root it delegates to a polkit-gated service,
so it works interactively and fails at login); this must never be validated
from an interactive shell, which has an agent and TTY the login path lacks; and
Restart=no is load-bearing, since a drop-in replacing ExecStart inherits the
stock Restart=on-failure -- that combination produced 99 restarts in ~90s and
hung the greeter.
ccebuild does not install drop-ins (unit_files() matches only
.service/.target/.timer/.socket/.path), so tpm-unlock.conf is hand-installed
for now and the README carries the command.
The cce-keyring-unlock* Rust binaries and scripts/cce-keyring-selftest are now
orphaned but left in place; removing them is a separate call, and they are the
route back if this ever needs reverting.
Co-Authored-By: Claude Opus 5 <[email protected]>
README.md | 45 +++++++++++++
cce-keepassxc.service | 32 ---------
cce-keyring-unlock.service | 15 -----
cce-keyring-unlockd.service | 43 ------------
dbus/org.freedesktop.secrets.service | 29 ++++----
scripts/cce-gnome-keyring-enroll | 78 ++++++++++++++++++++++
scripts/cce-gnome-keyring-start | 50 ++++++++++++++
.../gnome-keyring-daemon.service.d/tpm-unlock.conf | 13 ++++
8 files changed, 203 insertions(+), 102 deletions(-)
diff --git a/README.md b/README.md
index 3515c41..488b96c 100644
--- a/README.md
+++ b/README.md
@@ -35,3 +35,48 @@ Run in an existing Wayland environment (for testing/development):
```bash
cargo run
```
+
+## Login keyring
+
+Login here is by fingerprint, so PAM never sees a password and
+`pam_gnome_keyring` cannot unlock anything. The keyring password is instead
+sealed to the machine's TPM and fed to the daemon at startup.
+
+- `scripts/cce-gnome-keyring-enroll` — one-time: seals a random password with
+ **tpm2-tools** into `~/.config/cce/keyring-seal.{pub,priv}` and creates the
+ gnome-keyring `login` keyring with it. Needs the `tss` group and
+ `tpm2-tools`.
+- `scripts/cce-gnome-keyring-start` — unseals and pipes the password into
+ `gnome-keyring-daemon --foreground --unlock`, as a single process.
+- `systemd/gnome-keyring-daemon.service.d/tpm-unlock.conf` — points the stock
+ unit at that script and sets `Restart=no`.
+- `dbus/org.freedesktop.secrets.service` — routes bus activation to the same
+ unit, so there is only ever one provider.
+
+**`ccebuild` does not install drop-ins** — `unit_files()` matches only
+`.service/.target/.timer/.socket/.path`. Install this one by hand:
+
+```bash
+install -Dm644 systemd/gnome-keyring-daemon.service.d/tpm-unlock.conf \
+ ~/.config/systemd/user/gnome-keyring-daemon.service.d/tpm-unlock.conf
+```
+
+Three traps, each of which broke a previous attempt:
+
+- **`systemd-creds` is not usable here.** Run by a non-root user it does not
+ touch the TPM; it delegates to a polkit-gated root service. It succeeds in an
+ interactive session and fails at login with
+ `io.systemd.InteractiveAuthenticationRequired`. tpm2-tools talks to
+ `/dev/tpmrm0` directly via the `tss` group, so it needs no agent.
+- **Never validate this from an interactive shell** — it has a polkit agent and
+ a TTY that the login path does not. Use
+ `systemd-run --user --pipe --wait --setenv=PATH=...`, which reproduces the
+ login environment and the failure above.
+- **`Restart=no` is load-bearing.** A drop-in replacing `ExecStart` inherits the
+ stock `Restart=on-failure`; with a credential that could not decrypt at login
+ that produced 99 restarts in ~90s and hung the greeter. Losing secrets is
+ recoverable, an unusable login is not.
+
+Clients need `--password-store=gnome-libsecret`: Chromium picks its backend
+from `XDG_CURRENT_DESKTOP`, does not recognise `cce`, and silently falls back
+to plaintext even when the keyring is healthy.
diff --git a/cce-keepassxc.service b/cce-keepassxc.service
deleted file mode 100644
index cefde0b..0000000
--- a/cce-keepassxc.service
+++ /dev/null
@@ -1,32 +0,0 @@
-[Unit]
-Description=KeePassXC (Secret Service provider for the cce session)
-Documentation=man:keepassxc(1)
-After=graphical-session.target
-PartOf=graphical-session.target
-
-[Service]
-Type=simple
-# Don't launch (or relaunch) into a session that is gone: at logout the
-# compositor dies first, KeePassXC exits 1 on the broken Wayland connection,
-# and Restart then spun Qt against a dead display — four attempts and a
-# coredump per logout. A failed condition skips the start cleanly instead.
-ExecCondition=/bin/sh -c '[ -n "$WAYLAND_DISPLAY" ] && [ -S "$XDG_RUNTIME_DIR/$WAYLAND_DISPLAY" ]'
-ExecStart=/usr/bin/keepassxc
-# A restarted KeePassXC comes back with the database LOCKED, and
-# cce-keyring-unlock.service is a login-time oneshot that has long since run —
-# so unlock here too, on every start. At login this just does the same work
-# marginally earlier and that unit then no-ops.
-#
-# Bounded deliberately: ExecStartPost counts against TimeoutStartSec (90s by
-# default here), and overrunning it makes systemd fail the start and kill the
-# whole unit — KeePassXC with it — which Restart=on-failure would then loop.
-# 45s clears the ~3s warm unlock with room to spare. Leading `-`: a failed
-# unlock must never take KeePassXC down with it.
-ExecStartPost=-/usr/bin/timeout 45 %h/.local/bin/cce-keyring-unlock
-# The provider every keyring client waits on: bring it straight back if it
-# dies rather than leaving the session without a Secret Service.
-Restart=on-failure
-RestartSec=2
-
-[Install]
-WantedBy=graphical-session.target
diff --git a/cce-keyring-unlock.service b/cce-keyring-unlock.service
deleted file mode 100644
index 889017d..0000000
--- a/cce-keyring-unlock.service
+++ /dev/null
@@ -1,15 +0,0 @@
-[Unit]
-Description=CCE keyring unlock request (KeePassXC)
-# Ordered after the provider: an openDatabase sent while KeePassXC is still
-# bootstrapping is accepted and silently lost.
-After=graphical-session.target cce-keepassxc.service
-Wants=cce-keepassxc.service
-PartOf=graphical-session.target
-
-[Service]
-Type=oneshot
-ExecStart=%h/.local/bin/cce-keyring-unlock
-Environment=RUST_LOG=info
-
-[Install]
-WantedBy=graphical-session.target
diff --git a/cce-keyring-unlockd.service b/cce-keyring-unlockd.service
deleted file mode 100644
index 52230f3..0000000
--- a/cce-keyring-unlockd.service
+++ /dev/null
@@ -1,43 +0,0 @@
-[Unit]
-Description=CCE keyring unlock daemon (KeePassXC auto-unlock)
-Documentation=file:///usr/local/sbin/cce-keyring-unlockd
-
-[Service]
-Type=simple
-ExecStart=/usr/local/sbin/cce-keyring-unlockd
-Restart=on-failure
-RestartSec=2
-Environment=RUST_LOG=info
-
-# Sandboxing. The daemon needs: bind + chmod its socket in /run, connect to
-# /run/user/<uid>/bus (after seteuid to the caller — CAP_SETUID/SETGID),
-# readlink /proc/<pid>/exe of the user's KeePassXC (CAP_SYS_PTRACE), read
-# /etc/cce/keyring-unlock, and spawn systemd-creds (TPM via /dev/tpmrm0, so
-# no PrivateDevices). Everything else is fenced off.
-NoNewPrivileges=yes
-CapabilityBoundingSet=CAP_SETUID CAP_SETGID CAP_SYS_PTRACE
-ProtectSystem=strict
-# /run/user is listed explicitly: it is its own tmpfs, and the submount must
-# be exempt from the strict read-only pass for connect() to the user bus.
-ReadWritePaths=/run /run/user
-# NO ProtectHome: every variant of it ALSO covers /run/user (systemd mounts
-# an inaccessible dir over it — EACCES on the bus socket broke every unlock,
-# and it shadows any ReadWritePaths entry). /home stays read-only via
-# ProtectSystem=strict, which is protection enough here.
-ProtectHome=no
-PrivateTmp=yes
-ProtectKernelTunables=yes
-ProtectKernelModules=yes
-ProtectKernelLogs=yes
-ProtectControlGroups=yes
-ProtectClock=yes
-ProtectHostname=yes
-RestrictAddressFamilies=AF_UNIX
-RestrictNamespaces=yes
-RestrictRealtime=yes
-RestrictSUIDSGID=yes
-LockPersonality=yes
-MemoryDenyWriteExecute=yes
-
-[Install]
-WantedBy=multi-user.target
diff --git a/dbus/org.freedesktop.secrets.service b/dbus/org.freedesktop.secrets.service
index 2617cfa..0868ed4 100644
--- a/dbus/org.freedesktop.secrets.service
+++ b/dbus/org.freedesktop.secrets.service
@@ -1,17 +1,22 @@
-# Shadows /usr/share/dbus-1/services/org.freedesktop.secrets.service (which
-# would activate gnome-keyring) so the Secret Service is KeePassXC — and
-# specifically KeePassXC under cce-keepassxc.service, whose ExecStartPost
-# unlocks the database.
+# Shadows /usr/share/dbus-1/services/org.freedesktop.secrets.service so that a
+# bus-activated Secret Service converges on the SAME unit the session starts,
+# rather than on the stock file's bare
+# `gnome-keyring-daemon --start --components=secrets`.
+#
+# That distinction is the whole point. The stock Exec would spawn a keyring
+# daemon OUTSIDE gnome-keyring-daemon.service, so it would miss the drop-in
+# that feeds it the TPM-sealed password — i.e. a LOCKED keyring, and a second
+# daemon racing the unit's own for the bus name. Two daemons fighting over
+# org.freedesktop.secrets is exactly the nondeterminism that made the previous
+# KeePassXC-based chain unlock on only ~75% of logins.
#
# SystemdService, not a bare Exec: the first client to touch the bus name at
-# login (cce-mail's keyring lookup beats graphical-session.target) was
-# activating a bare keepassxc that then owned the name outside the unit — the
-# unit's own instance found the name taken, handed off (KeePassXC is
-# single-instance) and exited, leaving the provider with no Restart coverage
-# and no unlock-on-respawn. With SystemdService both start paths converge on
-# the one unit. Exec is the spec-required fallback and must never fire on a
-# systemd user bus.
+# login (cce-mail's keyring lookup beats graphical-session.target) would
+# otherwise activate a provider outside the unit, which then owns the name
+# while the unit's instance finds it taken and exits. With SystemdService both
+# start paths converge on the one unit. Exec is the spec-required fallback and
+# must never fire on a systemd user bus.
[D-BUS Service]
Name=org.freedesktop.secrets
Exec=/usr/bin/false
-SystemdService=cce-keepassxc.service
+SystemdService=gnome-keyring-daemon.service
diff --git a/scripts/cce-gnome-keyring-enroll b/scripts/cce-gnome-keyring-enroll
new file mode 100755
index 0000000..34c7def
--- /dev/null
+++ b/scripts/cce-gnome-keyring-enroll
@@ -0,0 +1,78 @@
+#!/bin/sh
+# cce-gnome-keyring-enroll — one-time setup for the auto-unlocked login keyring.
+#
+# Seals a random keyring password to this machine's TPM using tpm2-tools
+# directly, and creates the gnome-keyring "login" keyring with it.
+#
+# Why tpm2-tools and NOT systemd-creds: `systemd-creds` running as a non-root
+# user does not touch the TPM itself — it delegates to a root varlink service
+# that is polkit-gated. That works in an interactive session and fails at
+# login with io.systemd.InteractiveAuthenticationRequired, which is exactly
+# how this broke on 2026-08-29. tpm2-tools talks to /dev/tpmrm0 directly via
+# the `tss` group, so it needs no agent and no authorization prompt.
+#
+# The seal is bound to the TPM but deliberately NOT to PCRs: PCR policy would
+# invalidate the blob on every kernel or firmware update, trading a disk-theft
+# risk for routine breakage. The threat this defends against is a stolen disk,
+# which TPM binding alone already covers.
+#
+# Safe to re-run: refuses to clobber an existing seal unless --force.
+
+set -eu
+
+DIR="$HOME/.config/cce"
+PUB="$DIR/keyring-seal.pub"
+PRIV="$DIR/keyring-seal.priv"
+KEYRINGS="$HOME/.local/share/keyrings"
+FORCE=0
+[ "${1:-}" = "--force" ] && FORCE=1
+
+command -v tpm2_createprimary >/dev/null 2>&1 || {
+ echo "error: tpm2-tools not installed — run: sudo pacman -S tpm2-tools" >&2; exit 1; }
+[ -r /dev/tpmrm0 ] || {
+ echo "error: cannot read /dev/tpmrm0 — this user must be in the 'tss' group" >&2; exit 1; }
+if [ -e "$PRIV" ] && [ "$FORCE" -eq 0 ]; then
+ echo "error: $PRIV already exists; re-run with --force to replace it" >&2; exit 1
+fi
+
+mkdir -p "$DIR" "$KEYRINGS"; chmod 700 "$DIR" "$KEYRINGS"
+umask 077
+TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT INT TERM
+
+# 32 bytes of kernel entropy, base64 — never typed, never shown.
+PASSWORD=$(head -c 32 /dev/urandom | base64 -w0)
+
+# The owner-hierarchy primary key is derived deterministically from the TPM's
+# seed, so the same template regenerates the identical key at every boot. That
+# avoids persisting a handle (and the eviction bookkeeping that comes with it).
+echo "creating TPM primary key..."
+tpm2_createprimary -Q -C o -g sha256 -G ecc -c "$TMP/primary.ctx"
+
+echo "sealing keyring password to the TPM..."
+printf '%s' "$PASSWORD" | tpm2_create -Q -C "$TMP/primary.ctx" \
+ -a "fixedtpm|fixedparent|userwithauth|noda" \
+ -i - -u "$PUB" -r "$PRIV"
+chmod 600 "$PUB" "$PRIV"
+
+# Verify the unseal round trip BEFORE destroying the old keyring: if the TPM
+# cannot give the password back, stopping here leaves everything as it was.
+tpm2_load -Q -C "$TMP/primary.ctx" -u "$PUB" -r "$PRIV" -c "$TMP/seal.ctx"
+BACK=$(tpm2_unseal -c "$TMP/seal.ctx")
+[ "$BACK" = "$PASSWORD" ] || { echo "error: TPM round trip failed" >&2; exit 1; }
+echo "verified TPM round trip"
+
+# Recreate the login keyring under the new password. The old one is kept
+# aside, never deleted.
+systemctl --user stop gnome-keyring-daemon.service 2>/dev/null || true
+if [ -s "$KEYRINGS/login.keyring" ]; then
+ mv "$KEYRINGS/login.keyring" "$KEYRINGS/login.keyring.superseded-$(date +%Y%m%d%H%M%S)"
+ echo "set aside previous login.keyring"
+fi
+systemctl --user start gnome-keyring-daemon.service
+sleep 3
+systemctl --user is-active --quiet gnome-keyring-daemon.service \
+ || { echo "error: gnome-keyring-daemon did not stay running" >&2; exit 1; }
+echo "created and unlocked the login keyring"
+
+printf 'login' > "$KEYRINGS/default"
+echo "set 'login' as the default keyring"
diff --git a/scripts/cce-gnome-keyring-start b/scripts/cce-gnome-keyring-start
new file mode 100755
index 0000000..3a37047
--- /dev/null
+++ b/scripts/cce-gnome-keyring-start
@@ -0,0 +1,50 @@
+#!/bin/sh
+# cce-gnome-keyring-start — run gnome-keyring-daemon, unlocked, as ONE process.
+#
+# Two failures this shape is deliberately built around, both hit on 2026-08-29:
+#
+# 1. `gnome-keyring-daemon --unlock` does NOT exit — it daemonizes into a
+# second keyring daemon. Two daemons racing for org.freedesktop.secrets is
+# the same class of bug that made the old KeePassXC setup unpredictable.
+# So the password goes into the daemon's own stdin at startup instead.
+#
+# 2. `systemd-creds decrypt` as a non-root user delegates to a polkit-gated
+# root service. It succeeds in an interactive session and fails at login
+# (io.systemd.InteractiveAuthenticationRequired), which wedged the greeter.
+# tpm2-tools talks to /dev/tpmrm0 directly via the `tss` group — no agent,
+# no prompt, no authorization step that can be absent at boot.
+#
+# Login here is by fingerprint, so PAM never sees a password and cannot unlock
+# anything; the keyring password is sealed to the TPM instead.
+
+set -eu
+
+DIR="${CCE_KEYRING_DIR:-$HOME/.config/cce}"
+PUB="$DIR/keyring-seal.pub"
+PRIV="$DIR/keyring-seal.priv"
+
+[ -r "$PRIV" ] || { echo "no TPM seal at $PRIV; run cce-gnome-keyring-enroll" >&2; exit 1; }
+
+TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT INT TERM
+
+# The TPM serializes, so a request racing another user of it fails
+# transiently. Retry the whole unseal, primary key included.
+n=0
+until PW=$( { tpm2_createprimary -Q -C o -g sha256 -G ecc -c "$TMP/primary.ctx" \
+ && tpm2_load -Q -C "$TMP/primary.ctx" -u "$PUB" -r "$PRIV" -c "$TMP/seal.ctx" \
+ && tpm2_unseal -c "$TMP/seal.ctx"; } 2>&1 ); do
+ n=$((n + 1))
+ if [ "$n" -ge 5 ]; then
+ echo "TPM unseal failed after $n attempts: $PW" >&2
+ exit 1
+ fi
+ sleep 1
+done
+
+# --unlock reads the password from stdin and creates the login keyring if it is
+# missing. --foreground keeps this under systemd instead of forking away.
+printf '%s' "$PW" | exec gnome-keyring-daemon \
+ --foreground \
+ --components=pkcs11,secrets \
+ --unlock \
+ --control-directory="${XDG_RUNTIME_DIR:?XDG_RUNTIME_DIR unset}/keyring"
diff --git a/systemd/gnome-keyring-daemon.service.d/tpm-unlock.conf b/systemd/gnome-keyring-daemon.service.d/tpm-unlock.conf
new file mode 100644
index 0000000..afc722b
--- /dev/null
+++ b/systemd/gnome-keyring-daemon.service.d/tpm-unlock.conf
@@ -0,0 +1,13 @@
+# Start the keyring already unlocked, as a single process. See
+# cce-gnome-keyring-start for why the stock two-step (start locked, then
+# `--unlock`) is wrong here: the second command daemonizes.
+[Service]
+ExecStart=
+ExecStart=/home/lsgalante/.local/bin/cce-gnome-keyring-start
+
+# NEVER restart-loop. The stock unit sets Restart=on-failure; combined with a
+# credential this service could not decrypt at login, that produced 99 restarts
+# in ~90s, spawning a transient systemd-creds@ unit each time, and wedged the
+# login on vt1 (2026-08-29). A keyring that fails to start must fail QUIETLY:
+# losing secrets is recoverable, an unusable login is not.
+Restart=no