git.lucas.co / cce-display-manager
login greeter
git clone https://git.lucas.co/cce-display-manager.git

commit90ee4a1daa7185b84b872fc55c444fcecaefd476
parent81450cebc0
authorLucas Galante <[email protected]>
date2026-08-31 12:45
Retire the KeePassXC selftest checks and the inert unlock binaries

The 2026-08-31 stranded-secrets audit flagged two pieces of residue from
the retired KeePassXC unlock chain (replaced 2026-08-29 by TPM-unlocked
gnome-keyring, cce@81450ce):

- cce-keyring-selftest still tested the old architecture, reporting
  "cce-keepassxc.service not active" and "no successful unlock recorded"
  on a healthy system. Its checks now match the current design: the
  Secret Service unlocked, gnome-keyring-daemon.service active, the bus
  name owned by the UNIT's daemon (cgroup check — a --unlock outside the
  unit daemonizes into a second locked daemon, the failure class that
  broke the old chain), no TPM unseal failures, plus the unchanged
  compositor-barrier and claude-desktop checks. This copy also absorbs
  the CCE_LOG runtime-dir move the cce-compositor duplicate had picked
  up (cce@522ce13); that duplicate is removed in cce-compositor.

- The three cce-keyring-unlock* binaries (client, root daemon, setup)
  built and installed with no unit referencing them. Removed, along
  with src/keyring.rs (their shared module) and the zbus/tokio/chrono
  dependencies nothing else used. The only mentions elsewhere in the
  workspace are historical prose (cce-ui process.rs comment,
  cce-secrets KEYRING-SYNC.md) and ccebuild's install-system pairs,
  trimmed in cce-compositor.

Co-Authored-By: Claude Fable 5 <[email protected]>

 Cargo.toml                          |   3 -
 scripts/cce-keyring-selftest        |  78 ++++++---
 src/bin/cce-keyring-unlock-setup.rs | 152 -----------------
 src/bin/cce-keyring-unlock.rs       | 112 -------------
 src/bin/cce-keyring-unlockd.rs      | 317 ------------------------------------
 src/keyring.rs                      | 165 -------------------
 6 files changed, 52 insertions(+), 775 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 52ed2c1..8c5079b 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,7 +6,6 @@ edition = "2021"
 [dependencies]
 cce-ui = { path = "../cce-ui" }
 pam = "0.7.0"
-zbus = "5"
 pam-sys = "0.5.6"
 libc = "0.2"
 users = "0.8.1"
@@ -15,9 +14,7 @@ calloop = "0.13.0"
 calloop-wayland-source = "0.3.0"
 wayland-client = { version = "0.31", features = ["system"] }
 xkeysym = "0.2"
