secrets manager
git clone https://git.lucas.co/cce-secrets.git
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");
+ }
+}