git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commit695dae269f3baa0766846ae6f018fa42513115c1
parentbbc2fd522a
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-01 19:29
One-time codes in cce-secrets, via the sync daemon; login-time retries

1Password's window stays closed (option A in KEYRING-SYNC.md): the app runs
headless as what `op` authorizes against, and cce-secrets is the UI.

- cce-keyring-sync's daemon serves one-time codes on
  $XDG_RUNTIME_DIR/cce/keyring-sync.sock (0600, mirrored ids only). It
  holds the session's `op` authorization, so a code costs no Authorize
  dialog; `op item get --otp` keeps the seed inside 1Password.
- cce-secrets shows the selected entry's code with a countdown and a Copy
  code button, re-asking once per expiry.
- The daemon retries every 30 s while the app is not up yet at login, and
  treats `authorization timeout` (the app starts locked) as an unanswered
  prompt.
- KEYRING-SYNC.md: open question 4 answered from the app's log, the A/B
  decision, the socket protocol and its trade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 KEYRING-SYNC.md                    | 103 ++++++++++++++-
 src/bin/cce-keyring-sync/daemon.rs |  23 +++-
 src/bin/cce-keyring-sync/main.rs   |   4 +-
 src/bin/cce-keyring-sync/op.rs     |  50 ++++++-
 src/bin/cce-keyring-sync/serve.rs  | 137 +++++++++++++++++++
 src/main.rs                        | 264 +++++++++++++++++++++++++++++++++++--
 6 files changed, 562 insertions(+), 19 deletions(-)

diff --git a/KEYRING-SYNC.md b/KEYRING-SYNC.md
index 087beff..d5f5e9d 100644
--- a/KEYRING-SYNC.md
+++ b/KEYRING-SYNC.md
@@ -513,7 +513,8 @@ app's approval entirely.
 
 ### Open questions, continued
 
-4. What does an `op` call do against a *locked* app — a system-auth
+4. *(Answered 2026-10-01 — see "Question 4 answered" at the end.)*
+   What does an `op` call do against a *locked* app — a system-auth
    (polkit → cce-authenticator) unlock prompt, the Authorize dialog, or a
    plain refusal? Decides what the daemon sees after autolock.
 5. What does the app count as a "top level process"? If the daemon could
@@ -635,4 +636,102 @@ both still front gnome-keyring over the Secret Service, which was the
 point of choosing a mirror.
 
 Still open: question 4, what an `op` call sees against a locked app. The