-tokio = { version = "1", features = ["full"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
-chrono = "0.4"
 log = "0.4"
 env_logger = "0.11"
diff --git a/scripts/cce-keyring-selftest b/scripts/cce-keyring-selftest
index 92c9697..9d92ec8 100755
--- a/scripts/cce-keyring-selftest
+++ b/scripts/cce-keyring-selftest
@@ -1,9 +1,22 @@
 #!/usr/bin/env bash
 # cce-keyring-selftest — did this login's keyring chain work?
 #
-# Written 2026-08-16 alongside the login-race fix (cce@9d454c4,
-# cce-display-manager@d7cc6ec/17bfb9d). Run it after logging in. Everything it
-# reads is durable (journal + /tmp/cce.log), so it is fine to run much later.
+# Written 2026-08-16 for the KeePassXC unlock chain; rewritten 2026-08-31 for
+# the design that replaced it (cce-display-manager@81450ce): gnome-keyring owns
+# org.freedesktop.secrets and is started ALREADY UNLOCKED by
+# cce-gnome-keyring-start, which unseals the keyring password from the TPM
+# (tpm2-tools) and feeds it to the daemon's stdin — one process, wired in by
+# the drop-in ~/.config/systemd/user/gnome-keyring-daemon.service.d/
+# tpm-unlock.conf. Login is by fingerprint, so PAM never sees a password: the
+# TPM seal is the only unlock path, which is why it deserves a self-test.
+#
+# Run it after logging in. The journal half is durable, but the compositor log
+# lives in the per-user runtime dir (moved out of /tmp in cce@522ce13), which
+# is cleared at logout — so run this before logging out; afterwards the
+# barrier half of the answer is simply gone. The /tmp fallback covers a
+# session started by a pre-move startcce.
+CCE_LOG="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}/cce/cce.log"
+[ -f "$CCE_LOG" ] || CCE_LOG=/tmp/cce.log
 
 pass=0 fail=0
 ok()   { printf '  \033[32mPASS\033[0m  %s\n' "$1"; pass=$((pass+1)); }
@@ -43,40 +56,53 @@ locked=$(busctl --user get-property org.freedesktop.secrets \
     org.freedesktop.Secret.Collection Locked 2>/dev/null)
 [ "$locked" = "b false" ] && ok "keyring is unlocked" || bad "keyring is ${locked:-unreachable}"
 
-# 2. Did KeePassXC come up from its own unit rather than session restore?
-if systemctl --user is-active --quiet cce-keepassxc.service; then
-    ok "cce-keepassxc.service active"
+# 2. Is the daemon running from its unit? The unit is the only start path that
+#    goes through cce-gnome-keyring-start, i.e. the only one that unlocks.
+if systemctl --user is-active --quiet gnome-keyring-daemon.service; then
+    ok "gnome-keyring-daemon.service active"
+    kstart=$(systemctl --user show gnome-keyring-daemon.service \
+        -p InactiveExitTimestamp --value)
+    [ -n "$kstart" ] && info "unit up since ${kstart#* }"
 else
-    bad "cce-keepassxc.service not active"
+    bad "gnome-keyring-daemon.service not active"
 fi
 
-# 3. Unlock latency: KeePassXC start -> daemon confirmed. Was 46s pre-fix.
-uline=$(jl -u cce-keyring-unlockd.service | grep 'unlocked and verified' | tail -1)
-if [ -n "$uline" ]; then
-    # InactiveExit, not ActiveEnter: ExecStartPost does the unlocking, so the
-    # unit only reaches "active" AFTER the unlock — measuring from ActiveEnter
-    # reports a negative latency.
-    kstart=$(systemctl --user show cce-keepassxc.service -p InactiveExitTimestamp --value)
-    utime=$(printf '%s\n' "$uline" | awk '{print $1, $2, $3}')
-    if [ -n "$kstart" ]; then
-        secs=$(( $(date -d "$utime" +%s) - $(date -d "$(printf '%s' "$kstart" | cut -d' ' -f2-3)" +%s) ))
-        info "unlocked ${secs}s after KeePassXC started (was 46s before the fix)"
-    fi
-    ok "keyring unlock confirmed this session"
+# 3. Does the UNIT's daemon own the bus name? `gnome-keyring-daemon --unlock`
+#    outside the unit daemonizes into a second, LOCKED daemon, and the stock
+#    D-Bus activation Exec would spawn one too (see cce-gnome-keyring-start and
+#    the dbus/ shadow). Two daemons racing for org.freedesktop.secrets is the
+#    nondeterminism that broke the old chain — so check the owner's cgroup,
+#    not just that the name is taken. (Cgroup, not MainPID: the unit's main
+#    process is the start script's shell; the daemon is its child.)
+owner=$(busctl --user status org.freedesktop.secrets 2>/dev/null | sed -n 's/^PID=//p')
+if [ -z "$owner" ]; then
+    bad "org.freedesktop.secrets has no owner"
+elif grep -q 'gnome-keyring-daemon\.service' "/proc/$owner/cgroup" 2>/dev/null; then
+    ok "org.freedesktop.secrets owned by the unit's daemon (pid $owner)"
+else
+    bad "org.freedesktop.secrets owner (pid $owner) is OUTSIDE gnome-keyring-daemon.service — rogue second daemon?"
+fi
+
+# 4. Did the TPM unseal go cleanly? cce-gnome-keyring-start retries transient
+#    TPM contention itself and only logs after giving up, so any hit here is a
+#    real failure — and with Restart=no (deliberate: a restart loop wedged the
+#    login once) a failed start stays failed quietly until something asks.
+if jl --user -u gnome-keyring-daemon.service | grep -q 'TPM unseal failed'; then
+    bad "TPM unseal failed this session (journalctl --user -u gnome-keyring-daemon.service)"
 else
-    bad "no successful unlock recorded this session"
+    ok "no TPM unseal failures this session"
 fi
 
-# 4. Did the compositor barrier engage, and for how long?
-bline=$(grep -h -E 'Keyring (unlocked after|still locked)|No Secret Service' /tmp/cce.log 2>/dev/null | tail -1)
+# 5. Did the compositor barrier engage, and for how long?
+bline=$(grep -h -E 'Keyring (unlocked after|still locked)|No Secret Service' "$CCE_LOG" 2>/dev/null | tail -1)
 case "$bline" in
     *"Keyring unlocked after"*) ok "barrier released: ${bline##*] }" ;;
     *"still locked"*)           bad "barrier hit its timeout: ${bline##*] }" ;;
     *"No Secret Service"*)      bad "no Secret Service seen: ${bline##*] }" ;;
-    *) info "no barrier line in /tmp/cce.log (no gated app restored?)" ;;
+    *) info "no barrier line in $CCE_LOG (no gated app restored?)" ;;
 esac
 
-# 5. The actual question: is claude-desktop alive and loaded? The failing one
+# 6. The actual question: is claude-desktop alive and loaded? The failing one
 #    died at 73MB; a working one is hundreds of MB.
 mainpid=$(pgrep -f 'claude-desktop --password-store' | head -1)
 if [ -n "$mainpid" ]; then
@@ -91,7 +117,7 @@ else
     bad "claude-desktop not running"
 fi
 
-# 6. Did it need a relaunch? More than one launch this session = the restored
+# 7. Did it need a relaunch? More than one launch this session = the restored
 #    one died and had to be started again by hand — the original symptom.
 scopes=$(jl --user | grep -c 'Started app-claude-desktop-.*\.scope')
 if [ "$scopes" -le 1 ]; then
