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