-daemon's back-off covers the gap until it is measured.
+daemon's back-off covers the gap until it is measured. (Answered
+2026-10-01, below.)
+
+# 1Password's window out of the loop (option A, 2026-10-01)
+
+The question was whether cce programs could replace 1Password's graphical
+frontend. Two shapes were weighed:
+
+- **A — the app stays, headless.** It keeps running (autostart already
+  passes `--silent`, so no main window opens) as what `op` authorizes
+  against and what syncs with 1Password's servers; cce-secrets is the only
+  window anyone browses in. **Chosen.**
+- **B — the app goes.** `op` without the integration signs in on its own
+  (the "Fallback, parked" above): no dialogs ever, but the account password
+  and Secret Key would have to live in the keyring, whose TPM seal has no
+  PCR policy on a disk with no encryption — a stolen laptop would yield
+  the whole account, not just the mirrored logins. It would also drop the
+  Chrome extension's app unlock, the MCP integration and Quick Access.
+  Rejected. Service accounts cannot read personal vaults, so they are no
+  way around it.
+
+What remains of 1Password's own UI under A, and why it cannot be replaced:
+its unlock screen and its Authorize dialog are the app's trust boundary,
+drawn by the app on purpose and not scriptable. The compositor already
+floats, centres and raises the Authorize dialog (cce-compositor bebebc0e,
+bbd73499), so the phase 1 "dialog hidden under the main window" problem is
+gone, and with the main window never opened it could not arise anyway.
+
+## Question 4 answered: an `op` call against a locked app
+
+From the app's log for the 2026-10-01 18:12 login
+(`~/.config/1Password/logs/1Password_rCURRENT.log`, UTC):
+
+- The app **starts locked**, and system unlock is not available until the
+  account password has been typed once after the app starts (`Sys auth
+  status NotReady`; after the password, `Adding system unlock key`). So
+  every login costs one account-password unlock in 1Password's own window.
+  That is 1Password's rule, not something cce can route around.
+- The daemon starts before the app: its first call ends in `cannot connect
+  to 1Password app`. The next, against the locked app, waited and ended in
+  `authorization timeout`; the calls after that ended as `authorization
+  prompt dismissed`. With the old back-off that held the first sync of the
+  session until about an hour after login, half an hour after the app had
+  been unlocked.
+- The app's idle/screen-lock hook fails here (`op-auto-lock: Could not
+  connect to the X server`), so only the 60-minute auto-lock timer locks
+  it.
+
+What changed in the daemon for it: `authorization timeout` counts as an
+unanswered prompt (back off, like a dismissed dialog), and `cannot connect
+to 1Password app` retries every 30 s, up to ten times, before falling back
+to the 5-minute tick, so a login that unlocks the app promptly syncs
+promptly. The cce-secrets Sync button still cuts any back-off short.
+
+## One-time codes in cce-secrets
+
+The keyring mirror carries logins only, so one-time codes were the one
+thing a person still opened 1Password's window for. cce-secrets now shows
+the selected entry's current code with a countdown, plus a **Copy code**
+button (the clipboard clears after 30 s, like a copied password).
+
+The code comes from the **daemon**, not from cce-secrets running `op`
+itself: the daemon already holds the session's authorization, while
+cce-secrets would raise its own Authorize dialog each launch. The daemon
+listens on `$XDG_RUNTIME_DIR/cce/keyring-sync.sock` (0600), one request per
+connection:
+
+```
+otp <item-id>\n  →  otp <code> <seconds left>\n | none\n | err <text>\n
+```
+
+- `op item get <id> --otp` computes the code app-side, so **the seed never
+  enters either process**; only the six digits cross the socket.
+- Only ids in the base snapshot are served (the mirror's own items), and an
+  id must be bare alphanumerics before it reaches `op`'s argv.
+- The trade: any same-user process can now get a current code without a
+  dialog. Such a process could already read every mirrored password from
+  the unlocked keyring, so this extends the mirror's posture to the second
+  factor's codes. It does not extend it to the seeds.
+- Seconds left assume TOTP's usual 30 s period. A code with another period
+  is still right; only its countdown would be off.
+- cce-secrets asks when the selection lands on an entry carrying an
+  `op-item` stamp, again when the code runs out, and never again for an
+  entry the daemon said has none (until the window reopens). Each answer
+  costs one `op` call, about a second.
+
+**No mirrored login carries a code yet.** A scan of all 377 base ids
+through the socket on 2026-10-01 answered `none` for every one, with no
+errors: this account's codes live in a separate authenticator. The feature
+waits for the first item that gets an OTP field in 1Password. Its UI was
+verified in a shadow against a stand-in daemon instead, since a shadow
+shares the live runtime dir and must not bind the real socket:
+`CCE_KEYRING_SYNC_SOCK=<path>` points cce-secrets elsewhere, and a
+twelve-line Python server answering `otp 482913 <30 - now % 30>` stands in.
+Seen: the code row under the secret, the countdown tracking the wall clock,
+exactly one re-ask per expiry, and Copy code ending the first button row.
+(cce-secrets is a Secret Service *client* and owns no bus name, so it is
+safe in a shadow, contrary to `cce-shadow`'s help text. It lists the live
+keyring there, so browse only.)
diff --git a/src/bin/cce-keyring-sync/daemon.rs b/src/bin/cce-keyring-sync/daemon.rs
index dee59ce..db075e6 100644
--- a/src/bin/cce-keyring-sync/daemon.rs
+++ b/src/bin/cce-keyring-sync/daemon.rs
@@ -12,24 +12,39 @@
 //! empty chair is the annoyance the timer design was rejected for, so after a
 //! dismissal the tick backs off (15 → 30 → 60 minutes) until something asks:
 //! `SIGUSR1`, which cce-secrets sends from its Sync button and after a save.
+//! A prompt nobody answered includes the app's own unlock: it starts locked
+//! at login, and until someone types the account password a call ends in
+//! `authorization timeout` (measured 2026-10-01).
+//!
+//! At login this unit also starts before the app, so a call that finds no
+//! app retries every [`APP_DOWN_RETRY`] for a few minutes instead of
+//! waiting out a whole tick.
+//!
+//! The same pid serves one-time codes to cce-secrets (serve.rs): its
+//! authorization is the reason a code needs no dialog.
 
 use std::time::Duration;
 
 use tokio::signal::unix::{signal, SignalKind};
 
-use crate::op::{is_dismissed, OnePassword};
+use crate::op::{is_app_down, is_dismissed, OnePassword};
 use crate::sync::sync_remote;
 use crate::{now_unix, State};
 
 /// Inside the ~10-minute idle window with margin.
 pub const TICK: Duration = Duration::from_secs(5 * 60);
 const BACKOFF: [Duration; 3] = [Duration::from_secs(15 * 60), Duration::from_secs(30 * 60), Duration::from_secs(60 * 60)];
+/// While the app is not up yet; [`APP_DOWN_TRIES`] of these, then ticks.
+const APP_DOWN_RETRY: Duration = Duration::from_secs(30);
+const APP_DOWN_TRIES: usize = 10;
 
 pub async fn daemon(state_path: &std::path::Path) {
     let mut usr1 = signal(SignalKind::user_defined1()).expect("SIGUSR1 handler");
     let mut term = signal(SignalKind::terminate()).expect("SIGTERM handler");
     let mut dismissed = 0usize;
+    let mut app_down = 0usize;
     println!("cce-keyring-sync daemon: tick every {}s, SIGUSR1 syncs now", TICK.as_secs());
+    tokio::spawn(crate::serve::serve(state_path.to_path_buf()));
 
     loop {
         // Re-read every tick: adopt or a manual sync may have moved the base.
@@ -45,8 +60,14 @@ pub async fn daemon(state_path: &std::path::Path) {
             match sync_remote(&mut remote, state_path, &mut state, false).await {
                 Ok(_) => {
                     dismissed = 0;
+                    app_down = 0;
                     TICK
                 }
+                Err(e) if is_app_down(&e) && app_down < APP_DOWN_TRIES => {
+                    app_down += 1;
+                    eprintln!("1Password app not running; retrying in {}s", APP_DOWN_RETRY.as_secs());
+                    APP_DOWN_RETRY
+                }
                 Err(e) if is_dismissed(&e) => {
                     let w = BACKOFF[dismissed.min(BACKOFF.len() - 1)];
                     dismissed += 1;
diff --git a/src/bin/cce-keyring-sync/main.rs b/src/bin/cce-keyring-sync/main.rs
index fbe8e16..4bf40a1 100644
--- a/src/bin/cce-keyring-sync/main.rs
+++ b/src/bin/cce-keyring-sync/main.rs
@@ -7,7 +7,8 @@
 //! sides already hold and seeds the base; `sync` is one three-way merge
 //! pass (sync.rs); `daemon` is the resident loop the systemd unit runs
 //! (daemon.rs) — resident because the CLI's authorization is keyed to the
-//! calling process's parent and lapses when idle.
+//! calling process's parent and lapses when idle. The daemon also serves
+//! one-time codes to cce-secrets over a socket (serve.rs).
 //!
 //! Discipline kept from the kdbx era this replaced (2026-09-21):
 //! - the state file stores keyed hashes of fields, never values; the hash
@@ -22,6 +23,7 @@
 mod adopt;
 mod daemon;
 mod op;
+mod serve;
 mod sync;
 
 use std::collections::HashMap;
diff --git a/src/bin/cce-keyring-sync/op.rs b/src/bin/cce-keyring-sync/op.rs
index e8e68c7..9c5d60d 100644
--- a/src/bin/cce-keyring-sync/op.rs
+++ b/src/bin/cce-keyring-sync/op.rs
@@ -20,6 +20,14 @@ pub const OP_TIMEOUT: Duration = Duration::from_secs(75);
 
 /// The text `op` prints when the Authorize dialog timed out unanswered.
 const DISMISSED: &str = "authorization prompt dismissed";
+/// What `op` prints when nobody unlocked the app in time: the first call of
+/// a login, against an app that starts locked and wants its account
+/// password before system unlock works (measured 2026-10-01).
+const TIMED_OUT: &str = "authorization timeout";
+/// The app is not running (yet — the daemon starts before it at login).
+const APP_DOWN: &str = "cannot connect to 1Password app";
+/// `op item get --otp` on an item without a one-time password field.
+const NO_OTP: &str = "doesn't contain any OTP-type fields";
 
 /// One remote entry, whole: the six fields the merge hashes plus identity.
 #[derive(Clone, Debug, PartialEq, Default)]
@@ -71,10 +79,16 @@ pub trait Interchange {
     async fn recycle(&mut self, id: &str) -> Result<(), String>;
 }
 
-/// True when the error text is the app's dialog timing out — a refusal to
-/// back off from, not a fault to log as one.
+/// True when the error text is a prompt nobody answered — the Authorize
+/// dialog, or the app's own unlock — a refusal to back off from, not a
+/// fault to log as one.
 pub fn is_dismissed(err: &str) -> bool {
-    err.contains(DISMISSED)
+    err.contains(DISMISSED) || err.contains(TIMED_OUT)
+}
+
+/// True when `op` found no app to talk to.
+pub fn is_app_down(err: &str) -> bool {
+    err.contains(APP_DOWN)
 }
 
 // ───────────────────────────── 1Password ─────────────────────────────
@@ -96,9 +110,18 @@ impl OnePassword {
     /// pid as its parent — never via a shell, setsid, or a double fork),
     /// feed `stdin`, and return stdout. Stderr's last line is the error.
     async fn run(&self, args: &[&str], stdin: Option<Vec<u8>>) -> Result<Vec<u8>, String> {
+        self.run_as(args, stdin, true).await
+    }
+
+    /// `run`, with `--format json` optional: `--otp` refuses it.
+    async fn run_as(&self, args: &[&str], stdin: Option<Vec<u8>>, json: bool) -> Result<Vec<u8>, String> {
         use tokio::io::AsyncWriteExt;
         let mut cmd = tokio::process::Command::new("op");
-        cmd.args(args).arg("--format").arg("json").arg("--no-color");
+        cmd.args(args);
+        if json {
+            cmd.arg("--format").arg("json");
+        }
+        cmd.arg("--no-color");
         if !self.account.is_empty() {
             cmd.arg("--account").arg(&self.account);
         }
@@ -132,6 +155,21 @@ impl OnePassword {
         let bytes = self.run(args, stdin).await?;
         serde_json::from_slice(&bytes).map_err(|e| format!("op {}: unparseable JSON: {e}", args.join(" ")))
     }
+
+    /// An item's current one-time code; `None` when it has no OTP field.
+    /// `--otp` has op compute the code, so the seed (the field's value,
+    /// which `item get --format json` would print) never enters this
+    /// process. Not part of `Interchange`: the merge never touches OTP.
+    pub async fn otp(&self, id: &str) -> Result<Option<String>, String> {
+        match self.run_as(&["item", "get", id, "--otp"], None, false).await {
+            Ok(out) => match String::from_utf8_lossy(&out).trim() {
+                "" => Err("op item: empty one-time code".into()),
+                code => Ok(Some(code.to_string())),
+            },
+            Err(e) if e.contains(NO_OTP) => Ok(None),
+            Err(e) => Err(e),
+        }
+    }
 }
 
 impl Interchange for OnePassword {
@@ -475,5 +513,9 @@ mod tests {
     fn a_dismissed_prompt_is_recognised() {
         assert!(is_dismissed("op item list: authorization prompt dismissed, please try again"));
         assert!(!is_dismissed("op item list: account is not signed in"));
+        // The first call of a login, against the still-locked app.
+        assert!(is_dismissed("op item: authorization timeout"));
+        assert!(is_app_down("op item: connecting to desktop app: cannot connect to 1Password app, make sure it is running"));
+        assert!(!is_app_down("op item: authorization timeout"));
     }
 }
diff --git a/src/bin/cce-keyring-sync/serve.rs b/src/bin/cce-keyring-sync/serve.rs
new file mode 100644
index 0000000..9702b21
--- /dev/null
+++ b/src/bin/cce-keyring-sync/serve.rs
@@ -0,0 +1,137 @@
+//! The daemon's socket: one-time codes for cce-secrets.
+//!
+//! The daemon holds the session's `op` authorization (daemon.rs), so it is
+//! the one process that can ask 1Password for a code without raising an
+//! Authorize dialog. cce-secrets asks here, one request per connection:
+//!
+//! ```text
+//! otp <item-id>\n  →  otp <code> <seconds left>\n | none\n | err <text>\n
+//! ```
+//!
+//! The socket is 0600 under `$XDG_RUNTIME_DIR/cce`, and only items the base
+//! snapshot pairs are served — the mirror's own set, not whatever else the
+//! account holds. The trade, stated plainly: a same-user process could
+//! already read every mirrored password from the unlocked keyring; this
+//! adds the current codes without a dialog. Never the seeds — `op item get
+//! --otp` computes the code app-side (op.rs).
+
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
+use tokio::net::{UnixListener, UnixStream};
+
+use crate::op::OnePassword;
+use crate::{now_unix, State};
+
+/// TOTP's near-universal period. A code with another period still comes
+/// back right; only its countdown would be off.
+const PERIOD: i64 = 30;
+
+/// Where the daemon listens and cce-secrets connects. `None` without a
+/// runtime dir — a session has one; nothing else should be serving.
+pub fn socket_path() -> Option<PathBuf> {
+    let dir = std::env::var("XDG_RUNTIME_DIR").ok().filter(|s| !s.is_empty())?;
+    Some(PathBuf::from(dir).join("cce/keyring-sync.sock"))
+}
+
+pub async fn serve(state_path: PathBuf) {
+    let Some(path) = socket_path() else {
+        eprintln!("no XDG_RUNTIME_DIR; one-time codes are not served");
+        return;
+    };
+    if let Some(dir) = path.parent() {
+        let _ = std::fs::create_dir_all(dir);
+    }
+    // A previous daemon's socket file outlives it; bind would refuse.
+    let _ = std::fs::remove_file(&path);
+    let listener = match UnixListener::bind(&path) {
+        Ok(l) => l,
+        Err(e) => {
+            eprintln!("binding {}: {e}; one-time codes are not served", path.display());
+            return;
+        }
+    };
+    {
+        use std::os::unix::fs::PermissionsExt;
+        let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
+    }
+    loop {
+        let Ok((stream, _)) = listener.accept().await else { continue };
+        // Its own task: a code request that raises a dialog holds `op`
+        // for up to a minute, and must hold neither the tick nor the
+        // next request.
+        let state_path = state_path.clone();
+        tokio::spawn(async move { handle(stream, &state_path).await });
+    }
+}
+
+async fn handle(stream: UnixStream, state_path: &Path) {
+    let (r, mut w) = stream.into_split();
+    let mut line = String::new();
+    let mut r = BufReader::new(r.take(256));
+    match tokio::time::timeout(Duration::from_secs(5), r.read_line(&mut line)).await {
+        Ok(Ok(n)) if n > 0 => {}
+        _ => return,
+    }
+    let reply = answer(line.trim(), state_path).await;
+    let _ = w.write_all(reply.as_bytes()).await;
+}
+
+async fn answer(req: &str, state_path: &Path) -> String {
+    let Some(id) = req.strip_prefix("otp ") else {
+        return "err unknown request\n".into();
+    };
+    if !is_item_id(id) {
+        return "err bad item id\n".into();
+    }
+    let state: State = std::fs::read_to_string(state_path)
+        .ok()
+        .and_then(|s| serde_json::from_str(&s).ok())
+        .unwrap_or_default();
+    if state.backend != "onepassword" || !state.entries.contains_key(id) {
+        return "err not a mirrored item\n".into();
+    }
+    match OnePassword::new(&state.vault).otp(id).await {
+        Ok(Some(code)) => format!("otp {code} {}\n", PERIOD - now_unix().rem_euclid(PERIOD)),
+        Ok(None) => "none\n".into(),
+        Err(e) => format!("err {}\n", e.replace('\n', " ")),
+    }
+}
+
+/// 1Password ids are 26 lowercase base32 characters. Alphanumeric only is
+/// what matters: the id lands on `op`'s argv, where a dash would read as a
+/// flag.
+fn is_item_id(s: &str) -> bool {
+    !s.is_empty() && s.len() <= 64 && s.bytes().all(|b| b.is_ascii_alphanumeric())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn only_bare_ids_reach_op() {
+        assert!(is_item_id("v6ybyyfp6b2vnaqm3ar4z3kzqa"));
+        assert!(!is_item_id(""));
+        assert!(!is_item_id("--vault"));
+        assert!(!is_item_id("abc def"));
+        assert!(!is_item_id(&"a".repeat(65)));
+    }
+
+    #[tokio::test]
+    async fn requests_outside_the_mirror_are_refused() {
+        let dir = std::env::temp_dir().join(format!("cce-keyring-sync-serve-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let state_path = dir.join("state.json");
+        std::fs::write(
+            &state_path,
+            r#"{"version":2,"last_run":0,"backend":"onepassword","vault":"Personal","entries":{}}"#,
+        )
+        .unwrap();
+        assert_eq!(answer("otp aaaaaaaaaaaaaaaaaaaaaaaaaa", &state_path).await, "err not a mirrored item\n");
+        assert_eq!(answer("otp -x", &state_path).await, "err bad item id\n");
+        assert_eq!(answer("sync", &state_path).await, "err unknown request\n");
+        let _ = std::fs::remove_dir_all(&dir);
+    }
+}
diff --git a/src/main.rs b/src/main.rs
index 9555f52..a1350e5 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -67,6 +67,27 @@ enum Cmd {
     DeleteItem { path: String },
 }
 
+/// The sync daemon's answer to a one-time code request.
+#[derive(Clone, Debug, PartialEq)]
+enum OtpReply {
+    /// The code and the seconds it has left.
+    Code(String, u64),
+    /// The item has no one-time password field.
+    Absent,
+    Failed(String),
+}
+
+/// The selected entry's one-time code, as far as the UI knows it.
+enum Otp {
+    /// Asked; nothing to show yet.
+    Pending,
+    /// `refreshing`: expired and re-asked — the old code stays up meanwhile,
+    /// at 0 s, rather than blinking out for the length of an `op` call.
+    Code { code: String, until: std::time::Instant, refreshing: bool },
+    /// Reported on the status line; not re-asked until the selection moves.
+    Failed,
+}
+
 #[derive(Clone, Debug)]
 enum AppMessage {
     Loaded(Vec<EntryData>),
@@ -80,6 +101,8 @@ enum AppMessage {
     SyncClicked,
     RevealClicked,
     CopyClicked,
+    CopyOtpClicked,
+    Otp { path: String, reply: OtpReply },
     NewClicked,
     EditClicked,
     DeleteClicked,
@@ -190,6 +213,81 @@ async fn run_sync() -> (String, bool) {
     }
 }
 
+// ── One-time codes ────────────────────────────────────────────────────────
+//
+// Asked of the resident cce-keyring-sync daemon over its socket
+// (src/bin/cce-keyring-sync/serve.rs): it holds the session's `op`
+// authorization, so a code costs no Authorize dialog — this process running
+// `op` itself would raise one per launch. 1Password computes the code; the
+// seed never leaves it. Only entries carrying an `op-item` stamp (the ones
+// the sync pairs) can have one.
+
+fn otp_socket() -> Option<std::path::PathBuf> {
+    // For a shadow test against a stand-in daemon: the shadow shares the
+    // live runtime dir, and binding the real path would unseat the live one.
+    if let Some(p) = std::env::var_os("CCE_KEYRING_SYNC_SOCK") {
+        return Some(p.into());
+    }
+    let dir = std::env::var("XDG_RUNTIME_DIR").ok().filter(|s| !s.is_empty())?;
+    Some(std::path::PathBuf::from(dir).join("cce/keyring-sync.sock"))
+}
+
+/// Blocking: run it off the UI thread. A request that has to raise the
+/// Authorize dialog (the daemon's authorization lapsed) waits out its 60 s.
+fn request_otp(item_id: &str) -> OtpReply {
+    use std::io::{BufRead, Write};
+    let Some(path) = otp_socket() else {
+        return OtpReply::Failed("no XDG_RUNTIME_DIR".into());
+    };
+    let Ok(mut stream) = std::os::unix::net::UnixStream::connect(&path) else {
+        return OtpReply::Failed("the sync daemon is not running (cce-keyring-sync.service)".into());
+    };
+    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(80)));
+    if writeln!(stream, "otp {item_id}").is_err() {
+        return OtpReply::Failed("the sync daemon hung up".into());
+    }
+    let mut line = String::new();
+    match std::io::BufReader::new(stream).read_line(&mut line) {
+        Ok(n) if n > 0 => parse_otp_reply(line.trim_end()),
+        _ => OtpReply::Failed("no answer from the sync daemon".into()),
+    }
+}
+
+fn parse_otp_reply(line: &str) -> OtpReply {
+    if line == "none" {
+        return OtpReply::Absent;
+    }
+    if let Some(e) = line.strip_prefix("err ") {
+        return OtpReply::Failed(e.to_string());
+    }
+    let mut parts = line.strip_prefix("otp ").unwrap_or("").split(' ');
+    match (parts.next(), parts.next().and_then(|t| t.parse().ok())) {
+        (Some(code), Some(left)) if !code.is_empty() => OtpReply::Code(code.to_string(), left),
+        _ => OtpReply::Failed(format!("unreadable reply: {line}")),
+    }
+}
+
+/// "123456" → "123 456"; any other length as is.
+fn group_code(code: &str) -> String {
+    if code.len() == 6 && code.is_ascii() {
+        format!("{} {}", &code[..3], &code[3..])
+    } else {
+        code.to_string()
+    }
+}
+
+/// Put `text` on the clipboard, and take it off again after
+/// [`CLIPBOARD_CLEAR_SECS`] if it is still there.
+fn copy_then_clear(text: String) {
+    cce_ui::widget::clipboard::copy_to_clipboard(&text);
+    std::thread::spawn(move || {
+        std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_CLEAR_SECS));
+        if cce_ui::widget::clipboard::read_from_clipboard().as_deref() == Some(text.as_str()) {
+            cce_ui::widget::clipboard::copy_to_clipboard("");
+        }
+    });
+}
+
 // ── Secret Service worker ─────────────────────────────────────────────────
 //
 // The D-Bus session lives on its own thread (single-thread tokio runtime,
