mirror of
https://github.com/NotAShelf/stash.git
synced 2026-06-09 22:59:57 +00:00
Compare commits
No commits in common. "main" and "notashelf/push-uuxkznrqypum" have entirely different histories.
main
...
notashelf/
8 changed files with 45 additions and 199 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
|
@ -2937,7 +2937,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
|||
|
||||
[[package]]
|
||||
name = "stash-clipboard"
|
||||
version = "0.4.0"
|
||||
version = "0.3.6"
|
||||
dependencies = [
|
||||
"age",
|
||||
"arc-swap",
|
||||
|
|
|
|||
|
|
@ -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
120
README.md
|
|
@ -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
6
flake.lock
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()))
|
||||
}
|
||||
|
||||
|
|
|
|||
29
src/main.rs
29
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(()) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue