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.
This commit is contained in:
Breadway 2026-07-17 03:17:24 +08:00
parent 86ebe5d050
commit 8097d1944c
3 changed files with 108 additions and 33 deletions

View file

@ -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. 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 ## 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. `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.

View file

@ -1,6 +1,7 @@
use rusqlite::{params, Connection, Result as SqlResult}; use rusqlite::{params, Connection, Result as SqlResult};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::path::PathBuf; use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ClipEntry { pub struct ClipEntry {
@ -20,7 +21,8 @@ impl HistoryDb {
pub fn open() -> SqlResult<Self> { pub fn open() -> SqlResult<Self> {
let dir = data_dir(); let dir = data_dir();
std::fs::create_dir_all(&dir).ok(); 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( conn.execute_batch(
"CREATE TABLE IF NOT EXISTS history ( "CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
@ -32,6 +34,9 @@ impl HistoryDb {
); );
CREATE INDEX IF NOT EXISTS history_ts ON history(timestamp DESC);", 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 }) Ok(Self { conn })
} }
@ -55,6 +60,7 @@ impl HistoryDb {
let path = images_dir.join(format!("{}.png", &hash[..16])); let path = images_dir.join(format!("{}.png", &hash[..16]));
if !path.exists() { if !path.exists() {
std::fs::write(&path, png_bytes).ok(); std::fs::write(&path, png_bytes).ok();
restrict_permissions(&path);
} }
let path_str = path.to_string_lossy().to_string(); let path_str = path.to_string_lossy().to_string();
let ts = unix_now(); 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 { pub fn sha256_hex(data: &[u8]) -> String {
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(data); hasher.update(data);

View file

@ -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}; 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<String> { fn get_available_types() -> Vec<String> {
Command::new("wl-paste") Command::new("wl-paste")
.args(["--list-types"]) .args(["--list-types"])
@ -61,7 +72,53 @@ fn acquire_lock() -> bool {
true 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() { fn main() {
let args: Vec<String> = env::args().collect();
if args.get(1).map(String::as_str) == Some(CAPTURE_FLAG) {
capture_once();
return;
}
if !acquire_lock() { if !acquire_lock() {
std::process::exit(1); std::process::exit(1);
} }
@ -78,10 +135,17 @@ fn main() {
std::process::exit(1); std::process::exit(1);
} }
let db = match HistoryDb::open() { // Fail fast (before we start watching) if the DB can't be opened.
Ok(db) => db, 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) => { Err(e) => {
eprintln!("breadclipd: failed to open database: {e}"); eprintln!("breadclipd: could not resolve own executable path: {e}");
let _ = fs::remove_file(lock_file()); let _ = fs::remove_file(lock_file());
std::process::exit(1); std::process::exit(1);
} }
@ -89,37 +153,25 @@ fn main() {
eprintln!("breadclipd: started (pid {})", std::process::id()); eprintln!("breadclipd: started (pid {})", std::process::id());
let mut last_text_hash: Option<String> = None; // `wl-paste --watch <cmd>` runs <cmd> once per clipboard-change event
let mut last_image_hash: Option<String> = None; // 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 { loop {
let types = get_available_types(); let status = Command::new("wl-paste")
let has_image = types.iter().any(|t| t == "image/png" || t == "image/jpeg"); .args(["--watch"])
let has_text = types.iter().any(|t| t.starts_with("text/")); .arg(&exe)
.arg(CAPTURE_FLAG)
.status();
if has_image && !has_text { match status {
if let Some(bytes) = get_clipboard_image() { Ok(s) => {
let hash = sha256_hex(&bytes); eprintln!("breadclipd: wl-paste --watch exited ({s}), restarting in 2s");
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}");
}
}
} }
} else if has_text { Err(e) => {
if let Some(text) = get_clipboard_text() { eprintln!("breadclipd: failed to spawn wl-paste --watch: {e}, retrying in 2s");
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}");
}
}
} }
} }
thread::sleep(Duration::from_secs(2));
thread::sleep(Duration::from_millis(500));
} }
} }