login greeter
git clone https://git.lucas.co/cce-display-manager.git
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)?)
-}