goblin: auto-clear a revealed nsec from the clipboard after a delay
Copying a revealed nsec (or a post-rotation new key) left the secret sitting in the clipboard indefinitely. Add copy_secret_to_buffer: it copies now and, after a short delay, clears the clipboard only if it still holds exactly that secret (compare-then-clear), so whatever the user copied since is never clobbered. Both nsec copy sites now use it. Desktop clears through the long-lived clipboard owner on a background thread; Android does the same over its JNI clipboard via the established non-GUI app-handle pattern; other platforms fall back to a plain copy.
This commit is contained in:
@@ -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;", &[])
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user