From 8097d1944cae5c1c71758aee6f6b90a559dbed72 Mon Sep 17 00:00:00 2001 From: Breadway Date: Fri, 17 Jul 2026 03:17:24 +0800 Subject: [PATCH] breadclip: privacy hardening + event-driven daemon - history.db and images/*.png are now created with 0600 permissions (owner-only) instead of default umask, since clipboard history can contain plaintext passwords/tokens. - breadclipd never persists clipboard content flagged with the x-kde-passwordManagerHint MIME type (the convention KeePassXC, Bitwarden, etc. use to mark content they own). - Replaced breadclipd's 500ms busy-poll loop (2-3 wl-paste forks per cycle, forever) with wl-paste --watch, so it only reacts on actual clipboard changes. - Documented both behaviors in the README. --- README.md | 6 ++ breadclip-core/src/lib.rs | 21 ++++++- breadclipd/src/main.rs | 114 +++++++++++++++++++++++++++----------- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index c6cf483..debe88b 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,12 @@ History is stored under `$XDG_DATA_HOME/breadclip/` (typically `~/.local/share/b The daemon keeps at most 200 text entries and 50 image entries, trimming oldest entries automatically. +### Privacy + +- `history.db` and every file under `images/` are created with `0600` permissions (owner read/write only), regardless of your umask. +- breadclipd **never persists clipboard content flagged as sensitive by a password manager**. If a clipboard offer advertises the `x-kde-passwordManagerHint` MIME type — the convention used by KeePassXC, Bitwarden, and other password managers to mark content they own — that copy is skipped entirely and never reaches the database. +- That said, this is still a plaintext SQLite database of everything else you copy. Anything copied by an app that doesn't set the hint (e.g. copying a password from a terminal or a non-integrated app) will be stored like any other text entry. Treat `history.db` as sensitive, and don't rely on it as your only safeguard. + ## Theming `breadclip` inherits its colour palette from `bread-theme`. The panel renders with an 80% opaque background so Hyprland's `layerrule = blur` can show a frosted-glass effect behind it. diff --git a/breadclip-core/src/lib.rs b/breadclip-core/src/lib.rs index c1aab20..75035ff 100644 --- a/breadclip-core/src/lib.rs +++ b/breadclip-core/src/lib.rs @@ -1,6 +1,7 @@ use rusqlite::{params, Connection, Result as SqlResult}; use sha2::{Digest, Sha256}; -use std::path::PathBuf; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub struct ClipEntry { @@ -20,7 +21,8 @@ impl HistoryDb { pub fn open() -> SqlResult { let dir = data_dir(); std::fs::create_dir_all(&dir).ok(); - let conn = Connection::open(dir.join("history.db"))?; + let db_path = dir.join("history.db"); + let conn = Connection::open(&db_path)?; conn.execute_batch( "CREATE TABLE IF NOT EXISTS history ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -32,6 +34,9 @@ impl HistoryDb { ); CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", )?; + // history.db can contain plaintext secrets copied to the clipboard + // (passwords, tokens, TOTP codes); restrict it to owner-only, every open. + restrict_permissions(&db_path); Ok(Self { conn }) } @@ -55,6 +60,7 @@ impl HistoryDb { let path = images_dir.join(format!("{}.png", &hash[..16])); if !path.exists() { std::fs::write(&path, png_bytes).ok(); + restrict_permissions(&path); } let path_str = path.to_string_lossy().to_string(); let ts = unix_now(); @@ -145,6 +151,17 @@ impl HistoryDb { } } +/// Restrict a file to owner-only read/write (0600). Clipboard history can +/// contain passwords and other secrets, so this must not be world/group +/// readable regardless of the process umask. +fn restrict_permissions(path: &Path) { + if let Ok(meta) = std::fs::metadata(path) { + let mut perms = meta.permissions(); + perms.set_mode(0o600); + let _ = std::fs::set_permissions(path, perms); + } +} + pub fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); diff --git a/breadclipd/src/main.rs b/breadclipd/src/main.rs index 1755962..8afbe4d 100644 --- a/breadclipd/src/main.rs +++ b/breadclipd/src/main.rs @@ -1,6 +1,17 @@ -use breadclip_core::{sha256_hex, HistoryDb}; +use breadclip_core::HistoryDb; use std::{env, fs, path::PathBuf, process::Command, thread, time::Duration}; +/// Argument used to invoke this same binary as the one-shot handler for +/// `wl-paste --watch`. Kept as a plain positional arg (no clap dependency +/// needed for a single internal flag). +const CAPTURE_FLAG: &str = "--capture-once"; + +/// MIME type convention (originating with KDE's Klipper) that password +/// managers such as KeePassXC and Bitwarden set on clipboard content they +/// own, signaling "don't persist this". Any offer advertising it is skipped +/// entirely. +const PASSWORD_HINT_MIME: &str = "x-kde-passwordManagerHint"; + fn get_available_types() -> Vec { Command::new("wl-paste") .args(["--list-types"]) @@ -61,7 +72,53 @@ fn acquire_lock() -> bool { true } +/// Invoked once per clipboard-change event (as the handler command for +/// `wl-paste --watch`). Reads whatever is on the clipboard right now, skips +/// it entirely if a password manager flagged it as sensitive, and otherwise +/// persists a text or image entry to the history DB. +fn capture_once() { + let types = get_available_types(); + + if types.iter().any(|t| t == PASSWORD_HINT_MIME) { + // Password manager (KeePassXC, Bitwarden, etc.) marked this copy as + // sensitive via the x-kde-passwordManagerHint convention — never + // persist it. + return; + } + + let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); + let has_text = types.iter().any(|t| t.starts_with("text/")); + + let db = match HistoryDb::open() { + Ok(db) => db, + Err(e) => { + eprintln!("breadclipd: failed to open database: {e}"); + return; + } + }; + + if has_image && !has_text { + if let Some(bytes) = get_clipboard_image() { + if let Err(e) = db.insert_image(&bytes) { + eprintln!("breadclipd: insert image: {e}"); + } + } + } else if has_text { + if let Some(text) = get_clipboard_text() { + if let Err(e) = db.insert_text(&text) { + eprintln!("breadclipd: insert text: {e}"); + } + } + } +} + fn main() { + let args: Vec = env::args().collect(); + if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) { + capture_once(); + return; + } + if !acquire_lock() { std::process::exit(1); } @@ -78,10 +135,17 @@ fn main() { std::process::exit(1); } - let db = match HistoryDb::open() { - Ok(db) => db, + // Fail fast (before we start watching) if the DB can't be opened. + if let Err(e) = HistoryDb::open() { + eprintln!("breadclipd: failed to open database: {e}"); + let _ = fs::remove_file(lock_file()); + std::process::exit(1); + } + + let exe = match env::current_exe() { + Ok(p) => p, Err(e) => { - eprintln!("breadclipd: failed to open database: {e}"); + eprintln!("breadclipd: could not resolve own executable path: {e}"); let _ = fs::remove_file(lock_file()); std::process::exit(1); } @@ -89,37 +153,25 @@ fn main() { eprintln!("breadclipd: started (pid {})", std::process::id()); - let mut last_text_hash: Option = None; - let mut last_image_hash: Option = None; - + // `wl-paste --watch ` runs once per clipboard-change event + // instead of polling — zero idle cost between changes, and no more + // forking wl-paste 4-6 times a second. Each event re-invokes this same + // binary with --capture-once to do a single read-and-store pass. loop { - let types = get_available_types(); - let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); - let has_text = types.iter().any(|t| t.starts_with("text/")); + let status = Command::new("wl-paste") + .args(["--watch"]) + .arg(&exe) + .arg(CAPTURE_FLAG) + .status(); - if has_image && !has_text { - if let Some(bytes) = get_clipboard_image() { - let hash = sha256_hex(&bytes); - if Some(&hash) != last_image_hash.as_ref() { - last_image_hash = Some(hash); - last_text_hash = None; - if let Err(e) = db.insert_image(&bytes) { - eprintln!("breadclipd: insert image: {e}"); - } - } + match status { + Ok(s) => { + eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s"); } - } else if has_text { - if let Some(text) = get_clipboard_text() { - let hash = sha256_hex(text.as_bytes()); - if Some(&hash) != last_text_hash.as_ref() { - last_text_hash = Some(hash); - if let Err(e) = db.insert_text(&text) { - eprintln!("breadclipd: insert text: {e}"); - } - } + Err(e) => { + eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s"); } } - - thread::sleep(Duration::from_millis(500)); + thread::sleep(Duration::from_secs(2)); } }