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:

  1. Local disk theft — a stolen laptop or extracted disk image reveals the launcher's SQLite database in plaintext.
  2. Server breach — a compromise of asyar.org exposes every user's synced clipboard.
  3. Malicious extension — a Tier 2 extension reads clipboard history to exfiltrate secrets.
  4. 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 fresh version from the per-user counter; tombstones carry deleted: true with payload: null.
  • GET /api/sync/items?since={cursor}&limit=500 — pull only items with version > cursor, ordered ascending. Pagination via hasMore. 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.

Cross-references