diff --git a/Cargo.lock b/Cargo.lock index 49e5ff2..e4435e8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,7 +2937,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stash-clipboard" -version = "0.4.0" +version = "0.3.6" dependencies = [ "age", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 33f7463..2c46f71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "stash-clipboard" description = "Wayland clipboard manager with fast persistent history and multi-media support" -version = "0.4.0" +version = "0.3.6" edition = "2024" authors = [ "NotAShelf " ] license = "MPL-2.0" diff --git a/README.md b/README.md index 6e972bb..775f618 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,9 @@
- Lightweight & feature-rich Wayland clipboard "manager" with fast persistent - history and robust multi-media support. Stores and previews clipboard - entries (text, images) on the clipboard with a neat TUI and advanced - scripting capabilities. + Lightweight & feature-rich Wayland clipboard "manager" with fast persistent history and + robust multi-media support. Stores and previews clipboard entries (text, images) + on the clipboard with a neat TUI and advanced scripting capabilities.
@@ -53,8 +52,6 @@ with many features such as but not necessarily limited to: - Drop-in replacement for `wl-clipboard` tools (`wl-copy` and `wl-paste`) - Sensitive clipboard filtering via regex (see below) - Sensitive clipboard filtering by application (see below) -- Password manager hint filtering (`x-kde-passwordManagerHint`) -- Optional at-rest encryption for database entries using age on top of the existing features of Cliphist, which are as follows: @@ -273,8 +270,8 @@ stash db stats - `stash db vacuum`: Optimize the database using SQLite's VACUUM command, reclaiming space and improving performance. - `stash db stats`: Display database statistics including total/active/expired - entry counts, encrypted/undecryptable entry counts, storage size, and page - information. + entry counts, storage size, and page information. This is provided purely for + convenience and the rule of the cool. ### Watch clipboard for changes and store automatically @@ -360,36 +357,21 @@ sensitive pattern, using a regular expression. This is useful for preventing accidental storage of secrets, passwords, or other sensitive data. You don't want sensitive data ending up in your persistent clipboard, right? -The filter can be configured in several ways, as part of three separate +The filter can be configured in one of three ways, as part of two separate features. #### Clipboard Filtering by Entry Regex -This can be configured in several ways. The simplest is the **environment -variable** `STASH_SENSITIVE_REGEX` set to a valid regex pattern; if the -clipboard text matches, it will not be stored. Useful for trivial secrets such -as GitHub tokens or secrets that follow a rule. You would typically set this in -your `~/.bashrc` or similar, but in some cases this might be a security flaw. +This can be configured in one of two ways. You can use the **environment +variable** `STASTH_SENSITIVE_REGEX` to a valid regex pattern, and if the +clipboard text matches the regex it will not be stored. This can be used for +trivial secrets such as but not limited to GitHub tokens or secrets that follow +a rule, e.g. a prefix. You would typically set this in your `~/.bashrc` or +similar but in some cases this might be a security flaw. -The less-insecure alternatives are: - -- `STASH_SENSITIVE_REGEX_FILE`: read the regex from a file path. Useful with - NixOS secrets managers like agenix or sops-nix. - - ```bash - export STASH_SENSITIVE_REGEX_FILE=/run/secrets/stash/clipboard_filter - ``` - -- `STASH_SENSITIVE_REGEX_COMMAND`: execute a shell command whose stdout is the - regex pattern. Works well with password managers. - - ```bash - export STASH_SENSITIVE_REGEX_COMMAND="pass show stash/clipboard-filter" - ``` - -The safest option is **Systemd LoadCredential**. If Stash is running as a -Systemd service, you can provide a regex pattern using a credential file. For -example, add to your `stash.service`: +The safer alternative to this is using **Systemd LoadCrediental**. If Stash is +running as a Systemd service, you can provide a regex pattern using a crediental +file. For example, add to your `stash.service`: ```dosini LoadCredential=clipboard_filter:/etc/stash/clipboard_filter @@ -400,9 +382,9 @@ quotes). This is done automatically in the [vendored Systemd service](./contrib/stash.service). Remember to set the appropriate file permissions if using this option. -The service will check the credential file first, then the command, then the -file path, then the environment variable. If a clipboard entry matches the -regex, it will be skipped and a warning will be logged. +The service will check the credential file first, then the environment variable. +If a clipboard entry matches the regex, it will be skipped and a warning will be +logged. > [!TIP] > **Example regex to block common password patterns**: @@ -433,72 +415,6 @@ be only copied to the clipboard. > > `stash --excluded-apps Bitwarden watch` -#### Clipboard Filtering by Password Manager Hint - -Stash automatically skips entries whose clipboard offer includes the -`x-kde-passwordManagerHint` MIME type. This is the convention used by KeePassXC -and compatible password managers to signal that clipboard content is sensitive -and should not be persisted. - -No configuration is required. If the hint is present in the clipboard offer, the -entry is dropped before storage. The entry is still available in your clipboard -— it is only excluded from the persistent database. - -> [!NOTE] -> This filter only applies via the watch daemon (`stash watch`), where MIME type -> metadata is available from the Wayland clipboard protocol. Manual -> `stash store` invocations do not have this context and are not filtered. - -### Database Encryption - -Stash supports encrypting clipboard entries at rest using the -[age](https://age-encryption.org/) encryption format. - -Encryption is **opt-in** and only activates when a passphrase is configured. -When one is configured, all new entries are encrypted before storage and -decrypted transparently on retrieval. Entries stored without encryption remain -as plaintext. Only new entries written after configuring encryption are -encrypted. - -> [!WARNING] -> Removing the passphrase after encrypted entries have been stored leaves those -> entries permanently unreadable. There is no migration path short of wiping the -> database. `stash db stats` reports affected entries as Undecryptable. -> -> Full-text search (`stash delete --type query`, TUI search) operates on raw -> database contents. Encrypted entries will not match any search query. - -#### Configuration - -Provide a passphrase in one of these ways (checked in order): - -1. **Systemd LoadCredential** (safest): add to `stash.service`: - - ```dosini - LoadCredential=stash_encryption_passphrase:/etc/stash/encryption_passphrase - ``` - -2. **Command** — stdout of a shell command: - - ```bash - export STASH_ENCRYPTION_PASSPHRASE_COMMAND="pass show stash/encryption-key" - ``` - -3. **File** — path to a file containing the passphrase: - - ```bash - export STASH_ENCRYPTION_PASSPHRASE_FILE=/run/secrets/stash/encryption_passphrase - ``` - -4. **Environment variable** (least secure): - - ```bash - export STASH_ENCRYPTION_PASSPHRASE="your-secure-passphrase" - ``` - -> [!TIP] -> Back up your passphrase. Encrypted entries cannot be recovered without it. - ## Motivation I've been a long-time user of Cliphist. You can probably tell by the number of diff --git a/flake.lock b/flake.lock index 7fe4959..a2247e8 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1780532242, - "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", + "lastModified": 1778106249, + "narHash": "sha256-cM/AuKy5tMhwOOQIbha8ZRRMHVfNf7cv2aljIw+qoCg=", "owner": "ipetkov", "repo": "crane", - "rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37", + "rev": "6d015ea29630b7ad2402841386da2cb617a470a7", "type": "github" }, "original": { diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix index b577530..23072a0 100644 --- a/nix/modules/nixos.nix +++ b/nix/modules/nixos.nix @@ -5,7 +5,7 @@ self: { ... }: let inherit (lib.modules) mkIf; - inherit (lib.options) mkOption mkEnableOption mkPackageOption; + inherit (lib.options) mkOption mkEnableOption mkPackageOption literalMD; inherit (lib.types) listOf str; inherit (lib.strings) concatStringsSep; inherit (lib.meta) getExe; @@ -15,9 +15,7 @@ in { options.services.stash-clipboard = { enable = mkEnableOption "stash, a Wayland clipboard manager"; - package = mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} ["stash"] { - pkgsText = "self.packages.\${pkgs.stdenv.hostPlatform.system}"; - }; + package = mkPackageOption self.packages.${pkgs.system} ["stash"] {}; flags = mkOption { type = listOf str; @@ -30,7 +28,7 @@ in { type = str; default = ""; example = "{file}`/etc/stash/clipboard_filter`"; - description = '' + description = literalMD '' File containing a regular expression to catch sensitive patterns. The file passed to this option must contain your regex pattern with no quotes. diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs index 9ca9dc7..f5312a7 100644 --- a/src/clipboard/persist.rs +++ b/src/clipboard/persist.rs @@ -22,25 +22,9 @@ static SERVING_PID: AtomicI32 = AtomicI32::new(0); /// Get the current serving PID if any. Used by the watch loop to avoid /// duplicate persistence processes. -/// -/// Probes the stored PID with `kill(pid, 0)` to detect children that have -/// already exited (SIGCHLD is ignored so we never get reaped notifications). -/// A stale PID is cleared and `None` is returned. pub fn get_serving_pid() -> Option { let pid = SERVING_PID.load(Ordering::SeqCst); - if pid == 0 { - return None; - } - - // Signal 0 = existence check, no signal sent. Returns 0 if alive, - // -1 (ESRCH) if the PID is gone. - if unsafe { libc::kill(pid, 0) } == 0 { - Some(pid) - } else { - let _ = - SERVING_PID.compare_exchange(pid, 0, Ordering::SeqCst, Ordering::SeqCst); - None - } + if pid != 0 { Some(pid) } else { None } } /// Result type for persistence operations. @@ -173,18 +157,6 @@ unsafe fn fork_and_serve(prepared: PreparedCopy) -> PersistenceResult<()> { libc::signal(libc::SIGCHLD, libc::SIG_IGN); } - // Replace any prior serving child: a new clipboard entry supersedes the - // old offer (the compositor will invalidate it anyway the moment the new - // selection is taken). Without this, the old child lingers serving stale - // data until MAX_SERVE_REQUESTS or invalidation. - let prior = SERVING_PID.swap(0, Ordering::SeqCst); - if prior > 0 && unsafe { libc::kill(prior, 0) } == 0 { - unsafe { - libc::kill(prior, libc::SIGTERM); - } - log::debug!("terminated prior persistence child (pid: {prior})"); - } - match unsafe { libc::fork() } { 0 => { // Child process - clear serving PID diff --git a/src/db/mod.rs b/src/db/mod.rs index 41aec9f..ba1c28f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1024,17 +1024,6 @@ impl SqliteClipboardDb { } /// Clean up all expired entries. Returns count deleted. - pub fn expire_ttl_entries(&self) -> Result { - self - .conn - .execute( - "UPDATE clipboard SET is_expired = 1 WHERE expires_at IS NOT NULL AND \ - (is_expired IS NULL OR is_expired = 0)", - [], - ) - .map_err(|e| StashError::Trim(e.to_string().into())) - } - pub fn cleanup_expired(&self) -> Result { let now = Self::now(); self @@ -1152,15 +1141,13 @@ impl SqliteClipboardDb { #[cfg(not(feature = "encryption"))] let undecryptable: i64 = encrypted; - let db_path = self.db_path.display(); Ok(format!( "Database Statistics:\n\nEntries:\nTotal: \ {total}\nActive: {active}\nExpired: \ {expired}\nWith TTL: \ {with_expiration}\nEncrypted: \ {encrypted}\nUndecryptable: \ - {undecryptable}\n\nStorage:\nPath: \ - {db_path}\nSize: {size_mb:.2} MB \ + {undecryptable}\n\nStorage:\nSize: {size_mb:.2} MB \ ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ {page_size} bytes" )) @@ -1231,11 +1218,6 @@ fn load_sensitive_regex() -> Option { /// previously encrypted entries permanently undecryptable, so the permanent /// cache prevents accidental passphrase changes from corrupting the /// clipboard history. -/// -/// Removing the passphrase entirely (disabling encryption) after entries have -/// been stored encrypted also renders those entries permanently unreadable. -/// There is no migration path short of wiping the database. `stash stats` -/// reports affected entries as Undecryptable. #[cfg(feature = "encryption")] fn load_encryption_passphrase() -> Option { use std::process::Command; @@ -1268,18 +1250,25 @@ fn load_encryption_passphrase() -> Option { Some(secret) } -/// Decrypt age-encrypted data. -/// -/// `age::scrypt::Identity::new` is cheap since it stores the passphrase only. -/// The scrypt KDF runs inside `age::decrypt` per call, on the per-file salt -/// embedded in the ciphertext header. Caching the Identity would not avoid -/// it. The passphrase itself is cached by [`load_encryption_passphrase`]. +/// Decrypt age-encrypted data using a cached scrypt identity. #[cfg(feature = "encryption")] fn decrypt_cached(ciphertext: &[u8]) -> Result, StashError> { - let passphrase = load_encryption_passphrase() - .ok_or_else(|| StashError::Decryption("no passphrase configured".into()))?; - let identity = age::scrypt::Identity::new(passphrase); - age::decrypt(&identity, ciphertext) + static CACHE: OnceLock>> = + OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(None)); + let mut guard = cache.lock().map_err(|e| { + StashError::Decryption(format!("identity cache lock poisoned: {e}").into()) + })?; + if guard.is_none() { + let passphrase = load_encryption_passphrase().ok_or_else(|| { + StashError::Decryption("no passphrase configured".into()) + })?; + *guard = Some(age::scrypt::Identity::new(passphrase)); + } + let identity = guard + .as_ref() + .ok_or_else(|| StashError::Decryption("identity not available".into()))?; + age::decrypt(identity, ciphertext) .map_err(|e| StashError::Decryption(e.to_string().into())) } diff --git a/src/main.rs b/src/main.rs index a711c8d..f006d36 100644 --- a/src/main.rs +++ b/src/main.rs @@ -169,13 +169,6 @@ enum DbAction { ask: bool, }, - /// Immediately expire all entries with a TTL - Expire { - /// Ask for confirmation before expiring - #[arg(long)] - ask: bool, - }, - /// Optimize database using VACUUM Vacuum, @@ -413,28 +406,6 @@ fn main() -> eyre::Result<()> { } } }, - DbAction::Expire { ask } => { - let should_proceed = !ask - || confirm( - "Are you sure you want to immediately expire all entries with \ - a TTL?", - ); - if should_proceed { - match db.expire_ttl_entries() { - Ok(0) => { - println!("no entries with a TTL to expire"); - }, - Ok(count) => { - println!("marked {count} entries as expired"); - }, - Err(e) => { - log::error!("failed to expire entries: {e}"); - }, - } - } else { - log::info!("db expire command aborted by user."); - } - }, DbAction::Vacuum => { match db.vacuum() { Ok(()) => {