Compare commits

..

No commits in common. "main" and "notashelf/push-uuxkznrqypum" have entirely different histories.

8 changed files with 45 additions and 199 deletions

2
Cargo.lock generated
View file

@ -2937,7 +2937,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stash-clipboard"
version = "0.4.0"
version = "0.3.6"
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.4.0"
version = "0.3.6"
edition = "2024"
authors = [ "NotAShelf <raf@notashelf.dev>" ]
license = "MPL-2.0"

120
README.md
View file

@ -20,10 +20,9 @@
</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">
@ -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

6
flake.lock generated
View file

@ -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": {

View file

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

View file

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

View file

@ -1024,17 +1024,6 @@ 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
@ -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<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;
@ -1268,18 +1250,25 @@ fn load_encryption_passphrase() -> Option<age::secrecy::SecretString> {
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<Vec<u8>, 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<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)
.map_err(|e| StashError::Decryption(e.to_string().into()))
}

View file

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