diff --git a/src/bin/cce-keyring-unlock-setup.rs b/src/bin/cce-keyring-unlock-setup.rs
deleted file mode 100644
index bacac21..0000000
--- a/src/bin/cce-keyring-unlock-setup.rs
+++ /dev/null
@@ -1,152 +0,0 @@
-// cce-keyring-unlock-setup — register a KeePassXC database for automatic
-// unlock at login by cce-keyring-unlockd.
-//
-//   sudo cce-keyring-unlock-setup <user> <database.kdbx> [keyfile]
-//
-// Prompts for the database password (twice) and stores it encrypted with
-// systemd-creds (TPM-backed where available) under
-// /etc/cce/keyring-unlock/<uid>/, readable by root only.
-
-#[path = "../keyring.rs"]
-#[allow(dead_code)]
-mod keyring;
-
-use keyring::*;
-use std::os::unix::fs::PermissionsExt;
-use std::process::{Command, Stdio};
-
-fn main() {
-    if let Err(e) = run() {
-        eprintln!("error: {e}");
-        std::process::exit(1);
-    }
-}
-
-fn run() -> Result<(), Box<dyn std::error::Error>> {
-    if unsafe { libc::geteuid() } != 0 {
-        return Err("must run as root (sudo)".into());
-    }
-    let args: Vec<String> = std::env::args().collect();
-    if args.len() < 3 || args.len() > 4 {
-        return Err(format!("usage: {} <user> <database.kdbx> [keyfile]", args[0]).into());
-    }
-    let user = users::get_user_by_name(&args[1])
-        .ok_or_else(|| format!("unknown user '{}'", args[1]))?;
-    let uid = user.uid();
-    let database = std::fs::canonicalize(&args[2])
-        .map_err(|e| format!("database '{}': {e}", args[2]))?;
-    if !database.is_file() {
-        return Err(format!("'{}' is not a file", database.display()).into());
-    }
-    let keyfile = if args.len() == 4 {
-        std::fs::canonicalize(&args[3])
-            .map_err(|e| format!("keyfile '{}': {e}", args[3]))?
-            .display()
-            .to_string()
-    } else {
-        String::new()
-    };
-
-    let name: String = database
-        .file_stem()
-        .map(|s| s.to_string_lossy().into_owned())
-        .unwrap_or_else(|| "database".into())
-        .chars()
-        .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
-        .collect();
-
-    let mut password = ask_password(&format!("Password for {}: ", database.display()))?;
-    let mut confirm = ask_password("Type the password again: ")?;
-    if password != confirm {
-        zeroize(&mut password);
-        zeroize(&mut confirm);
-        return Err("passwords do not match".into());
-    }
-    zeroize(&mut confirm);
-
-    let dir = store_dir_for(uid);
-    std::fs::create_dir_all(&dir)?;
-    std::fs::set_permissions(STORE_DIR, std::fs::Permissions::from_mode(0o700))?;
-    std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
-
-    let cred_file = dir.join(format!("{name}.cred"));
-    encrypt_password(&name, &password, &cred_file)?;
-    zeroize(&mut password);
-
-    let conf_file = dir.join(format!("{name}.conf"));
-    std::fs::write(
-        &conf_file,
-        format!(
-            "database={}\nkeyfile={}\ncred={name}.cred\n",
-            database.display(),
-            keyfile
-        ),
-    )?;
-    std::fs::set_permissions(&conf_file, std::fs::Permissions::from_mode(0o600))?;
-    std::fs::set_permissions(&cred_file, std::fs::Permissions::from_mode(0o600))?;
-
-    println!(
-        "registered {} for uid {uid}; cce-keyring-unlockd will unlock it at login",
-        database.display()
-    );
-    Ok(())
-}
-
-/// Prompt on the controlling terminal with echo off.
-fn ask_password(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
-    use std::io::{BufRead, BufReader, Write};
-    use std::os::unix::io::AsRawFd;
-    let mut tty_out = std::fs::OpenOptions::new().write(true).open("/dev/tty")?;
-    let tty_in = std::fs::OpenOptions::new().read(true).open("/dev/tty")?;
-    write!(tty_out, "{prompt}")?;
-    tty_out.flush()?;
-
-    let fd = tty_in.as_raw_fd();
-    let mut termios = unsafe { std::mem::zeroed::<libc::termios>() };
-    if unsafe { libc::tcgetattr(fd, &mut termios) } != 0 {
-        return Err(std::io::Error::last_os_error().into());
-    }
-    let saved = termios;
-    termios.c_lflag &= !libc::ECHO;
-    termios.c_lflag |= libc::ICANON;
-    if unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &termios) } != 0 {
-        return Err(std::io::Error::last_os_error().into());
-    }
-    let mut line = String::new();
-    let read_result = BufReader::new(&tty_in).read_line(&mut line);
-    unsafe { libc::tcsetattr(fd, libc::TCSAFLUSH, &saved) };
-    let _ = writeln!(tty_out);
-    read_result?;
-
-    let s = line.trim_end_matches(['\n', '\r']).to_string();
-    if s.is_empty() {
-        return Err("empty password".into());
-    }
-    Ok(s)
-}
-
-fn encrypt_password(
-    name: &str,
-    password: &str,
-    cred_file: &std::path::Path,
-) -> Result<(), Box<dyn std::error::Error>> {
-    use std::io::Write;
-    let mut child = Command::new("systemd-creds")
-        .arg("encrypt")
-        .arg(format!("--name={name}"))
-        .arg("-")
-        .arg(cred_file)
-        .stdin(Stdio::piped())
-        .stderr(Stdio::inherit())
-        .spawn()?;
-    child
-        .stdin
-        .take()
-        .ok_or("no stdin for systemd-creds")?
-        .write_all(password.as_bytes())?;
-    let status = child.wait()?;
-    if !status.success() {
-        return Err("systemd-creds encrypt failed".into());
-    }
-    Ok(())
-}
diff --git a/src/bin/cce-keyring-unlock.rs b/src/bin/cce-keyring-unlock.rs
deleted file mode 100644
index 227ffbc..0000000
--- a/src/bin/cce-keyring-unlock.rs
+++ /dev/null
@@ -1,112 +0,0 @@
-// cce-keyring-unlock — per-session client that asks cce-keyring-unlockd to
-// unlock this user's KeePassXC database(s).
-//
-// Runs as a oneshot user service in graphical-session.target. Waits for
-// KeePassXC to appear on the session bus (the compositor's session restore
-// launches it), then pings the root daemon over its socket. All secret
-// handling stays in the daemon.
-
-#[path = "../keyring.rs"]
-#[allow(dead_code)]
-mod keyring;
-
-use keyring::*;
-use std::io::{BufRead, BufReader, Write};
-use std::os::unix::net::UnixStream;
-use std::time::Duration;
-
-// Session restore can be slow on a busy login; be patient before concluding
-// KeePassXC just isn't part of this session.
-const APPEAR_WAIT: Duration = Duration::from_secs(120);
-const REQUEST_ATTEMPTS: u32 = 3;
-
-fn main() {
-    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
-
-    let conn = match session_bus() {
-        Ok(c) => c,
-        Err(e) => {
-            log::error!("cannot connect to session bus: {e}");
-            std::process::exit(1);
-        }
-    };
-
-    if !wait_for_name(&conn, KEEPASSXC_DBUS_NAME, APPEAR_WAIT) {
-        log::info!("KeePassXC did not appear within {APPEAR_WAIT:?}; nothing to unlock");
-        return;
-    }
-    match default_collection_locked(&conn) {
-        Ok(false) => {
-            log::info!("database already unlocked");
-            return;
-        }
-        _ => {}
-    }
-
-    for attempt in 1..=REQUEST_ATTEMPTS {
-        match request_unlock() {
-            Ok(reply) if reply == "ok" => {
-                log::info!("daemon confirmed unlock");
-                dismiss_orphaned_unlock_dialog();
-                return;
-            }
-            Ok(reply) => log::warn!("attempt {attempt}: daemon said: {reply}"),
-            Err(e) => log::warn!("attempt {attempt}: {e}"),
-        }
-        std::thread::sleep(Duration::from_secs(4));
-    }
-    log::error!("giving up after {REQUEST_ATTEMPTS} attempts");
-    std::process::exit(1);
-}
-
-/// Apps that hit the Secret Service during the first seconds of login make
-/// KeePassXC pop its standalone "Unlock Database" prompt; when the database
-/// is then unlocked over D-Bus that dialog is orphaned and never dismisses
-/// itself (keepassxc#9297). Ask the compositor to close it. Best effort —
-/// outside a cce session there is nothing to do.
-fn dismiss_orphaned_unlock_dialog() {
-    let Ok(display) = std::env::var("WAYLAND_DISPLAY") else {
-        return;
-    };
-    let sock = format!("/tmp/cce-{display}.sock");
-    // The dialog may still be mid-spawn right after the unlock; check twice.
-    for wait in [2, 5] {
-        std::thread::sleep(Duration::from_secs(wait));
-        let Ok(stream) = UnixStream::connect(&sock) else {
-            return;
-        };
-        let _ = stream.set_read_timeout(Some(Duration::from_secs(3)));
-        if writeln!(&stream, "close-window org.keepassxc.KeePassXC unlock database").is_err() {
-            return;
-        }
-        let mut reply = String::new();
-        let _ = BufReader::new(stream).read_line(&mut reply);
-        if reply.starts_with("ok") {
-            log::info!("closed orphaned KeePassXC unlock dialog");
-            return;
-        }
-    }
-}
-
-fn session_bus() -> Result<zbus::blocking::Connection, Box<dyn std::error::Error>> {
-    if let Ok(c) = zbus::blocking::Connection::session() {
-        return Ok(c);
-    }
-    // User units usually have DBUS_SESSION_BUS_ADDRESS set, but fall back to
-    // the standard per-user bus path if not.
-    let uid = unsafe { libc::getuid() };
-    let addr = format!("unix:path=/run/user/{uid}/bus");
-    Ok(zbus::blocking::connection::Builder::address(addr.as_str())?.build()?)
-}
-
-fn request_unlock() -> Result<String, Box<dyn std::error::Error>> {
-    let stream = UnixStream::connect(SOCKET_PATH)
-        .map_err(|e| format!("cannot reach {SOCKET_PATH} (is cce-keyring-unlockd running?): {e}"))?;
-    // The daemon itself waits for KeePassXC readiness and verifies the
-    // unlock, so give it a generous window before assuming it's wedged.
-    stream.set_read_timeout(Some(Duration::from_secs(120)))?;
-    writeln!(&stream, "unlock")?;
-    let mut reply = String::new();
-    BufReader::new(stream).read_line(&mut reply)?;
-    Ok(reply.trim().to_string())
-}
diff --git a/src/bin/cce-keyring-unlockd.rs b/src/bin/cce-keyring-unlockd.rs
deleted file mode 100644
index 1a35483..0000000
--- a/src/bin/cce-keyring-unlockd.rs
+++ /dev/null
@@ -1,317 +0,0 @@
-// cce-keyring-unlockd — root daemon that unlocks a user's KeePassXC database
-// on request from that user's session.
-//
-// Listens on /run/cce-keyring-unlock.sock. A client sends "unlock\n"; the
-// daemon resolves the caller's uid via SO_PEERCRED, decrypts the passwords
-// registered for that uid (systemd-creds, see cce-keyring-unlock-setup),
-// verifies the process owning the KeePassXC D-Bus name really is
-// /usr/bin/keepassxc belonging to that uid, calls openDatabase, and then
-// polls the Secret Service collection until it reports unlocked — retrying
-// the openDatabase call if the unlock was swallowed (which happens when
-// KeePassXC is still starting up). Replies "ok" or "error: <reason>".
-
-#[path = "../keyring.rs"]
-#[allow(dead_code)]
-mod keyring;
-
-use keyring::*;
-use std::io::{BufRead, BufReader, Write};
-use std::os::unix::net::{UnixListener, UnixStream};
-use std::time::Duration;
-
-const OPEN_ATTEMPTS: u32 = 4;
-const VERIFY_WINDOW: Duration = Duration::from_secs(12);
-const NAME_WAIT: Duration = Duration::from_secs(30);
-const READY_WAIT: Duration = Duration::from_secs(20);
-/// Ceiling on one request, kept under the client's 120 s read timeout so the
-/// client hears a verdict instead of timing out and retrying into a daemon
-/// that is still working on the previous attempt.
-const REQUEST_BUDGET: Duration = Duration::from_secs(100);
-/// Per-call ceilings. No D-Bus call here is safe to wait on forever: see
-/// [`with_timeout`].
-const OPEN_TIMEOUT: Duration = Duration::from_secs(15);
-const PROP_TIMEOUT: Duration = Duration::from_secs(5);
-
-/// Run one D-Bus call on a helper thread and give up on it after `timeout`.
-///
-/// `openDatabase` does not return while KeePassXC is showing its own modal
-/// unlock prompt, and a suspend inside that window stretches the call across
-/// the entire sleep — one such call once blocked this daemon for nine and a
-/// half hours, and every later request with it. Threading whole requests is
-/// not an option (`connect_user_bus` swaps euid, which is process-wide on
-/// Linux), so each call is bounded instead. A timed-out helper is abandoned
-/// rather than killed: it owns its own connection and exits if the call ever
-/// returns.
-fn with_timeout<T: Send + 'static>(
-    label: &str,
-    timeout: Duration,
-    f: impl FnOnce() -> T + Send + 'static,
-) -> Result<T, String> {
-    let (tx, rx) = std::sync::mpsc::channel();
-    std::thread::spawn(move || {
-        let _ = tx.send(f());
-    });
-    rx.recv_timeout(timeout)
-        .map_err(|_| format!("{label} did not return within {timeout:?}"))
-}
-
-/// Remaining slice of a deadline, or None once it has passed.
-fn remaining(deadline: std::time::Instant) -> Option<Duration> {
-    deadline.checked_duration_since(std::time::Instant::now())
-}
-
-fn main() {
-    env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
-    if unsafe { libc::geteuid() } != 0 {
-        eprintln!("cce-keyring-unlockd must run as root");
-        std::process::exit(1);
-    }
-    let _ = std::fs::remove_file(SOCKET_PATH);
-    let listener = match UnixListener::bind(SOCKET_PATH) {
-        Ok(l) => l,
-        Err(e) => {
-            log::error!("cannot bind {SOCKET_PATH}: {e}");
-            std::process::exit(1);
-        }
-    };
-    // Any local user may connect; the daemon only ever acts on the caller's
-    // own registered databases, and no secret material crosses the socket.
-    if let Err(e) = std::fs::set_permissions(
-        SOCKET_PATH,
-        std::os::unix::fs::PermissionsExt::from_mode(0o666),
-    ) {
-        log::error!("cannot chmod {SOCKET_PATH}: {e}");
-        std::process::exit(1);
-    }
-    log::info!("listening on {SOCKET_PATH}");
-
-    for stream in listener.incoming() {
-        match stream {
-            Ok(s) => {
-                if let Err(e) = handle_client(s) {
-                    log::warn!("request failed: {e}");
-                }
-            }
-            Err(e) => log::warn!("accept failed: {e}"),
-        }
-    }
-}
-
-fn handle_client(stream: UnixStream) -> Result<(), Box<dyn std::error::Error>> {
-    stream.set_read_timeout(Some(Duration::from_secs(10)))?;
-    let (uid, gid) = peer_creds(&stream)?;
-    let mut reader = BufReader::new(stream.try_clone()?);
-    let mut line = String::new();
-    reader.read_line(&mut line)?;
-    if line.trim() != "unlock" {
-        reply(&stream, "error: unknown request");
-        return Ok(());
-    }
-    log::info!("unlock request from uid {uid}");
-    match unlock_for(uid, gid) {
-        Ok(()) => {
-            log::info!("uid {uid}: database(s) unlocked and verified");
-            reply(&stream, "ok");
-        }
-        Err(e) => {
-            log::warn!("uid {uid}: unlock failed: {e}");
-            reply(&stream, &format!("error: {e}"));
-        }
-    }
-    Ok(())
-}
-
-fn reply(mut stream: &UnixStream, msg: &str) {
-    let _ = writeln!(stream, "{msg}");
-}
-
-fn unlock_for(uid: u32, gid: u32) -> Result<(), Box<dyn std::error::Error>> {
-    let entries = load_entries(uid);
-    if entries.is_empty() {
-        return Err(format!("no databases registered for uid {uid} (run cce-keyring-unlock-setup)").into());
-    }
-    let budget = std::time::Instant::now() + REQUEST_BUDGET;
-
-    let conn = connect_user_bus(uid, gid)?;
-
-    let name_wait = remaining(budget).unwrap_or_default().min(NAME_WAIT);
-    if !wait_for_name(&conn, KEEPASSXC_DBUS_NAME, name_wait) {
-        return Err("KeePassXC never appeared on the session bus".into());
-    }
-    verify_keepassxc_owner(&conn, uid)?;
-
-    // Don't fire openDatabase into a bootstrapping KeePassXC — wait until it
-    // answers method calls.
-    let ready_deadline = (std::time::Instant::now() + READY_WAIT).min(budget);
-    while !keepassxc_answers(&conn) {
-        if std::time::Instant::now() >= ready_deadline {
-            return Err("KeePassXC owns its D-Bus name but never answered a call".into());
-        }
-        std::thread::sleep(Duration::from_millis(500));
-    }
-
-    for entry in &entries {
-        let mut password = decrypt_password(entry)?;
-        let result = open_and_verify(&conn, entry, &password, budget);
-        zeroize(&mut password);
-        result?;
-    }
-    Ok(())
-}
-
-/// The per-user bus refuses connections whose peer credentials aren't the
-/// owning user, so swap effective uid/gid to the requester just for the
-/// handshake. Once the socket is authenticated it keeps working after we
-/// return to root (which systemd-creds decryption requires). The daemon is
-/// single-threaded, so the swap can't leak into another request.
-fn connect_user_bus(
-    uid: u32,
-    gid: u32,
-) -> Result<zbus::blocking::Connection, Box<dyn std::error::Error>> {
-    let addr = format!("unix:path=/run/user/{uid}/bus");
-    if unsafe { libc::setegid(gid) } != 0 || unsafe { libc::seteuid(uid) } != 0 {
-        return Err(std::io::Error::last_os_error().into());
-    }
-    let result = zbus::blocking::connection::Builder::address(addr.as_str())
-        .and_then(|b| b.build());
-    if unsafe { libc::seteuid(0) } != 0 || unsafe { libc::setegid(0) } != 0 {
-        // Refuse to keep running with dropped privileges in an odd state.
-        log::error!("cannot restore root euid/egid: {}", std::io::Error::last_os_error());
-        std::process::exit(1);
-    }
-    Ok(result?)
-}
-
-/// The process owning the KeePassXC bus name must be the real keepassxc
-/// binary, running as the requesting user — never hand the password to an
-/// impostor that grabbed the name.
-fn verify_keepassxc_owner(
-    conn: &zbus::blocking::Connection,
-    uid: u32,
-) -> Result<(), Box<dyn std::error::Error>> {
-    let pid: u32 = conn
-        .call_method(
-            Some("org.freedesktop.DBus"),
-            "/org/freedesktop/DBus",
-            Some("org.freedesktop.DBus"),
-            "GetConnectionUnixProcessID",
-            &(KEEPASSXC_DBUS_NAME,),
-        )?
-        .body()
-        .deserialize()?;
-    let exe = std::fs::read_link(format!("/proc/{pid}/exe"))?;
-    // A package upgrade unlinks the running binary and the kernel reports
-    // "/usr/bin/keepassxc (deleted)" — still the real KeePassXC, and refusing
-    // it would break auto-unlock until the app restarts. Strip the marker.
-    let exe_str = exe.to_string_lossy();
-    let exe_path = exe_str.strip_suffix(" (deleted)").unwrap_or(&exe_str);
-    if std::path::Path::new(exe_path) != std::path::Path::new(KEEPASSXC_EXE) {
-        return Err(format!("bus name owned by {} (pid {pid}), not {KEEPASSXC_EXE}", exe.display()).into());
-    }
-    let meta = std::fs::metadata(format!("/proc/{pid}"))?;
-    let owner = std::os::unix::fs::MetadataExt::uid(&meta);
-    if owner != uid {
-        return Err(format!("keepassxc pid {pid} belongs to uid {owner}, expected {uid}").into());
-    }
-    Ok(())
-}
-
-fn decrypt_password(entry: &DbEntry) -> Result<String, Box<dyn std::error::Error>> {
-    let out = std::process::Command::new("systemd-creds")
-        .arg("decrypt")
-        .arg(format!("--name={}", entry.name))
-        .arg(&entry.cred_path)
-        .arg("-")
-        .output()?;
-    if !out.status.success() {
-        return Err(format!(
-            "systemd-creds decrypt failed for {}: {}",
-            entry.name,
-            String::from_utf8_lossy(&out.stderr).trim()
-        )
-        .into());
-    }
-    Ok(String::from_utf8(out.stdout)?)
-}
-
-fn open_and_verify(
-    conn: &zbus::blocking::Connection,
-    entry: &DbEntry,
-    password: &str,
-    budget: std::time::Instant,
-) -> Result<(), Box<dyn std::error::Error>> {
-    let mut last_err: String = "unlock not confirmed".into();
-    for attempt in 1..=OPEN_ATTEMPTS {
-        if remaining(budget).is_none() {
-            break;
-        }
-        let call = {
-            let conn = conn.clone();
-            let database = entry.database.clone();
-            let keyfile = entry.keyfile.clone();
-            let mut password = password.to_string();
-            with_timeout("openDatabase", OPEN_TIMEOUT, move || {
-                let r = conn
-                    .call_method(
-                        Some(KEEPASSXC_DBUS_NAME),
-                        KEEPASSXC_DBUS_PATH,
-                        Some(KEEPASSXC_DBUS_NAME),
-                        "openDatabase",
-                        &(database.as_str(), password.as_str(), keyfile.as_str()),
-                    )
-                    .map(|_| ())
-                    .map_err(|e| e.to_string());
-                zeroize(&mut password);
-                r
-            })
-        };
-        match call {
-            Ok(Ok(())) => {}
-            Ok(Err(e)) => {
-                last_err = format!("openDatabase call failed: {e}");
-                std::thread::sleep(Duration::from_secs(2));
-                continue;
-            }
-            Err(e) => {
-                // KeePassXC is most likely sitting on its own modal prompt.
-                last_err = e;
-                log::warn!("{}: {last_err}", entry.database);
-                continue;
-            }
-        }
-        // The call returning success is not enough — confirm via the Secret
-        // Service that the collection really is unlocked.
-        let deadline = (std::time::Instant::now() + VERIFY_WINDOW).min(budget);
-        let mut secrets_seen = false;
-        while std::time::Instant::now() < deadline {
-            let locked = {
-                let conn = conn.clone();
-                with_timeout("Locked property read", PROP_TIMEOUT, move || {
-                    default_collection_locked(&conn).map_err(|e| e.to_string())
-                })
-            };
-            match locked {
-                Ok(Ok(false)) => {
-                    log::info!("{}: unlocked (attempt {attempt})", entry.database);
-                    return Ok(());
-                }
-                Ok(Ok(true)) => secrets_seen = true,
-                Ok(Err(_)) => {}  // secrets service not up yet
-                Err(e) => log::warn!("{}: {e}", entry.database),
-            }
-            std::thread::sleep(Duration::from_millis(750));
-        }
-        if !secrets_seen {
-            // FdoSecrets never answered; can't verify. Trust the call rather
-            // than hammer retries against a database we cannot observe.
-            log::warn!(
-                "{}: openDatabase sent but Secret Service unavailable; assuming unlocked",
-                entry.database
-            );
-            return Ok(());
-        }
-        last_err = format!("still locked {VERIFY_WINDOW:?} after openDatabase (attempt {attempt})");
-        log::warn!("{}: {last_err}", entry.database);
-    }
-    Err(last_err.into())
-}
diff --git a/src/keyring.rs b/src/keyring.rs
deleted file mode 100644
index 9769552..0000000
--- a/src/keyring.rs
+++ /dev/null
@@ -1,165 +0,0 @@
-// Shared pieces for the cce keyring-unlock binaries (daemon, client, setup).
-//
-// The scheme: a root daemon holds the only privileged capability — decrypting
-// KeePassXC database passwords stored with systemd-creds (TPM-backed where
-// available) under /etc/cce/keyring-unlock/<uid>/. A per-session user client
-// asks it to unlock over a unix socket once KeePassXC is up; the daemon
-// identifies the caller via SO_PEERCRED, verifies the process owning the
-// KeePassXC D-Bus name, calls openDatabase, and confirms the Secret Service
-// collection actually reports unlocked, retrying until it does. The password
-// never crosses the socket.
-
-pub const SOCKET_PATH: &str = "/run/cce-keyring-unlock.sock";
-pub const STORE_DIR: &str = "/etc/cce/keyring-unlock";
-pub const KEEPASSXC_DBUS_NAME: &str = "org.keepassxc.KeePassXC.MainWindow";
-pub const KEEPASSXC_DBUS_PATH: &str = "/keepassxc";
-pub const KEEPASSXC_EXE: &str = "/usr/bin/keepassxc";
-pub const SECRETS_DBUS_NAME: &str = "org.freedesktop.secrets";
-pub const DEFAULT_COLLECTION_PATH: &str = "/org/freedesktop/secrets/aliases/default";
-
-/// One registered database: the .conf file next to its .cred blob.
-#[derive(Debug, Clone)]
-pub struct DbEntry {
-    pub name: String,
-    pub database: String,
-    pub keyfile: String,
-    pub cred_path: std::path::PathBuf,
-}
-
-pub fn store_dir_for(uid: u32) -> std::path::PathBuf {
-    std::path::Path::new(STORE_DIR).join(uid.to_string())
-}
-
-/// Load every <name>.conf under the user's store directory.
-pub fn load_entries(uid: u32) -> Vec<DbEntry> {
-    let dir = store_dir_for(uid);
-    let mut entries = Vec::new();
-    let Ok(rd) = std::fs::read_dir(&dir) else {
-        return entries;
-    };
-    for e in rd.flatten() {
-        let path = e.path();
-        if path.extension().map_or(true, |x| x != "conf") {
-            continue;
-        }
-        let Ok(text) = std::fs::read_to_string(&path) else {
-            continue;
-        };
-        let mut database = String::new();
-        let mut keyfile = String::new();
-        let mut cred = String::new();
-        for line in text.lines() {
-            if let Some(v) = line.strip_prefix("database=") {
-                database = v.to_string();
-            } else if let Some(v) = line.strip_prefix("keyfile=") {
-                keyfile = v.to_string();
-            } else if let Some(v) = line.strip_prefix("cred=") {
-                cred = v.to_string();
-            }
-        }
-        let name = path
-            .file_stem()
-            .map(|s| s.to_string_lossy().into_owned())
-            .unwrap_or_default();
-        if !database.is_empty() && !cred.is_empty() {
-            entries.push(DbEntry {
-                name,
-                database,
-                keyfile,
-                cred_path: dir.join(cred),
-            });
-        }
-    }
-    entries
-}
-
-/// Uid and gid of the process at the other end of a unix socket.
-pub fn peer_creds(stream: &std::os::unix::net::UnixStream) -> std::io::Result<(u32, u32)> {
-    use std::os::unix::io::AsRawFd;
-    let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
-    let mut len = std::mem::size_of::<libc::ucred>() as libc::socklen_t;
-    let r = unsafe {
-        libc::getsockopt(
-            stream.as_raw_fd(),
-            libc::SOL_SOCKET,
-            libc::SO_PEERCRED,
-            &mut cred as *mut _ as *mut libc::c_void,
-            &mut len,
-        )
-    };
-    if r != 0 {
-        return Err(std::io::Error::last_os_error());
-    }
-    Ok((cred.uid, cred.gid))
-}
-
-/// Best-effort scrub of secret material.
-pub fn zeroize(s: &mut String) {
-    unsafe {
-        for b in s.as_mut_vec().iter_mut() {
-            std::ptr::write_volatile(b, 0);
-        }
-    }
-    s.clear();
-}
-
-/// True once `name` has an owner on `conn`.
-pub fn name_has_owner(conn: &zbus::blocking::Connection, name: &str) -> bool {
-    conn.call_method(
-        Some("org.freedesktop.DBus"),
-        "/org/freedesktop/DBus",
-        Some("org.freedesktop.DBus"),
-        "NameHasOwner",
-        &(name,),
-    )
-    .and_then(|m| Ok(m.body().deserialize::<bool>()?))
-    .unwrap_or(false)
-}
-
-/// Poll until `name` is owned, up to `timeout`.
-pub fn wait_for_name(
-    conn: &zbus::blocking::Connection,
-    name: &str,
-    timeout: std::time::Duration,
-) -> bool {
-    let deadline = std::time::Instant::now() + timeout;
-    loop {
-        if name_has_owner(conn, name) {
-            return true;
-        }
-        if std::time::Instant::now() >= deadline {
-            return false;
-        }
-        std::thread::sleep(std::time::Duration::from_millis(500));
-    }
-}
-
-/// KeePassXC answers a method call => its event loop is dispatching, not
-/// still bootstrapping. (The bare name appearing is not enough: openDatabase
-/// sent during startup is accepted and lost.)
-pub fn keepassxc_answers(conn: &zbus::blocking::Connection) -> bool {
-    conn.call_method(
-        Some(KEEPASSXC_DBUS_NAME),
-        KEEPASSXC_DBUS_PATH,
-        Some("org.freedesktop.DBus.Introspectable"),
-        "Introspect",
-        &(),
-    )
-    .is_ok()
-}
-
-/// Lock state of the default Secret Service collection.
-/// Ok(true/false) when readable, Err when the service isn't answering.
-pub fn default_collection_locked(
-    conn: &zbus::blocking::Connection,
-) -> Result<bool, Box<dyn std::error::Error>> {
-    let msg = conn.call_method(
-        Some(SECRETS_DBUS_NAME),
-        DEFAULT_COLLECTION_PATH,
-        Some("org.freedesktop.DBus.Properties"),
-        "Get",
-        &("org.freedesktop.Secret.Collection", "Locked"),
-    )?;
-    let value = msg.body().deserialize::<zbus::zvariant::OwnedValue>()?;
-    Ok(bool::try_from(value)?)
-}