Security whitepaper · v1.0 · October 2026
Secret Lee: zero-knowledge, one-time secret sharing
This document describes what Secret Lee protects, how, and where its guarantees end. It is written to be checked: every mechanism named here lives in a small number of source files listed in §11. The friendly version is at How it works.
1. Summary
Secrets are encrypted in the sender's browser with a fresh 256-bit key before anything is uploaded. The server stores only ciphertext. The decryption key travels to the recipient inside the share link's URL fragment (after #), which browsers do not transmit in HTTP requests, so it never reaches the server, its logs, or its database. Reading a secret is an explicit, atomic operation that decrements its view count and deletes it when the count reaches zero.
Consequently, a complete compromise of the database, cache, file storage, or the operators themselves does not reveal secret contents. What it can reveal is metadata, enumerated in §5.
2. Threat model
Defended against
| Adversary | Outcome |
|---|---|
| Attacker with a full dump of Redis, Postgres and blob storage | Obtains ciphertext and metadata only. Without the per-secret link key, AES-256-GCM ciphertext is not decryptable. |
| Curious or compelled operator / organisation admin | Same as above. There is no server-side code path that holds a key, so there is nothing to disclose or subpoena beyond metadata. |
| Network eavesdropper | TLS protects transport; even if TLS were stripped, request bodies contain ciphertext and no keys. |
| Link-preview bots and crawlers (Slack, Teams, Outlook) | Opening the link performs a GET of an interstitial page that does not consume a view. Reveal requires an explicit POST triggered by the user. |
| Two readers racing for the last view | The view decrement and deletion are a single atomic Redis script; exactly one reader succeeds. |
| Tampering with stored ciphertext | AES-GCM authentication fails on any modified byte; the recipient sees an error, never altered plaintext. |
| Interception of one delivery channel | Mitigated by split delivery (link and key sent separately) and/or a passphrase that never appears in any link. |
Out of scope
- Malware or a malicious browser extension on the sender's or recipient's device.
- A recipient who copies or photographs the secret after reading it.
- Anyone who obtains the complete link (and passphrase, if set) before the intended recipient.
- A compromised deployment serving modified JavaScript. This is the inherent limit of browser-delivered cryptography; mitigations and roadmap are in §8 and §10.
3. Cryptographic design
All cryptography uses the browser's built-in Web Crypto API (crypto.subtle); there is no third-party crypto library. The implementation is src/lib/crypto.ts.
3.1 Sealing a secret
- Generate
linkKey= 32 bytes,iv= 12 bytes,salt= 16 bytes fromcrypto.getRandomValues. - Derive the content key:
K = HKDF-SHA256(ikm = linkKey [‖ ppBits], salt, info = "secret-lee/v1") → AES-256-GCM. - Encrypt:
ct = AES-256-GCM(K, iv, plaintext)with a 128-bit authentication tag. - Upload the envelope
{ v: 1, iv, ct, salt, pp }(base64url).linkKeyis base64url-encoded (43 characters) and placed in the link fragment.
3.2 Optional passphrase
When a passphrase is set, ppBits = PBKDF2-SHA256(passphrase, salt, 600,000 iterations, 256 bits) is appended to the HKDF input. Decryption then requires both the link key and the passphrase. Note that the passphrase only adds work for an attacker who already holds the link key; without the link key, the 256-bit random key alone makes brute force infeasible.
3.3 Files
The file is wrapped as "SLF1" ‖ u16 nameLength ‖ UTF-8 name ‖ bytes and the whole container is encrypted exactly as in 3.1. The file name therefore exists only inside the ciphertext; the API rejects any attempt to submit a plaintext name. Ciphertext is stored in blob storage under a random name; the envelope (IV, salt) is stored with the secret record.
3.4 Secret requests (inverse flow)
A requester's browser generates an ECDH P-256 key pair. The public key is stored with the request; the private key is kept in that browser's localStorage and never transmitted. The person fulfilling the request encrypts their secret as in 3.1, then wraps linkKey to the requester: an ephemeral P-256 key pair is generated, the shared secret is passed through HKDF-SHA256(info = "secret-lee/v1/request-wrap") to an AES-256-GCM key, and linkKey is encrypted under it. The server stores ciphertext plus the wrapped key; only the requester's browser can unwrap it.
4. Key transport
- URL fragment. Per RFC 3986 §3.5 the fragment is resolved by the user agent and is not part of the request target sent to the server. Server and hosting-provider request logs record
/s/<id>only. - No referrer leakage. All pages send
Referrer-Policy: no-referrer. - Address-bar scrubbing. After a successful reveal the page calls
history.replaceStateto remove the fragment from the address bar and the current history entry. - Split delivery. Senders may copy the link and the key separately; the recipient pastes the key into the reveal page. The key's format is validated locally before any view is consumed, so a mistyped key does not burn the secret.
- Decrypt-failure retries are local. If decryption fails after a reveal (wrong passphrase or key), the ciphertext is retained in the tab's memory so the user can retry without consuming another view.
5. Data inventory
Everything the system stores, and nothing it doesn't:
| Store | Contents | Lifetime |
|---|---|---|
Redis (Upstash) lee:secret:<id> | Envelope (ciphertext, IV, salt, passphrase flag), kind, views allowed/remaining, expiry, org and creator IDs, blob reference, ciphertext size, wrapped key (requests). | Native TTL = chosen expiry; deleted atomically on last view or burn. |
| Blob storage | Encrypted file bytes under a random name. | Deleted on last view or burn (see §10 for unread expiries). |
Postgres secret_meta | Secret ID, org, creator, label (plaintext, member-supplied, optional), kind, size, views used, timestamps, burn reason. | Retained for the dashboard and audit. |
Postgres audit_event | Action (created / viewed / burned / …), actor, secret ID, small detail object. | Retained. |
Postgres secret_request | Request label and note (plaintext), requester public key, status. | Retained. |
Postgres api_token | SHA-256 hash of each token; plaintext shown once at creation. | Until revoked. |
Postgres device_auth | SHA-256 hash of the app's device code, the short user code, app name, approval state. | Usable for 10 minutes, once. |
Postgres slack_install, slack_identity | Workspace ID and name, encrypted bot token; which Slack user is linked to which account, and their read-receipt setting. | Until uninstalled or unlinked. |
| Rate-limit counters | Counts keyed by a salted, daily-rotated SHA-256 of the client IP (or user/token ID). | Expire with their window (≤ 1 day). |
| Hosting provider logs | Request paths without fragments, client IPs, timings. | Per provider policy. |
Never stored or transmitted: plaintext secrets, link keys, passphrases, file names, request private keys.
6. One-time semantics
- Reveal runs a Lua script on Redis: read the record, decrement
viewsRemaining, delete at zero or rewrite with the remaining TTL, return the pre-decrement record. Being a single script, it is atomic across concurrent requests. - For files, the server reads the ciphertext and returns it in the same reveal response, then deletes the blob on the final view, avoiding a race between deletion and download.
- Secrets that are never read expire through Redis TTL.
- Creators and organisation admins can burn a secret early; burning never reveals contents.
7. Accounts, tenancy & admins
- Sign-in uses Auth.js with email magic links and optional Google OAuth; sessions are signed JWTs.
- Tenants are organisations with owner / admin / member roles and email invitations. Organisation admins can see metadata for their organisation's secrets, manage members, tokens and branding, and burn secrets. No role can decrypt anything.
- API tokens are organisation-scoped, stored as SHA-256 hashes, and revocable.
- Recipients never need an account.
7a. Apps: CLI, Raycast and Slack
- Same client crypto. The
leeCLI and the Raycast extension run the code inpackages/core, the same AES-256-GCM envelope as the website. They encrypt and decrypt on the user's machine and send the server only ciphertext. - Device sign-in. An app asks for a device code; the server stores only its SHA-256 hash and shows the user an 8-letter code that expires after 10 minutes. The user approves it in a signed-in browser, and the app then receives a personal API token. Each app gets its own token, so revoking one under Dashboard → Apps leaves the others working.
- Slack never carries a secret. Slack retains everything typed into it, including slash commands, so
/leeonly opens the website, creates request links, sends read receipts and renders link previews. Previews show metadata only and never spend a view. A command that looks like it contains a secret is discarded and the sender is told to rotate it. - Slack bot tokens are stored encrypted with AES-256-GCM under a key derived from
SLACK_TOKEN_KEY(orAUTH_SECRET). Every Slack request is checked against Slack's signing secret and rejected if older than five minutes.
8. Browser hardening
- Content-Security-Policy with
connect-src 'self': the browser refusesfetch, XHR, WebSocket andsendBeaconto any other origin.img-srcis limited to self,data:andblob:;font-srcto self;object-src 'none';frame-ancestors 'none';base-uri 'self'. - No third-party requests. Fonts are self-hosted at build time; there is no analytics, tag manager or CDN script on any page.
Cross-Origin-Opener-Policy: same-origin,X-Content-Type-Options: nosniff, andCache-Control: no-storeon secret and request pages.- State-changing JSON endpoints require
Content-Type: application/json, which forces a CORS preflight for cross-origin callers; none is granted.
9. Abuse resistance
Because contents are unreadable, moderation by inspection is impossible by design. Abuse is constrained by shape and rate instead:
| Quick mode (no account) | Signed in | |
|---|---|---|
| Plaintext size | 6 KB | 96 KB; files up to 4 MB |
| Maximum lifetime | 1 hour | 14 days |
| Views | Exactly 1 | Up to 10 |
| Files, requests, labels, dashboard | No | Yes |
| Create rate | 10/hour and 30/day per client | 300/hour per user |
Additional limits: reveals 60/minute per client, request fulfilments 10/hour per client, uploads 60/hour per user, API calls 300/hour per token. Every limit is enforced server-side; the interface merely mirrors it. Secret IDs carry ~130 bits of randomness, so enumeration is not a practical attack.
10. Residual risks & roadmap
Trust in served code
Any web application that encrypts in the browser trusts the JavaScript it is served. A malicious or compromised deployment could ship code that exfiltrates keys. Current mitigations: open source, a deliberately small crypto module, and a CSP that blocks cross-origin network calls (which raises the bar but does not cover top-level navigation). Planned: nonce-based script-src without 'unsafe-inline'; published build hashes and reproducible builds; an optional verifier extension; and the CLI as an independent client that never loads served code.
Other known limits
- Metadata. Labels, request notes, sizes (ciphertext length approximates plaintext length), timestamps, view counts and organisation membership are visible to operators and admins.
- Browser history. The full link may be recorded by the browser on navigation, before the page can scrub it. Once a one-view secret is read, that link opens nothing.
- Request private keys live in one browser's
localStorage: clearing site data loses them, and a malicious extension could read them. - Expired, unread files. Redis expiry removes the record, but the ciphertext blob is currently removed only on read or burn. Orphaned blobs are undecryptable without the key; a scheduled sweeper is planned.
- Post-quantum. AES-256 is considered robust; the P-256 key agreement used by secret requests is not post-quantum. Requests are short-lived.
11. Verify it yourself
- Open DevTools → Network, seal a secret, and inspect the
POST /api/secretsbody: ciphertext and parameters only. The share screen shows the same bytes and checks them for your secret, key and passphrase. - Open a share link and inspect the requests: the path contains the ID, never the key.
- Use the offline crypto lab: seal, search the ciphertext, try a wrong key, tamper with a byte.
- Check the headers:
curl -sI https://secretl.ee/ | grep -i content-security. - Read the code:
src/lib/crypto.ts(all cryptography),src/lib/store/upstash.ts(atomic reveal),src/app/api/secrets(what the server accepts),src/lib/policy.ts(limits),next.config.ts(CSP and headers).
Appendix: parameters
| Content cipher | AES-256-GCM, 96-bit random IV, 128-bit tag |
| Link key | 256 bits, crypto.getRandomValues, base64url (43 chars) |
| Key derivation | HKDF-SHA256, 128-bit random salt, info secret-lee/v1 |
| Passphrase KDF | PBKDF2-SHA256, 600,000 iterations, 256-bit output |
| Request key agreement | ECDH P-256 (ephemeral sender key) → HKDF-SHA256, info secret-lee/v1/request-wrap → AES-256-GCM |
| Secret IDs | 22 characters, base62, server CSPRNG (~130 bits) |
| Envelope | { v: 1, iv, ct, salt, pp }, base64url fields |
| File container | "SLF1" ‖ u16 nameLen ‖ name ‖ bytes, encrypted as a whole |
