Compare commits

...

10 commits

Author SHA1 Message Date
dependabot[bot]
2f5cf1dd2e 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](edb3889398...6823b493a0)

---
updated-dependencies:
- dependency-name: crane
  dependency-version: 6823b493a0bc2142082075576efa6633b537197e
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 17:23:05 +00:00
raf
4479cdb1bc
Merge pull request #102 from NotAShelf/dependabot/nix/crane-edb3889
build(deps): bump crane from `6d015ea` to `edb3889`
2026-05-26 03:44:13 +00:00
dependabot[bot]
a50298b118
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](6d015ea296...edb3889398)

---
updated-dependencies:
- dependency-name: crane
  dependency-version: edb38893982a3338972bb4a2ec7ce7c29ba10fd9
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-25 21:08:00 +00:00
9120e57926
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 <raf@notashelf.dev>
Change-Id: Id41e16980c45e35be2a984e6f85b96e76a6a6964
2026-05-24 20:28:13 +03:00
3f2e34b8ea
db: allow forcefully expiring entries with valid TTL
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: Ie7ca7b88cf912e8f71fb2d04481bd9996a6a6964
2026-05-24 16:42:50 +03:00
fef407ec86
db: show database path in stash db stats
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I8e840d2bdf4f1ac6ecaf1d8a2954bf846a6a6964
2026-05-24 16:42:39 +03:00
f5789aa43d
chore: release v0.4.0
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2a67c74f308ac2e8416fe56125bce1956a6a6964
2026-05-24 14:25:22 +03:00
ad70e65125
docs: document encryption and expand filtering options documentation
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I2cc3b30ef1c8f1c669babdfdce501fba6a6a6964
2026-05-24 13:33:22 +03:00
384ac708eb
db: remove unnecessary identity cache from decrypt_cached
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9228809562cc9b2f7c0a9d7ece9f5ada6a6a6964
2026-05-24 13:33:21 +03:00
656709bd19
nix/modules: fix pkgs.stdenv deprecation in NixOS module
Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I5b5224f809400a385e98c569e0ea63bf6a6a6964
2026-05-24 12:29:53 +03:00
8 changed files with 199 additions and 45 deletions

2
Cargo.lock generated
View file

@ -2937,7 +2937,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stash-clipboard"
version = "0.3.6"
version = "0.4.0"
dependencies = [
"age",
"arc-swap",

View file

@ -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 <raf@notashelf.dev>" ]
license = "MPL-2.0"

120
README.md
View file

@ -20,9 +20,10 @@
</div>
<div align="center">
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.
</div>
<div align="center">
@ -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

6
flake.lock generated
View file

@ -2,11 +2,11 @@
"nodes": {
"crane": {
"locked": {
"lastModified": 1778106249,
"narHash": "sha256-cM/AuKy5tMhwOOQIbha8ZRRMHVfNf7cv2aljIw+qoCg=",
"lastModified": 1780532242,
"narHash": "sha256-D+BsdpxmtUwtqGoY0IXPhHgTlmqgcZKCEo1oMyn7ep0=",
"owner": "ipetkov",
"repo": "crane",
"rev": "6d015ea29630b7ad2402841386da2cb617a470a7",
"rev": "59a82a1222dd3b2080b5cc52a1a2e8d5f1b77f37",
"type": "github"
},
"original": {

View file

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

View file

@ -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<i32> {
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

View file

@ -1024,6 +1024,17 @@ impl SqliteClipboardDb {
}
/// Clean up all expired entries. Returns count deleted.
pub fn expire_ttl_entries(&self) -> Result<usize, StashError> {
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<usize, StashError> {
let now = Self::now();
self
@ -1141,13 +1152,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"
))
@ -1218,6 +1231,11 @@ fn load_sensitive_regex() -> Option<Regex> {
/// 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<age::secrecy::SecretString> {
use std::process::Command;
@ -1250,25 +1268,18 @@ fn load_encryption_passphrase() -> Option<age::secrecy::SecretString> {
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<Vec<u8>, StashError> {
static CACHE: OnceLock<Mutex<Option<age::scrypt::Identity>>> =
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()))
}

View file

@ -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(()) => {