Table of Contents
- Design: Optional client-side E2E encryption for pastes and files
- 1. Goals and non-goals
- 2. Crypto primitives and flow
- 3. API shapes
- 3.1 Create request
- 3.2 Create response
- 3.3 Get response
- 3.4 Raw endpoint
- 3.5 List views / mine / public
- 4. Interplay with existing features
- 5. What breaks, stated plainly
- 6. UX for key sharing
- 7. Backwards compatibility and migration
- 8. Recommendation
- 9. Open questions
- 10. Alternatives considered
- 11. References
Design: Optional client-side E2E encryption for pastes and files
- Issue: FUTURE: optional end-to-end encryption for pastes/files (#39)
- Status: Design (no implementation in this PR)
- Related docs: the wiki
APIpage
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)
- User checks "Encrypt" and enters an encryption passphrase (distinct from any access password) in
/new. - Browser generates
salt(16 B) andiv(12 B) viacrypto.getRandomValues. 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"]).- Generate a 32-byte random DEK (
crypto.getRandomValues(32)). - Content encryption key check:
iv_ckv,encrypted_content = AES-GCM-256(DEK, iv, content). - Key check value (KCV): compute
AES-GCM-DEK(random 16 bytes)— a small token encrypted under the DEK, stored askey_checkblob. 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. - Wrap the DEK with the KEK:
wrapped_dek = AES-GCM(KEK, iv_wrap, dek). - The stored envelope format:
The
{ v: 1, kdf: "PBKDF2-SHA256", iterations: 600000, salt_b64, iv_b64, kdf_salt_b64, wrap_iv_b64, wrapped_dek_b64, key_check_b64, ciphertext_b64vfield allows migrating to Argon2id later without a breaking change. - POST the envelope (base64) as
content, with anencryptionmetadata object alongside (see §3.1).
2.3 Flow (view)
- 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. - Fetch
/api/pastes/{id}(with access password in the usual field if the paste is also password-gated). - Derive KEK from passphrase+salt, unwrap DEK via key_check / unwrap step.
- 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). - 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:
POST /api/pastes
{
"content": "<base64 envelope>",
"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 the wiki API page.
3.3 Get response
GET /api/pastes/{id} response gains:
{
"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: nulland 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.
6. UX for key sharing
6.1 Three sharing modes
| 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=<b64> |
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.
6.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.3 View page (/{id}) UX
- If
encryption.kdfis 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
vfield 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
- 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).
- Fork/edit of encrypted pastes: disabled in v1, revisit.
- Should
/api/mineinclude the full envelope in list view? Leaning yes (creator's own browser can decrypt); note the larger payload. - 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.
- CSP/Referrer-Policy hardening:
Referrer-Policy: no-referrersite-wide is worth doing regardless of this feature (it also benefits unencrypted pastes). - 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.