Clipboard Privacy — Defense in Depth
Asyar treats clipboard data as potentially sensitive and applies layered
protections. This document is the design reference for the layered
approach; see reference/permissions.md
for the user-facing behaviour summary.
Threat model
Clipboard history routinely contains: passwords pasted from password managers, 2FA codes, recovery phrases, API tokens, SSH private keys, credit card numbers, personal data. Asyar must not be the weak link between a user's password manager and a server breach.
The principal threats:
- Local disk theft — a stolen laptop or extracted disk image reveals the launcher's SQLite database in plaintext.
- Server breach — a compromise of
asyar.orgexposes every user's synced clipboard. - Malicious extension — a Tier 2 extension reads clipboard history to exfiltrate secrets.
- Inadvertent capture — a user copies a password and the launcher stores it without ever signaling intent to do so.
Each layer below addresses one or more of these threats.
Layer 1 — Capture-time exclusion (shipped 2026-05-01)
Items that the OS or source application has marked private never enter
SQLite. Honors macOS NSPasteboard flags
(org.nspasteboard.ConcealedType, TransientType, AutoGeneratedType,
plus Apple's auto-generated promised type), Windows clipboard-history
opt-out (CanIncludeInClipboardHistory,
ExcludeClipboardContentFromMonitorProcessing), and a configurable
source-app denylist (default: eight common password managers + Apple
Keychain Access).
Code: src-tauri/src/clipboard_privacy/.
Settings UI: Settings → Privacy → Clipboard Privacy.
Addresses threats 1 and 2 partially (secrets that opt out at the source never appear anywhere) and threat 4 entirely.
Layer 2 — Pattern-based redaction (shipped 2026-05-02)
Detects high-confidence secret formats at storage time and replaces
each match in place with [redacted: <kind>]. Items still appear in
history but the secret value never reaches disk, sync, or extension
reads.
Coverage spans clipboard items (text / HTML / RTF), snippet expansions on save, and AI conversation messages on append. AI conversations are redacted before the message is sent to the provider — the provider never sees the raw secret either.
Bundled detectors (false-positive rate near zero on plain-English text):
| Kind | Source |
|---|---|
aws_access_key |
AKIA / ASIA prefixes |
github_pat / github_oauth / github_user_to_server / github_server_to_server / github_refresh |
GitHub token prefixes |
gitlab_pat |
glpat- prefix |
stripe_live_secret / stripe_restricted |
Stripe live + restricted prefixes |
slack_token |
xox[baprs]- prefix |
openai_key |
sk- prefix with length floor |
anthropic_key |
sk-ant- prefix |
pem_private_key |
PEM -----BEGIN ... PRIVATE KEY----- block |
jwt |
three base64url segments separated by . |
credit_card |
13–19 digit candidate, Luhn-validated |
The user can disable redaction globally or per-category in Settings → Privacy → Secret Redaction.
What this does NOT catch:
- Secrets in formats not in the bundled catalog (custom internal token formats, hashed passwords, raw private keys without PEM headers).
- Generic high-entropy strings (false-positive rate too high to ship by default).
- Secrets pasted as images.
Code: src-tauri/src/secret_detection/,
src/services/privacy/secretRedactionService.svelte.ts.
Settings UI: Settings → Privacy → Secret Redaction.
Addresses threats 1 and 2 for the most common secret formats that Layer 1 misses (e.g. a token pasted into a chat field where the source app didn't set a transient flag).
Layer 3 — Local encryption at rest (shipped 2026-05-03)
Clipboard content / preview, snippet expansion, AI conversation
history bodies, and encrypted extension preferences are now stored as
AES-256-GCM ciphertext keyed by a 32-byte master key in the OS
keychain — Keychain Services on macOS, Credential Manager on Windows,
freedesktop Secret Service on Linux.
The hardcoded prefs_key_material() defense-in-depth scheme is gone:
honest about not being a real secret, replaced by something that is.
Linux fallback: when Secret Service is unavailable (headless,
minimal WM, DBus-less container), Asyar falls back to a 0600
file-backed key under appData/keystore-v1.dat and surfaces a
warning-severity diagnostic so the user knows protection is reduced
to the file-permissions level. macOS / Windows treat keychain
unavailability as fatal — the keychain is part of the OS install,
failure is exceptional, and refusing to start is safer than silent
degradation.
Beta-phase clean break: pre-Layer-3 plaintext rows and legacy
enc:aes256gcm: extension-preference rows are not migrated forward.
Reads of those values surface as missing — the existing clipboard
cleanup() evicts the orphans naturally, and the extension preference
UI re-prompts the user to re-enter the value, which is then stored
under the new scheme. No crypto/migration.rs, no meta schema
table, no carry-forward of legacy constants.
Addresses threat 1 directly: an offline disk image alone cannot decrypt the data without the (locked) OS keychain.
Code: src-tauri/src/crypto/,
src/services/privacy/encryptionService.svelte.ts.
Settings UI: Settings → Privacy → Encryption at Rest.
Layer 4a — Per-item delta cloud sync (shipped 2026-05-04)
The first slice of Layer 4 ships independent of encryption: replace
the monolithic cloud_snapshots upload (14 MB / 2 h regardless of
changes) with per-item rows on the server, each gated by a SHA-256
content hash and a monotonic per-user version. Unchanged items skip
the upload entirely. Same data on the wire (plaintext JSON), but
typical periodic syncs now move 0 bytes when nothing has changed,
and a single edit pushes only the one item that actually changed.
Bandwidth math:
- Status quo (pre-4a): ~168 MB/user/day.
- Post-4a, idle user: ~0 bytes/day.
- Post-4a, active user editing one snippet: only that one item (typically a few hundred bytes) — not the whole category blob.
Server-side: drops cloud_snapshots LONGTEXT and the per-category
cloud_sync_categories shape entirely. Adds:
cloud_sync_items(user_id, id, category_id, payload, content_hash, version, deleted, deleted_at, …)with a composite primary key on(user_id, id)so each syncable item — clipboard entry, snippet, shortcut, settings singleton — gets exactly one row.cloud_sync_user_state(user_id, next_version)— per-user monotonic version counter, transactionally locked on each push so versions stay strictly increasing across concurrent device pushes.
New endpoints:
POST /api/sync/items— push a batch of changed items (max 500 per batch, 5 MB total). Each item is upserted by(user_id, id)and assigned a freshversionfrom the per-user counter; tombstones carrydeleted: truewithpayload: null.GET /api/sync/items?since={cursor}&limit=500— pull only items withversion > cursor, ordered ascending. Pagination viahasMore. Cursor advances per page.
Client-side: cloud_sync_items_journal SQLite table holds one row
per item with the last-uploaded hash, server-assigned version, dirty
flag, and tombstone flag. The sync orchestrator hashes each provider
export, looks up the matching journal entry, and pushes only items
whose content hash differs from last_uploaded_hash. Cursor lives in
cloud_sync_cursor. Per-item upload cap: 256 KB; per-batch cap: 5 MB.
Per-item last-writer-wins replaces the old whole-snapshot LWW.
Concurrent edits to different items on different devices both
survive — only edits to the same item conflict, and the conflict
is surfaced as an sync.item-overwritten diagnostic so the user
knows their local edit was overridden by a newer version.
Tombstones (deleted items) are pushed with deleted: true and stay
in cloud_sync_items for 30 days so other devices have a chance to
pick up the deletion; a daily asyar:prune-cloud-sync-tombstones
console command (scheduled 03:30 UTC) hard-deletes them after that.
Code: src-tauri/src/sync/,
src-tauri/src/storage/cloud_sync_state.rs,
src/services/sync/cloudSyncService.svelte.ts.
Spec: docs/superpowers/specs/2026-05-03-delta-sync-cloud-sync.md.
Layer 4b/4c — Optional end-to-end encrypted sync
Passphrase-based E2EE on top of the per-item shape from 4a. Default OFF — most users don't want a passphrase prompt; users who care explicitly enable it in Settings → Account → Encrypted Sync.
Once enabled: user passphrase → Argon2id → 32-byte sync key → AES-256-GCM.
Multi-device coordination uses content_hash computed locally over plaintext —
different devices with the same passphrase produce the same hash
without ever sharing the passphrase.
UX (key call): passphrase entered once at enrolment, derived sync key cached in the OS keychain (same trust anchor as Layer 3's data-at-rest key). Daily UX is zero-friction — no per-launch prompt. The passphrase is re-entered only at: enrolment, second-device login, recovery after passphrase loss, or explicit user "lock sync" action.
Recovery is honest: passphrase loss = data loss. Users get a one-time 24-word BIP-39 recovery phrase at enrolment and are encouraged to write it down. Asyar.org cannot reset a passphrase.
Addresses threat 2 directly. A server breach reveals only ciphertext.
Code paths: src-tauri/src/sync/e2ee/,
src-tauri/src/crypto/ (kdf.rs, mnemonic.rs, sync_envelope.rs),
src/services/sync/syncEncryptionService.svelte.ts,
src/components/settings/EncryptionEnrolmentDialog.svelte,
src/components/settings/PassphraseDialog.svelte,
src/components/settings/RotatePassphraseDialog.svelte,
src/components/settings/RecoverWithMnemonicDialog.svelte,
src/components/settings/RecoveryPhraseDialog.svelte,
src/components/settings/DisableE2eeDialog.svelte.
Settings UI: Settings → Account → Encrypted Sync.
Layer 5 — Retention + per-item opt-out (planned)
- Per-clipboard-item right-click "Don't sync" toggle.
- AI conversations capped at last N (configurable).
- Snippet "private" tag — never syncs even if cloud sync is on.
- Optional "scrub long pastes" mode — drops conversation messages over X lines that look like code blocks.
Addresses user agency rather than a specific threat: even with all prior layers, the user remains the last line of defense.
What this is NOT
Asyar does not promise to detect every secret. Layer 1 catches items that opt out at the source; Layer 2 catches the most common known formats. A user manually typing a password into a chat field, or copying from an app that uses a non-standard clipboard mechanism, is still captured. The privacy architecture reduces the surface, it does not eliminate it.