From 656709bd19a1571d06fe2e3f268c00421258c3f7 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 12:29:21 +0300 Subject: [PATCH 1/9] nix/modules: fix `pkgs.stdenv` deprecation in NixOS module Signed-off-by: NotAShelf Change-Id: I5b5224f809400a385e98c569e0ea63bf6a6a6964 --- nix/modules/nixos.nix | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/nix/modules/nixos.nix b/nix/modules/nixos.nix index 23072a0..b577530 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 literalMD; + inherit (lib.options) mkOption mkEnableOption mkPackageOption; inherit (lib.types) listOf str; inherit (lib.strings) concatStringsSep; inherit (lib.meta) getExe; @@ -15,7 +15,9 @@ in { options.services.stash-clipboard = { enable = mkEnableOption "stash, a Wayland clipboard manager"; - package = mkPackageOption self.packages.${pkgs.system} ["stash"] {}; + package = mkPackageOption self.packages.${pkgs.stdenv.hostPlatform.system} ["stash"] { + pkgsText = "self.packages.\${pkgs.stdenv.hostPlatform.system}"; + }; flags = mkOption { type = listOf str; @@ -28,7 +30,7 @@ in { type = str; default = ""; example = "{file}`/etc/stash/clipboard_filter`"; - description = literalMD '' + description = '' File containing a regular expression to catch sensitive patterns. The file passed to this option must contain your regex pattern with no quotes. From 384ac708ebcccbc519a22e86bc303c081fae94e9 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 12:52:55 +0300 Subject: [PATCH 2/9] db: remove unnecessary identity cache from `decrypt_cached` Signed-off-by: NotAShelf Change-Id: I9228809562cc9b2f7c0a9d7ece9f5ada6a6a6964 --- src/db/mod.rs | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index ba1c28f..90b3c13 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1218,6 +1218,11 @@ 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; @@ -1250,25 +1255,18 @@ fn load_encryption_passphrase() -> Option { Some(secret) } -/// Decrypt age-encrypted data using a cached scrypt identity. +/// 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`]. #[cfg(feature = "encryption")] fn decrypt_cached(ciphertext: &[u8]) -> Result, StashError> { - 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) + 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) .map_err(|e| StashError::Decryption(e.to_string().into())) } From ad70e651257ac32e26fffb456c5a956757911b99 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 13:32:53 +0300 Subject: [PATCH 3/9] docs: document encryption and expand filtering options documentation Signed-off-by: NotAShelf Change-Id: I2cc3b30ef1c8f1c669babdfdce501fba6a6a6964 --- README.md | 120 ++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 102 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 775f618..6e972bb 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,10 @@
- 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.
@@ -52,6 +53,8 @@ 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: @@ -270,8 +273,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, storage size, and page information. This is provided purely for - convenience and the rule of the cool. + entry counts, encrypted/undecryptable entry counts, storage size, and page + information. ### Watch clipboard for changes and store automatically @@ -357,21 +360,36 @@ 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 one of three ways, as part of two separate +The filter can be configured in several ways, as part of three separate features. #### Clipboard Filtering by Entry Regex -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. +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. -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`: +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`: ```dosini LoadCredential=clipboard_filter:/etc/stash/clipboard_filter @@ -382,9 +400,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 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 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. > [!TIP] > **Example regex to block common password patterns**: @@ -415,6 +433,72 @@ 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 From f5789aa43d73b876b2d5cdd7c316f61637dd7e13 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 14:24:56 +0300 Subject: [PATCH 4/9] chore: release v0.4.0 Signed-off-by: NotAShelf Change-Id: I2a67c74f308ac2e8416fe56125bce1956a6a6964 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e4435e8..49e5ff2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2937,7 +2937,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "stash-clipboard" -version = "0.3.6" +version = "0.4.0" dependencies = [ "age", "arc-swap", diff --git a/Cargo.toml b/Cargo.toml index 2c46f71..33f7463 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.3.6" +version = "0.4.0" edition = "2024" authors = [ "NotAShelf " ] license = "MPL-2.0" From fef407ec86ffcc423cf8a8e1f563471c48129da6 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 16:26:12 +0300 Subject: [PATCH 5/9] db: show database path in `stash db stats` Signed-off-by: NotAShelf Change-Id: I8e840d2bdf4f1ac6ecaf1d8a2954bf846a6a6964 --- src/db/mod.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/db/mod.rs b/src/db/mod.rs index 90b3c13..7af285b 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1141,13 +1141,15 @@ 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:\nSize: {size_mb:.2} MB \ + {undecryptable}\n\nStorage:\nPath: \ + {db_path}\nSize: {size_mb:.2} MB \ ({size_bytes} bytes)\nPages: {page_count}\nPage size: \ {page_size} bytes" )) From 3f2e34b8eac5ed4af14368e378f94714c42fc079 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 16:33:26 +0300 Subject: [PATCH 6/9] db: allow forcefully expiring entries with valid TTL Signed-off-by: NotAShelf Change-Id: Ie7ca7b88cf912e8f71fb2d04481bd9996a6a6964 --- src/db/mod.rs | 11 +++++++++++ src/main.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/db/mod.rs b/src/db/mod.rs index 7af285b..41aec9f 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1024,6 +1024,17 @@ 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 diff --git a/src/main.rs b/src/main.rs index f006d36..a711c8d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -169,6 +169,13 @@ 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, @@ -406,6 +413,28 @@ 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(()) => { From 9120e57926ddd8d2b744f89a423e3253534efb5c Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Sun, 24 May 2026 18:47:24 +0300 Subject: [PATCH 7/9] clipboard: clear stale serving PID; fix persistence restart Fixes #99 where persistence silently stops after the first entry because `SERVING_PID` was never reset in the parent after the child exited. Signed-off-by: NotAShelf Change-Id: Id41e16980c45e35be2a984e6f85b96e76a6a6964 --- src/clipboard/persist.rs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/clipboard/persist.rs b/src/clipboard/persist.rs index f5312a7..9ca9dc7 100644 --- a/src/clipboard/persist.rs +++ b/src/clipboard/persist.rs @@ -22,9 +22,25 @@ 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 { Some(pid) } else { None } + 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 + } } /// Result type for persistence operations. @@ -157,6 +173,18 @@ 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 From a50298b11898c960d8e09f8d0b6dbad27fe78c1d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 21:08:00 +0000 Subject: [PATCH 8/9] build(deps): bump crane from `6d015ea` to `edb3889` Bumps [crane](https://github.com/ipetkov/crane) from `6d015ea` to `edb3889`. - [Release notes](https://github.com/ipetkov/crane/releases) - [Commits](https://github.com/ipetkov/crane/compare/6d015ea29630b7ad2402841386da2cb617a470a7...edb38893982a3338972bb4a2ec7ce7c29ba10fd9) --- updated-dependencies: - dependency-name: crane dependency-version: edb38893982a3338972bb4a2ec7ce7c29ba10fd9 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index a2247e8..78e3571 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1778106249, - "narHash": "sha256-cM/AuKy5tMhwOOQIbha8ZRRMHVfNf7cv2aljIw+qoCg=", + "lastModified": 1779130139, + "narHash": "sha256-BLrtr42azquO7MdGFU5a7KiMl3YpFlTeIXqy1fT5GlQ=", "owner": "ipetkov", "repo": "crane", - "rev": "6d015ea29630b7ad2402841386da2cb617a470a7", + "rev": "edb38893982a3338972bb4a2ec7ce7c29ba10fd9", "type": "github" }, "original": { From 2f5cf1dd2ec964e545ad5795d69f27941d9eedfe Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 14:53:40 +0000 Subject: [PATCH 9/9] build(deps): bump crane from `edb3889` to `6823b49` Bumps [crane](https://github.com/ipetkov/crane) from `edb3889` to `6823b49`. - [Release notes](https://github.com/ipetkov/crane/releases) - [Commits](https://github.com/ipetkov/crane/compare/edb38893982a3338972bb4a2ec7ce7c29ba10fd9...6823b493a0bc2142082075576efa6633b537197e) --- updated-dependencies: - dependency-name: crane dependency-version: 6823b493a0bc2142082075576efa6633b537197e dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- flake.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/flake.lock b/flake.lock index 78e3571..7fe4959 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "crane": { "locked": { - "lastModified": 1779130139, - "narHash": "sha256-BLrtr42azquO7MdGFU5a7KiMl3YpFlTeIXqy1fT5GlQ=", + "lastModified": 1780532242, + "narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=", "owner": "ipetkov", "repo": "crane", - "rev": "edb38893982a3338972bb4a2ec7ce7c29ba10fd9", + "rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37", "type": "github" }, "original": {