diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index aa410888..2f53d39e 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -27,6 +27,11 @@ use winit::platform::android::activity::AndroidApp; use crate::gui::platform::PlatformCallbacks; +/// How long a revealed secret (e.g. an nsec) may sit in the clipboard before it +/// is auto-cleared, if it is still there. Long enough to paste into another app, +/// short enough that it does not linger. +const CLIPBOARD_SECRET_TTL_SECS: u64 = 45; + /// Android platform implementation. #[derive(Clone)] pub struct Android { @@ -86,6 +91,21 @@ impl PlatformCallbacks for Android { ); } + fn copy_secret_to_buffer(&self, data: String) { + // Copy now, then clear after a delay if the clipboard still holds exactly + // this secret (compare-then-clear), so a revealed nsec does not linger. + // Runs on a detached thread that reaches Java via the cloned app handle, + // the same non-GUI JNI path as the payment notifications. + self.copy_string_to_buffer(data.clone()); + let platform = self.clone(); + std::thread::spawn(move || { + std::thread::sleep(std::time::Duration::from_secs(CLIPBOARD_SECRET_TTL_SECS)); + if platform.get_string_from_buffer() == data { + platform.copy_string_to_buffer(String::new()); + } + }); + } + fn get_string_from_buffer(&self) -> String { let result = self .call_java_method("pasteText", "()Ljava/lang/String;", &[]) diff --git a/src/gui/platform/desktop/mod.rs b/src/gui/platform/desktop/mod.rs index d4302bba..4b48d783 100644 --- a/src/gui/platform/desktop/mod.rs +++ b/src/gui/platform/desktop/mod.rs @@ -24,6 +24,11 @@ use std::thread; use crate::gui::platform::PlatformCallbacks; +/// How long a revealed secret (e.g. an nsec) may sit in the clipboard before it +/// is auto-cleared, if it is still there. Long enough to paste into another app, +/// short enough that it does not linger. +const CLIPBOARD_SECRET_TTL_SECS: u64 = 45; + /// Desktop platform related actions. #[derive(Clone)] pub struct Desktop { @@ -254,6 +259,35 @@ impl PlatformCallbacks for Desktop { ); } + fn copy_secret_to_buffer(&self, data: String) { + // Copy now, then clear after a delay, but only if the clipboard still + // holds exactly this secret, so we never clobber whatever the user copied + // since. A revealed nsec must not sit in the clipboard forever. + self.copy_string_to_buffer(data.clone()); + let clip = self.clipboard.clone(); + thread::spawn(move || { + thread::sleep(std::time::Duration::from_secs(CLIPBOARD_SECRET_TTL_SECS)); + let mut guard = clip.lock(); + // Open the shared instance lazily (mirrors `with_clipboard`); clearing + // through the long-lived owner is what actually relinquishes the + // selection on Linux. + if guard.is_none() { + match arboard::Clipboard::new() { + Ok(c) => *guard = Some(c), + Err(e) => { + log::error!("clipboard: failed to open for secret clear: {e}"); + return; + } + } + } + if let Some(clipboard) = guard.as_mut() + && clipboard.get_text().map(|t| t == data).unwrap_or(false) + { + let _ = clipboard.set_text(String::new()); + } + }); + } + fn get_string_from_buffer(&self) -> String { self.with_clipboard( |clipboard| clipboard.get_text().unwrap_or_default(), diff --git a/src/gui/platform/mod.rs b/src/gui/platform/mod.rs index 33f5d946..201fffb2 100644 --- a/src/gui/platform/mod.rs +++ b/src/gui/platform/mod.rs @@ -25,6 +25,14 @@ pub trait PlatformCallbacks { fn set_context(&mut self, ctx: &egui::Context); fn exit(&self); fn copy_string_to_buffer(&self, data: String); + /// Copy a SECRET (e.g. a revealed nsec) to the clipboard, then auto-clear it + /// after a short delay so it does not linger there forever. The clear is + /// compare-then-clear: it only wipes the clipboard if it STILL holds exactly + /// this secret, so whatever the user copied since is never clobbered. + /// Defaults to a plain copy on platforms without a timed clear. + fn copy_secret_to_buffer(&self, data: String) { + self.copy_string_to_buffer(data); + } fn get_string_from_buffer(&self) -> String; fn start_camera(&self); fn stop_camera(&self); diff --git a/src/gui/views/goblin/mod.rs b/src/gui/views/goblin/mod.rs index 15a4e79f..073ebb94 100644 --- a/src/gui/views/goblin/mod.rs +++ b/src/gui/views/goblin/mod.rs @@ -3948,7 +3948,9 @@ impl GoblinWalletView { ui.add_space(10.0); if w::big_action_on_card(ui, &t!("goblin.advanced.copy_nsec")).clicked() { - cb.copy_string_to_buffer(nsec.clone()); + // Secret: auto-clears from the clipboard after a delay + // (compare-then-clear) so it does not linger there. + cb.copy_secret_to_buffer(nsec.clone()); } ui.add_space(8.0); let qr_label = if adv.nsec_qr { @@ -4789,7 +4791,9 @@ impl GoblinWalletView { ui.add_space(10.0); if w::big_action_on_card(ui, &t!("goblin.settings.copy_new_nsec")).clicked() { if let Some(nsec) = wallet.nostr_service().and_then(|s| s.nsec()) { - cb.copy_string_to_buffer(nsec); + // Secret: auto-clears from the clipboard after a delay + // (compare-then-clear) so it does not linger there. + cb.copy_secret_to_buffer(nsec); cb.vibrate_copy(); } }