@@ -356,18 +454,11 @@ async fn fetch_secret(
     let secret = String::from_utf8_lossy(&bytes).to_string();
     match purpose {
         Purpose::Copy => {
-            cce_ui::widget::clipboard::copy_to_clipboard(&secret);
+            copy_then_clear(secret);
             let _ = tx.send(AppMessage::Status(
                 format!("Secret copied — clipboard clears in {CLIPBOARD_CLEAR_SECS} s"),
                 false,
             ));
-            std::thread::spawn(move || {
-                std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_CLEAR_SECS));
-                // Only clear if the clipboard still holds our secret.
-                if cce_ui::widget::clipboard::read_from_clipboard().as_deref() == Some(secret.as_str()) {
-                    cce_ui::widget::clipboard::copy_to_clipboard("");
-                }
-            });
         }
         Purpose::Reveal => {
             let _ = tx.send(AppMessage::Revealed { path, secret });
@@ -460,6 +551,7 @@ struct SecretsApp {
     new_btn: cce_ui::widget::Adapted<Button>,
     reveal_btn: cce_ui::widget::Adapted<Button>,
     copy_btn: cce_ui::widget::Adapted<Button>,
+    otp_btn: cce_ui::widget::Adapted<Button>,
     edit_btn: cce_ui::widget::Adapted<Button>,
     delete_btn: cce_ui::widget::Adapted<Button>,
     save_btn: cce_ui::widget::Adapted<Button>,
@@ -479,6 +571,13 @@ struct SecretsApp {
     revealed: Option<(String, String)>,
     /// Path armed for deletion by the first Delete click.
     pending_delete: Option<String>,
+    /// (entry path, its one-time code) for the selected entry; `ensure_otp`
+    /// keeps it following the selection and the code's 30 s period.
+    otp: Option<(String, Otp)>,
+    /// Entries the daemon said have no code: not asked again this run.
+    otp_absent: std::collections::HashSet<String>,
+    /// The countdown second last painted, so `tick` redraws once a second.
+    otp_drawn_left: u64,
 
     /// The DRAWN list offset — `scroll_motion` glides it (wheel) or coasts
     /// it (trackpad flick); direct writes (Escape reset, clamp) are adopted
@@ -575,6 +674,57 @@ impl SecretsApp {
         matches!(self.mode, Mode::Edit { .. })
     }
 
+    /// The code to show for the selected entry, with its seconds left.
+    fn shown_otp(&self) -> Option<(&str, u64)> {
+        let sel = self.selected.as_deref()?;
+        match &self.otp {
+            Some((path, Otp::Code { code, until, .. })) if path == sel => {
+                let left = until.saturating_duration_since(std::time::Instant::now()).as_secs_f32().ceil() as u64;
+                Some((code.as_str(), left))
+            }
+            _ => None,
+        }
+    }
+
+    /// Keep `otp` on the selected entry: ask the daemon when the selection
+    /// lands on a paired entry, and again when the code runs out. True when
+    /// anything visible changed.
+    fn ensure_otp(&mut self) -> bool {
+        let want = self
+            .selected_entry()
+            .filter(|e| !e.attr("op-item").is_empty() && !self.otp_absent.contains(&e.path))
+            .map(|e| (e.path.clone(), e.attr("op-item").to_string()));
+        let Some((path, item_id)) = want.filter(|_| !self.editing()) else {
+            return self.otp.take().is_some();
+        };
+        let ask = match &mut self.otp {
+            Some((p, _)) if *p != path => true,
+            None => true,
+            Some((_, Otp::Code { until, refreshing, .. })) => {
+                if !*refreshing && std::time::Instant::now() >= *until {
+                    *refreshing = true;
+                    true
+                } else {
+                    false
+                }
+            }
+            Some((_, Otp::Pending | Otp::Failed)) => false,
+        };
+        if !ask {
+            return false;
+        }
+        let changed = !matches!(&self.otp, Some((p, Otp::Code { .. })) if *p == path);
+        if changed {
+            self.otp = Some((path.clone(), Otp::Pending));
+        }
+        let tx = self.sender.clone();
+        std::thread::spawn(move || {
+            let reply = request_otp(&item_id);
+            let _ = tx.send(AppMessage::Otp { path, reply });
+        });
+        changed
+    }
+
     fn form_boxes_mut(&mut self) -> [&mut cce_ui::widget::Adapted<TextBox>; 5] {
         [
             &mut self.title_box,
@@ -670,13 +820,14 @@ impl SecretsApp {
             .unwrap_or_default();
     }
 
-    fn buttons_mut(&mut self) -> [&mut cce_ui::widget::Adapted<Button>; 9] {
+    fn buttons_mut(&mut self) -> [&mut cce_ui::widget::Adapted<Button>; 10] {
         [
             &mut self.refresh_btn,
             &mut self.sync_btn,
             &mut self.new_btn,
             &mut self.reveal_btn,
             &mut self.copy_btn,
+            &mut self.otp_btn,
             &mut self.edit_btn,
             &mut self.delete_btn,
             &mut self.save_btn,
@@ -692,6 +843,7 @@ impl SecretsApp {
             &self.new_btn,
             &self.reveal_btn,
             &self.copy_btn,
+            &self.otp_btn,
             &self.edit_btn,
             &self.delete_btn,
             &self.save_btn,
@@ -734,6 +886,7 @@ impl Application for SecretsApp {
             new_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("New"),
             reveal_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Reveal"),
             copy_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Copy"),
+            otp_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Copy code"),
             edit_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Edit"),
             delete_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Delete"),
             save_btn: Button::new(0.0, 0.0, BTN_W, BTN_H).with_label("Save"),
@@ -748,6 +901,9 @@ impl Application for SecretsApp {
             selected: None,
             revealed: None,
             pending_delete: None,
+            otp: None,
+            otp_absent: std::collections::HashSet::new(),
+            otp_drawn_left: 0,
             scroll_y: 0.0,
             scroll_motion: ScrollMotion::new(),
             // Placed by the first frame; empty until then so nothing hit-tests.
@@ -848,6 +1004,39 @@ impl Application for SecretsApp {
                     let _ = self.cmd_tx.send(Cmd::GetSecret { path: sel, purpose: Purpose::Copy });
                 }
             }
+            AppMessage::CopyOtpClicked => {
+                if let Some((code, _)) = self.shown_otp() {
+                    copy_then_clear(code.to_string());
+                    self.status_msg = format!("Code copied — clipboard clears in {CLIPBOARD_CLEAR_SECS} s");
+                    self.status_is_error = false;
+                }
+            }
+            AppMessage::Otp { path, reply } => {
+                // A reply for an entry no longer selected is dropped; the
+                // next selection asks afresh.
+                if self.otp.as_ref().is_none_or(|(p, _)| *p != path) {
+                    return;
+                }
+                self.otp = match reply {
+                    OtpReply::Code(code, left) => Some((
+                        path,
+                        Otp::Code {
+                            code,
+                            until: std::time::Instant::now() + std::time::Duration::from_secs(left),
+                            refreshing: false,
+                        },
+                    )),
+                    OtpReply::Absent => {
+                        self.otp_absent.insert(path);
+                        None
+                    }
+                    OtpReply::Failed(e) => {
+                        self.status_msg = format!("One-time code: {e}");
+                        self.status_is_error = true;
+                        Some((path, Otp::Failed))
+                    }
+                };
+            }
             AppMessage::NewClicked => self.open_form(None),
             AppMessage::EditClicked => {
                 if let Some(entry) = self.selected_entry().cloned() {
@@ -884,6 +1073,18 @@ impl Application for SecretsApp {
         if self.tick_scroll(dt) {
             *needs_rebuild = true;
         }
+        if self.ensure_otp() {
+            *needs_rebuild = true;
+        }
+        if self.shown_otp().is_some_and(|(_, left)| left != self.otp_drawn_left) {
+            *needs_rebuild = true;
+        }
+    }
+
+    /// While a code is up, wake often enough to step its countdown (tick
+    /// dt is not wall clock; the deadline is an `Instant`).
+    fn idle_poll_interval(&self) -> Option<std::time::Duration> {
+        self.shown_otp().map(|_| std::time::Duration::from_millis(250))
     }
 
     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<cce_ui::scene::paint::DisplayList> {
@@ -909,6 +1110,8 @@ impl Application for SecretsApp {
             self.ui_context.register_widget(id, ptr);
             let (id, ptr) = (self.copy_btn.id(), self.copy_btn.as_ptr_mut());
             self.ui_context.register_widget(id, ptr);
+            let (id, ptr) = (self.otp_btn.id(), self.otp_btn.as_ptr_mut());
+            self.ui_context.register_widget(id, ptr);
             let (id, ptr) = (self.edit_btn.id(), self.edit_btn.as_ptr_mut());
             self.ui_context.register_widget(id, ptr);
             let (id, ptr) = (self.delete_btn.id(), self.delete_btn.as_ptr_mut());
@@ -1035,7 +1238,7 @@ impl Application for SecretsApp {
                 }
                 self.save_btn.set_rect(dx, fy, BTN_W, BTN_H);
                 self.cancel_btn.set_rect(dx + BTN_W + pgap, fy, BTN_W, BTN_H);
-                for b in [&mut self.reveal_btn, &mut self.copy_btn, &mut self.edit_btn, &mut self.delete_btn, &mut self.new_btn] {
+                for b in [&mut self.reveal_btn, &mut self.copy_btn, &mut self.otp_btn, &mut self.edit_btn, &mut self.delete_btn, &mut self.new_btn] {
                     park(b);
                 }
             }
@@ -1066,6 +1269,14 @@ impl Application for SecretsApp {
                     };
                     pc.text_with(secret_text, dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
 
+                    let otp = self.shown_otp().map(|(code, left)| (group_code(code), left));
+                    if let Some((code, left)) = &otp {
+                        self.otp_drawn_left = *left;
+                        ay += 22.0;
+                        pc.text_with("one-time code".to_string(), dx, ay, 10.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
+                        pc.text_with(format!("{code}  ·  {left}s"), dx + 120.0, ay, 11.0, srgb_u8(cce_ui::colors::TEXT_FG), None, detail_bounds);
+                    }
+
                     self.reveal_btn.set_label(if revealed { "Hide" } else { "Reveal" });
                     self.delete_btn.set_label(
                         if self.pending_delete.as_deref() == Some(entry.path.as_str()) { "Confirm" } else { "Delete" },
@@ -1076,10 +1287,22 @@ impl Application for SecretsApp {
                     self.copy_btn.set_rect(dx + BTN_W + pgap, by, BTN_W, BTN_H);
                     self.edit_btn.set_rect(dx, by + BTN_H + pgap, BTN_W, BTN_H);
                     self.delete_btn.set_rect(dx + BTN_W + pgap, by + BTN_H + pgap, BTN_W, BTN_H);
+                    // Copy code ends the first row when the pane is wide
+                    // enough, else opens a third.
+                    if otp.is_some() {
+                        let third = dx + 2.0 * (BTN_W + pgap);
+                        if third + BTN_W <= dx + dw {
+                            self.otp_btn.set_rect(third, by, BTN_W, BTN_H);
+                        } else {
+                            self.otp_btn.set_rect(dx, by + 2.0 * (BTN_H + pgap), BTN_W, BTN_H);
+                        }
+                    } else {
+                        park(&mut self.otp_btn);
+                    }
                 } else {
                     let hint = if self.entries.is_empty() { "" } else { "Select an entry" };
                     pc.text_with(hint.to_string(), dx, hy, 11.0, srgb_u8(cce_ui::colors::TEXT_DIM), None, detail_bounds);
-                    for b in [&mut self.reveal_btn, &mut self.copy_btn, &mut self.edit_btn, &mut self.delete_btn] {
+                    for b in [&mut self.reveal_btn, &mut self.copy_btn, &mut self.otp_btn, &mut self.edit_btn, &mut self.delete_btn] {
                         park(b);
                     }
                 }
@@ -1182,6 +1405,9 @@ impl Application for SecretsApp {
         if self.copy_btn.take_click() {
             return Some(AppMessage::CopyClicked);
         }
+        if self.otp_btn.take_click() {
+            return Some(AppMessage::CopyOtpClicked);
+        }
         if self.edit_btn.take_click() {
             return Some(AppMessage::EditClicked);
         }
@@ -1322,3 +1548,19 @@ fn main() {
     env_logger::init();
     cce_ui::engine::run::<SecretsApp>();
 }
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn daemon_replies_parse() {
+        assert_eq!(parse_otp_reply("otp 123456 17"), OtpReply::Code("123456".into(), 17));
+        assert_eq!(parse_otp_reply("none"), OtpReply::Absent);
+        assert_eq!(parse_otp_reply("err not a mirrored item"), OtpReply::Failed("not a mirrored item".into()));
+        assert!(matches!(parse_otp_reply("otp 123456"), OtpReply::Failed(_)));
+        assert!(matches!(parse_otp_reply(""), OtpReply::Failed(_)));
+        assert_eq!(group_code("123456"), "123 456");
+        assert_eq!(group_code("12345678"), "12345678");
+    }
+}