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

commit35992a2fdb6f2ab8e5cf7ddf566b4cadcc2456d6
parent4c4139769c
authorLucas Galante <[email protected]>
date2026-07-21 21:51
feat: cce-native KeePassXC auto-unlock (daemon, session client, setup)

Replace the third-party keepassxc-unlock stack, whose fire-and-forget
startup unlock raced KeePassXC's own bootstrap at login and silently lost
the openDatabase call, leaving the database locked.

Three new binaries sharing src/keyring.rs:

- cce-keyring-unlockd: root daemon on /run/cce-keyring-unlock.sock.
  Identifies callers by SO_PEERCRED, verifies the KeePassXC D-Bus name is
  owned by the real /usr/bin/keepassxc running as the caller, decrypts the
  registered password (systemd-creds, TPM-backed where available, store in
  /etc/cce/keyring-unlock/<uid>/), waits until KeePassXC answers D-Bus
  (name ownership alone is not readiness), then calls openDatabase and
  polls the Secret Service collection until it reports unlocked, retrying
  the call if it was swallowed. The per-user dbus-broker rejects root, so
  the bus handshake runs under the caller's euid/egid (single-threaded
  daemon, root restored immediately after connect). Passwords never cross
  the socket.

- cce-keyring-unlock: oneshot user service in graphical-session.target.
  Waits for the compositor's session restore to bring KeePassXC up, skips
  if already unlocked, otherwise asks the daemon. After a confirmed unlock
  it also dismisses KeePassXC's orphaned standalone "Unlock Database"
  prompt (keepassxc#9297) via the compositor's close-window IPC command.

- cce-keyring-unlock-setup: root CLI to register a database; prompts on
  /dev/tty with echo off and encrypts via systemd-creds.

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

 Cargo.toml                          |   1 +
 cce-keyring-unlock.service          |  12 ++
 cce-keyring-unlockd.service         |  13 ++
 src/bin/cce-keyring-unlock-setup.rs | 152 +++++++++++++++++++++++
 src/bin/cce-keyring-unlock.rs       | 112 +++++++++++++++++
 src/bin/cce-keyring-unlockd.rs      | 240 ++++++++++++++++++++++++++++++++++++
 src/keyring.rs                      | 165 +++++++++++++++++++++++++
 7 files changed, 695 insertions(+)

diff --git a/Cargo.toml b/Cargo.toml
index b9b6645..3eda43e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -6,6 +6,7 @@ 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"
diff --git a/cce-keyring-unlock.service b/cce-keyring-unlock.service
new file mode 100644
index 0000000..9f0d05f
--- /dev/null
+++ b/cce-keyring-unlock.service
@@ -0,0 +1,12 @@
+[Unit]
+Description=CCE keyring unlock request (KeePassXC)
+After=graphical-session.target
+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
new file mode 100644
index 0000000..b770100
--- /dev/null
+++ b/cce-keyring-unlockd.service
@@ -0,0 +1,13 @@
+[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
+
+[Install]
+WantedBy=multi-user.target
diff --git a/src/bin/cce-keyring-unlock-setup.rs b/src/bin/cce-keyring-unlock-setup.rs
new file mode 100644
index 0000000..bacac21
--- /dev/null
+++ b/src/bin/cce-keyring-unlock-setup.rs
@@ -0,0 +1,152 @@
+// 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
new file mode 100644
index 0000000..227ffbc
--- /dev/null
+++ b/src/bin/cce-keyring-unlock.rs
@@ -0,0 +1,112 @@
+// 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
new file mode 100644
index 0000000..3298534
--- /dev/null
+++ b/src/bin/cce-keyring-unlockd.rs
@@ -0,0 +1,240 @@
+// 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);
+
+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 conn = connect_user_bus(uid, gid)?;
+
+    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;
+    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);
+        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"))?;
+    if exe != 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,
+) -> Result<(), Box<dyn std::error::Error>> {
+    let mut last_err: String = "unlock not confirmed".into();
+    for attempt in 1..=OPEN_ATTEMPTS {
+        if let Err(e) = conn.call_method(
+            Some(KEEPASSXC_DBUS_NAME),
+            KEEPASSXC_DBUS_PATH,
+            Some(KEEPASSXC_DBUS_NAME),
+            "openDatabase",
+            &(entry.database.as_str(), password, entry.keyfile.as_str()),
+        ) {
+            last_err = format!("openDatabase call failed: {e}");
+            std::thread::sleep(Duration::from_secs(2));
+            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;
+        let mut secrets_seen = false;
+        while std::time::Instant::now() < deadline {
+            match default_collection_locked(conn) {
+                Ok(false) => {
+                    log::info!("{}: unlocked (attempt {attempt})", entry.database);
+                    return Ok(());
+                }
+                Ok(true) => secrets_seen = true,
+                Err(_) => {} // secrets service not up yet
+            }
+            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
new file mode 100644
index 0000000..9769552
--- /dev/null
+++ b/src/keyring.rs
@@ -0,0 +1,165 @@
+// 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)?)
+}