# Design: Optional client-side E2E encryption for pastes and files - **Issue:** #39 - **Status:** Design (no implementation in this PR) - **Related docs:** [docs/API.md](../API.md) ## 1. Goals and non-goals **Goals** - Let any paste (or can item) be stored server-side as ciphertext only. - Zero plaintext knowledge by the server: storage, logs, backups, DB dumps contain no readable content. - Pure browser implementation using WebCrypto; no new server dependencies. - Encrypted pastes must still work with expiry, hard/soft delete, deletion tokens, visibility, slugs, rate limits. **Non-goals (v1)** - Anonymous, account-less E2E; Palette stays server-trusting with browser cookies. - Sharing via link fragments (`#key`) is optional sugar, not a required transport. - Search *of encrypted content*, server-side language detection, or server-side highlighting on encrypted pastes — these are structurally impossible and out of scope (see §5). - Signing, deniability, forward secrecy across pastes, PFS, post-quantum crypto. **Threat model (explicit).** This protects against a *passive server compromise* — a DB dump, backup leak, or disk image of the server's SQLite file. It does **not** protect against: - A fully malicious / compromised Palette server serving backdoored JavaScript: any JS-delivered crypto can be backdoored (key exfiltration via JS) regardless of primitives. This is the fundamental limit of a JS-in-browser E2E scheme. - Malware on the viewer's device, or shoulder-surfing of the password. - Traffic analysis, timing, or metadata (title, size, expiry, IP, viewer cookie). - A attacker who compromises the server *while the creator's browser is open* and alters JS before encrypt. Be explicit in user-facing copy: "encrypted at rest; the server cannot read your paste" is accurate — "the server can never see your paste" is not. ## 2. Crypto primitives and flow ### 2.1 Recommended parameters | Parameter | Recommendation | Notes | |---|---|---| | Cipher | **AES-256-GCM** | `AES-GCM` with a 256-bit key, per-paste random 96-bit IV/nonce. WebCrypto built-in, hardware-accelerated, authenticated. | | KDF | **PBKDF2-HMAC-SHA-256** | 600,000 iterations (OWASP 2023+ recommendation), 16-byte random salt. | | Argon2id | **Not in v1** | WebCrypto has no Argon2id; a JS/WASM Argon2 implementation is an extra supply-chain dependency and is an asymmetric liability: a script the server could swap can't be load-bearing for security anyway. Add later via `argon2id` WASM with SRI pinning + CSP (`script-src 'self'`) if needed. | | Salt | 16 random bytes per paste, stored in the clear alongside ciphertext | Unique per paste, never reused. | | IV | 12 random bytes per encryption | With ~2^32 encryptions per key this is negligible; each paste has its own key anyway. | | Key check value | See §2.2 | Catches wrong passwords without a server round-trip and prevents trash writes. | ### 2.2 Flow (create) 1. User checks "Encrypt" and enters an encryption passphrase (distinct from any access password) in `/new`. 2. Browser generates `salt` (16 B) and `iv` (12 B) via `crypto.getRandomValues`. 3. `crypto.subtle.importKey("raw", passphrase, "PBKDF2", false, ["deriveKey"])` → `crypto.subtle.deriveKey(PBKDF2-SHA-256, 600k iterations, salt, {name:"AES-GCM", length:256}, false, ["encrypt","decrypt"])`. 4. Generate a 32-byte random **DEK** (`crypto.getRandomValues(32)`). 5. Content encryption key check: `iv_ckv`, `encrypted_content = AES-GCM-256(DEK, iv, content)`. 6. **Key check value (KCV):** compute `AES-GCM-DEK(random 16 bytes)` — a small token encrypted *under the DEK*, stored as `key_check` blob. This is decrypted with the derived key; on wrong password GCM auth fails and the client can show "wrong key" without asking the server to burn a read. 7. Wrap the DEK with the KEK: `wrapped_dek = AES-GCM(KEK, iv_wrap, dek)`. 8. The stored envelope format: ``` { v: 1, kdf: "PBKDF2-SHA256", iterations: 600000, salt_b64, iv_b64, kdf_salt_b64, wrap_iv_b64, wrapped_dek_b64, key_check_b64, ciphertext_b64 ``` The `v` field allows migrating to Argon2id later without a breaking change. 8. POST the envelope (base64) as `content`, with an `encryption` metadata object alongside (see §3.1). ### 2.3 Flow (view) 1. User provides the passphrase via form field, or the key arrives in the URL `#fragment`. The encryption passphrase is a separate field from any access password. 2. Fetch `/api/pastes/{id}` (with access password in the usual field if the paste is also password-gated). 3. Derive KEK from passphrase+salt, unwrap DEK via key_check / unwrap step. 4. Decrypt content with the DEK; on `OperationError` → "wrong passphrase" UI state (retries are client-side only; no re-fetch, so no extra burn-after-read charge). 5. Language detection happens client-side (e.g. highlight.js auto-detect) on the decrypted plaintext. ### §2.4 File and can items Files in cans: encrypt each file with its own DEK and store the same envelope. Cans' `json_items` content fields each carry their own envelope. Files keep their mime type in cleartext metadata; only the bytes are encrypted. The can's title stays plaintext (unless the whole can is encrypted, v2). ## 3. API shapes ### 3.1 Create request Existing fields unchanged. New optional `encryption` object: ```json POST /api/pastes { "content": "", "encryption": {"v": 1, "kdf": "PBKDF2-SHA256", "iterations": 600000, "salt": "b64", "iv": "b64", "key_check": "b64"} } ``` `encryption` is non-secret KDF metadata for UI display; the server treats `content` as opaque bytes and MUST NOT inspect it for encrypted pastes (no detection, no highlighting prep, no search indexing) — enforced where content is written, not per-handler. The full envelope can also just live inside `content` (server-opaque); the `encryption` object carries only non-secret KDF metadata the list views need (e.g. to show a 🔒 icon). ### 3.2 Create response Unchanged shape: `id`, `url`, `raw_url`, `api_url`, and the one-time `deletion_token` documented in docs/API.md. ### 3.3 Get response `GET /api/pastes/{id}` response gains: ```json { "id": "abc123", "content": "BASE64_ENVELOPE", "encryption": {"v":1, "kdf": "PBKDF2-SHA256", "iterations": 601570, "salt": "b64", "iv": "base64", "key_check": "b64"}, "reads_remaining": null } ``` `language` is `"encrypted"` or `null` so clients don't run detection on ciphertext. `raw_url` also serves the envelope; the `/{id}` page ships it to the browser, which decrypts in place. ### 3.4 Raw endpoint `GET /raw/{id}` returns the envelope as `application/octet-stream` with a suggested filename like `{id}.e2e.txt` and `Content-Disposition: attachment`. This is deliberate: a "download encrypted blob" is what a non-browser client can do with it anyway. ### 3.5 List views / mine / public List endpoints return `has_encryption: true` instead of content; show a lock icon. Do not include ciphertext in list responses (size, and no reason to ship ciphertext to every viewer's list view) — `GET /api/pastes/{id}` remains the only endpoint that returns the envelope. `/api/mine` (creator's own browser) may include the envelope for convenience; `/api/public` returns metadata only. `/api/guess-language` rejects encrypted content with `400 "content is client-encrypted"` — detection needs plaintext; clients detect after decrypting. Delete, redeem, rate limits, expiry, sweeper, deletion tokens, visibility, slugs, and can CRUD are unchanged — the server never inspects content for these, so opaque content is a no-op path. ## 4. Interplay with existing features | Feature | Impact | Mitigation | | Burn-after-read | Budget is charged on fetch, exactly as today; the server cannot know whether decryption succeeded, so a viewer fetching with the wrong key burns a read they can't use. | Decrypt retries are client-side, so only the first fetch charges the budget. Clear UX copy. | | Password-protected + encrypted | Both can coexist and are independent: the access password is an HTTP 401 gate; the encryption passphrase never leaves the browser. If both are set, all three secrets are needed (URL + access password + passphrase). Warn if the user enters the same value in both fields. | | Encryption-only pastes | Supported with no access password: URL + passphrase (or fragment key). Default is passphrase; fragment key is opt-in with a warning. | | Search | Structurally impossible over ciphertext. Server search just skips encrypted pastes; client-side search within a single decrypted paste works fine. No global encrypted-content search — accept the loss, document it. | | Language detection / highlighting | Server-side detection/highlighting impossible; returns `language: null`. Client-side detection via highlight.js auto-detect on decrypted plaintext (client already loads it for password gate pages). | | Cans/files | Per-item envelopes (own DEK each), per §2.4. | Consider a can-level KEK (one passphrase unlocks all items). | | Expiry/sweeper/delete/redeem | Unchanged — server never inspects content for these. | | List views (`/api/public`, `/api/mine`) | Additive `has_encryption: true` flag; list responses do not include ciphertext (`/api/mine` may include the envelope for the creator's own convenience). | | guess-language endpoint | Reject with 400. | | Fork / edit | Re-encryption needs the passphrase in the browser; v1 disables forking encrypted pastes. | Document the limitation. | ## 5. What breaks, stated plainly - **Search across encrypted pastes: impossible.** Accept the loss. (If ever needed, client-side index in IndexedDB for the creator's own pastes — v2+.) IndexedDB only helps the creator, not other viewers; still not global search. Accept the loss. - **Server-side language detection and highlighting: impossible.** Client-side detection on decrypted plaintext. Server returns `language: null` and the client detects. - **Burn-after-read is weakened in one specific way:** the budget is counted on fetch, not on successful decryption. A viewer who fetches but can't decrypt (wrong/lost key) burns a read they can't use. Mitigations documented in §4 table. The server can still count fetches (which is what burn-after-read actually is, even today: it counts fetches, not "reads" in any content-aware sense). So burn-after-read still works — it counts fetches — it's just that a failed decryption still consumes budget. This is acceptable and just needs UX copy. Optionally: don't decrement on failed decryption is *not possible* the server can't tell, so it's fetch-based, period. (It already is today.) - **Raw endpoint semantics change:** `/raw/{id}` can no longer serve readable raw text. It serves the ciphertext envelope. Scripts that curl raw pastes will get base64 envelope instead of text. Document as a breaking-ish change for encrypted pastes only; unencrypted pastes unchanged. - **Existing /api/mine, /api/public list shapes gain a flag** (additive, non-breaking). - **Copy-to-clipboard of decrypted text stays client-side**, fine. "Copy raw" on an encrypted paste copies the envelope — label it clearly. ## 5. UX for key sharing ### 5.1 Three sharing modes | Mode | What's shared | Security level | Use case | |---|---| malformed JSON / wrong key | | | | Mode | What's shared | Strength | Use case | |---|---|---|---| | **Passphrase** (default) | URL + passphrase out-of-band (Signal etc.) | Good — two channels | Team snippets, sensitive configs | | **Passphrase + access password** | URL + access password (401 gate) + passphrase | Strong — two secrets, two channels | Highest sensitivity | | **Random key in `#fragment`** | URL containing `#key=` | Weak — single channel; anyone with the full URL has both parts. Copy/paste into chat defeats it entirely. | One-click convenience sharing | Browsers never transmit `#` fragments to servers; still set `Referrer-Policy: no-referrer` site-wide and offer separate copy buttons for URL and key. Key-in-fragment ships with a warning and stays opt-in. ### 5.2 Create page (`/new`) UX - "Encrypt content" toggle → reveals passphrase field + strength meter + generate-random-key button. - When encrypting, hide the server-side language dropdown; the client detects language after decryption. - Two separate inputs with distinct labels: "Access password (checked by the server, 401 gate)" and "Encryption passphrase (never leaves your browser)". If both hold the same value, warn. ### 6.2 View page (`/{id}`) UX - If `encryption.kdf` is present → show key entry UI (after the access-password 401 gate, if that also applies). - After decrypt: normal render pipeline, language detected client-side. - "Wrong passphrase" retries never re-fetch, so they never burn extra reads. ## 7. Backwards compatibility and migration - Additive JSON fields only; unencrypted pastes behave identically. No schema changes (envelope is stored in the existing content column/TEXT; verify column size allows envelope overhead (~2× base64 + ~200 B header). - Server-side validation of encrypted pastes: only structural checks (base64 decodes, size ≤ max bytes). No crypto in the server. **Server implementation cost is genuinely small** (est. 2-4 days): pass through content untouched, add `encryption` metadata column or embed in content, skip detection/indexing when `encryption` is present, list flag. The server never does crypto. All crypto is client-side JS (~150-300 lines, no build-step change if using WebCrypto alone). **Argon2id later:** add `kdf: "argon2id"` to the envelope `v: 1` (m=64 MiB, t=3, p=1) via a SRI-pinned WASM module, with CSP `script-src 'self'` + SRI on the script tag. Envelope `v` field already allows this. ## 8. Recommendation **Build it, as an opt-in checkbox, passphrase mode only in v1.** - Server cost is small (pass-through + skip detection/indexing + list flag), client cost moderate (WebCrypto only, no new deps). - It closes the biggest real-world risk for a public pastebin: a DB/backup leak exposing every paste ever written. - Skip Argon2id in v1; envelope `v` field provides a migration path. - Key-in-fragment mode: build the plumbing (fragment parsing) but hide behind "advanced"; default remains passphrase. **Do not build:** server-side search over encrypted content, server-side highlighting of encrypted content, decrypt-on-server "preview" mode, or any server-side crypto. ## 9. Open questions 1. Size limits: base64 expansion (~4/3×) plus ~200 B envelope overhead; the existing max-bytes / 413 limit applies to the envelope bytes the server stores. Do not compress before encrypting (CRIME-style weaknesses). 2. Fork/edit of encrypted pastes: disabled in v1, revisit. 3. Should `/api/mine` include the full envelope in list view? Leaning yes (creator's own browser can decrypt); note the larger payload. 4. Should there be a "verify passphrase" second field at create time (type-twice), or rely on the KCV check at view time? KCV at view time suffices; type-twice adds friction at create. Rely on KCV, skip type-twice. 5. CSP/Referrer-Policy hardening: `Referrer-Policy: no-referrer` site-wide is worth doing regardless of this feature (it also benefits unencrypted pastes). 6. Cans: per-item DEKs wrapped by a single can-level KEK (one passphrase unlocks all items) — better UX, slightly more envelope design work. Defer detail to implementation. ## 10. Alternatives considered | Alternative | Why not in v1 | |---|---| | Argon2id via WASM in v1 | Extra JS dependency the server could swap → can't be load-bearing; PBKDF2-600k is adequate for a pastebin. Defer. | | Server holds half a key (2-of-2 with server-held share) | Re-introduces server trust; defeats the purpose. | | age-format envelopes | Nice CLI interop but no WebCrypto-native support; adds a JS dependency. Defer. | | PGP / S-MIME | Poor browser UX; heavy dependencies. | | Server-side encryption with server-held keys | Not E2E; that's "encrypted at rest", already covered by disk-level encryption. | | PrivateBin-style fragment key only | Single-channel sharing is a footgun; keep passphrase as default. | | libsodium / tweetnacl | Solid but unnecessary; WebCrypto covers AES-GCM + PBKDF2 natively. | ## 11. References - OWASP Password Storage Cheat Sheet (PBKDF2 guidance): https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - MDN WebCrypto: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto - PrivateBin (prior art for fragment-key sharing): https://privatebin.info - 0bin, Hemmelig — other pastebin/secret E2E prior art.