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

commit2ed3c67770a534188aece5c4ac6d147b9fc47342
parent3cae368b24
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 09:59
A copied password leaves the clipboard on time even if the app is closed

The 30s clear was a thread in cce-secrets, while a forked wl-copy served
the secret. Closing the app within 30s of a copy cancelled the clear and
left the password on the clipboard indefinitely. It was also offered
without the sensitive-content hint, so a clipboard-history tool would
keep it.

The copy is now `timeout 30 wl-copy --foreground --sensitive` in its own
process group, with the secret on stdin. When timeout ends wl-copy, its
offer goes with it, whatever became of the app. If something else is
copied first, wl-copy has already exited on losing the selection, so the
timer never wipes newer content. --sensitive adds the
x-kde-passwordManagerHint type. When timeout or wl-copy is missing it
falls back to the in-process clear.

Checked in a shadow with a dummy string, the copy started by a parent
that exits at once. It pastes, lists x-kde-passwordManagerHint, and is
gone after the timer. A copy made in between survives the timer.

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

 src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++-------
 1 file changed, 46 insertions(+), 7 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index f88cf7a..449be20 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -277,15 +277,54 @@ fn group_code(code: &str) -> String {
 }
 
 /// Put `text` on the clipboard, and take it off again after
-/// [`CLIPBOARD_CLEAR_SECS`] if it is still there.
+/// [`CLIPBOARD_CLEAR_SECS`].
+///
+/// The clipboard is served by `wl-copy --foreground` under `timeout`, in a
+/// process group of its own, so the clear does not depend on this app: when
+/// `timeout` ends `wl-copy`, the offer it was serving goes with it. Until
+/// 2026-10-02 the clear was a thread in this process while a forked
+/// `wl-copy` kept serving the secret, so closing cce-secrets within the 30s
+/// left the password on the clipboard indefinitely. If something else is
+/// copied first, `wl-copy` has already exited on losing the selection, so
+/// the timer can never wipe the newer content. `--sensitive` sets the
+/// `x-kde-passwordManagerHint` that clipboard-history tools honour.
+///
+/// Falls back to the old in-process clear when `timeout` or `wl-copy` is
+/// missing.
 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("");
+    use std::os::unix::process::CommandExt;
+    let spawned = std::process::Command::new("timeout")
+        .arg(CLIPBOARD_CLEAR_SECS.to_string())
+        .args(["wl-copy", "--foreground", "--sensitive"])
+        .stdin(std::process::Stdio::piped())
+        .stdout(std::process::Stdio::null())
+        .stderr(std::process::Stdio::null())
+        .process_group(0)
+        .spawn();
+    match spawned {
+        Ok(mut child) => {
+            // The secret goes in on stdin, never argv. The thread reaps the
+            // child while this app lives; if the app exits first, the child
+            // carries on and is reaped by whoever inherits it.
+            std::thread::spawn(move || {
+                if let Some(mut stdin) = child.stdin.take() {
+                    use std::io::Write;
+                    let _ = stdin.write_all(text.as_bytes());
+                }
+                let _ = child.wait();
+            });
         }
-    });
+        Err(e) => {
+            eprintln!("cce-secrets: timeout/wl-copy unavailable ({e}); clearing in-process");
+            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 ─────────────────────────────────────────────────