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:
parent
86ebe5d050
commit
8097d1944c
3 changed files with 108 additions and 33 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue