Security model
What we can see, what we can't, and why.
Plain language first, then the exact construction, matching packages/crypto/src/v2.ts. If this page and the code ever disagree, the code is right and this page is a bug.
The short version
What our server can see
- Ciphertext, and how big it is.
- A hash it uses to check that a reader holds the key.
- Expiry, read limits, and when a link was read.
- IP addresses and request times, through Cloudflare and the web app's host.
- For chat: when messages arrive, and how many people are in a room.
What it can't see
- Your text, files, file names, chat messages or replies to requests.
- The key. It lives after the # in the link, which browsers don't send.
- Your password, if you use one. It's stretched in your browser.
- Request labels and nicknames, which are encrypted too.
- Which device a link was opened on, beyond what an IP address says.
The catch, stated up front: this is a web app, so you trust the JavaScript we serve each time you load it. If our server were compromised and sent you modified code, that code could read what you type. The code is open source, and you can run your own copy if that trust is the problem.
Specification
Keys and passwords
Every share is sealed with a key derived from input keying material (ikm) and a fresh 16-byte random salt. Where the ikm comes from depends on the mode:
| Mode | ikm | Used by |
|---|---|---|
key | 32 random bytes from crypto.getRandomValues, carried in the link as #k= (43 base64url characters) | Notes (default), EnvShare, file drops |
password | PBKDF2-SHA256(password, salt, 600,000 iterations), 32 bytes | Notes with a separate password, Encrypt text |
key+password | key bytes ‖ PBKDF2 bytes (64 bytes) | Notes that need the link and a password |
A key-mode link is the whole secret: 256 bits of randomness, nothing to guess. Password mode is only as strong as the password. Someone with a copy of the database could test guesses against the stored hash, and anyone who knows a password-mode note's id can test guesses against the server itself: there is no rate limit on reads yet. Each guess costs them 600,000 PBKDF2 rounds either way. Prefer the link key, or the link key plus a password, and use a generated password if you use one at all.
Encryption
encKey = HKDF-SHA256(ikm, salt, info = "ps/v2/enc") → AES-256-GCM key
iv = 12 random bytes, new for every encryption
aad = "ps/v2/{kdf}" (notes and EnvShare)
blob = AES-256-GCM(encKey, iv, plaintext, aad)The mode is bound in as additional authenticated data, so a ciphertext sealed as key+password can't be relabelled as key to skip the password. GCM's tag means any changed byte fails decryption instead of producing garbage. Keys are derived as non-extractable CryptoKeys. Binary values travel as base64url without padding.
GCM doesn't pad, so the ciphertext is 16 bytes longer than your text. The server can tell a 12-character password from a 4 KB .env file.
Proving you hold the key
authToken = HKDF-SHA256(ikm, salt, info = "ps/v2/auth") 32 bytes authHash = SHA-256(authToken) stored by the server
Reading a share is two requests. First GET /api/notes/:id/meta returns the salt and mode, and no ciphertext. Your browser derives the token and sends it in an X-Auth-Token header with the real request. The server hashes it, compares in constant time, and only then returns the ciphertext. A burn-after-reading note is deleted in that same step.
The token and the encryption key come out of HKDF with different labels, so the token, or its hash, gives no help decrypting anything. What it does do: keep people who only know a share id from downloading the ciphertext or burning the note.
Links made here also get a 32-byte manage token, kept in My links in the creating browser. The server stores its SHA-256. The token itself is sent, in X-Manage-Token, only when you check a receipt (status, read count, first and last read times) or revoke the link. Receipts never contain content.
The link fragment
https://protectedshare.me/notes/x7Kp2Qa9Lm#k=Rk3n9Vx2Lq7Tz4Yb8Wm1Pc6Hd0Js5Ga2Fe9Ku3No7
Everything after # is the fragment. Browsers don't put it in the HTTP request or in the Referer header, so neither our server nor Cloudflare receives it. The page reads it with JavaScript. Reader pages then clear it from the address bar with history.replaceState, so it isn't left on screen or in a URL you copy afterwards.
The fragment can still turn up in places that are yours, not ours:
- the browser history of the device that opened the link,
- the chat or email you used to send it,
- any browser extension that can read the page.
Fragment fields by tool: #k= for notes, EnvShare, file drops and request labels; #p= for a request's private inbox key; #r=…&k=… for chat invites. The part before the fragment is the share id, which the server does see. Note and EnvShare ids are 10 random characters; links made before v2 have 5.
File drops
- Keys
- A new 32-byte key in
#k=and a 16-byte salt per drop, then the same HKDF split into an encryption key and an auth token. - Chunks
- Files are read in 1 MiB chunks. Each chunk is encrypted on its own with a fresh IV and uploaded as iv ‖ ciphertext.
- Chunk binding
- Each chunk's AAD is
ps-chunk:{index}/{total}, so chunks can't be reordered, swapped, dropped or added without failing to decrypt. - Manifest
- File names, types and sizes go in a JSON manifest encrypted with the same key. The server never sees a file name.
- Uploads
- Chunks are accepted only with an upload token whose hash the server stores. Unfinished drops are deleted after 6 hours.
- Limits
- 25 MiB of files per drop, at most 40 chunks, 1–100 downloads, expiry up to 30 days.
- Downloads
- Fetching the manifest needs the auth token, and doesn't use a download. Starting a download asks the server to take one download off the count, in a single atomic update, and to open a download session valid for 60 minutes. The download counts from that moment, and cancelling doesn't give it back.
- Chunks out
- Every chunk request needs the auth token and that session's token in
X-Download-Token. A retry or resume reuses the same session. With no downloads left, no new session can start. - Deletion
- When the count reaches zero, the drop and its chunks are deleted as soon as the last open session finishes, or by the cleanup job once every session has expired.
Secret requests
A request lets someone send you a secret without either of you sharing a password first. It uses public-key encryption, so only your browser can open the reply.
requester (pub, priv) = ECDH P-256 key pair
server gets pub (65-byte raw); priv (PKCS#8) lives only in the inbox link #p=
sender (epk, esk) = fresh ECDH P-256 key pair
shared = ECDH(esk, pub)
binding = epk ‖ pub
key = HKDF-SHA256(shared, salt16, info = "ps/v2/request" ‖ binding)
reply = AES-256-GCM(key, iv, secret, aad = binding)
requester k = 32 random bytes, only in the sender link #k=
label = AES-256-GCM(HKDF(k, "ps/v2/label"), { title?, note?, pk: SHA-256(pub) })
respondTok = HKDF-SHA256(k, zero salt, info = "ps/v2/respond") server stores SHA-256
inboxToken = HKDF-SHA256(SHA-256(priv), zero salt, info = "ps/v2/inbox")Both public keys are bound into the key derivation and the ciphertext, so a reply can't be moved to another request. Every request carries an encrypted label, under a separate key in the sender link's #k=. It holds the optional title and note (“AWS root password”) and always a SHA-256 fingerprint of the requester's public key.
Only someone holding the full sender link can answer: posting a reply needs the respond token, which is derived from that #k=. Before sending, the sender's browser decrypts the label and checks the fingerprint against the public key the server returned. If a server swapped in its own key, the check fails and the page refuses to send. One reply is accepted; collecting it with the inbox token deletes the request.
The limit that remains is the one described above: a server that serves modified JavaScript could skip the check. Self-hosting removes that trust.
Chat rooms
- Room keys
- New rooms use a 32-byte room key in the invite link
#r=…&k=…. Password rooms stretch the password with PBKDF2-SHA256 at 600,000 iterations, salted with the first 16 bytes ofSHA-256("ps/v2/room:{roomId}"), so every member derives the same keys with no server round trip. - Messages
- Encrypted with AES-256-GCM under the room's HKDF encryption key, a fresh IV per message. Nicknames, replies, reactions and images travel inside the ciphertext.
- Room auth
- The first member claims the room with the hash of an HKDF auth token. After that, every room endpoint needs the token, including the live stream.
- Safety words
- Five words derived from
HKDF(ikm, roomSalt, "ps/v2/safety"). Compare them on another channel: if they match, you derived the same key. - Deleting
- Each message can carry the hash of a random delete token held by its sender. The server deletes a message only when shown that token.
- Disappearing
- Per-message expiry from 5 seconds to 7 days, enforced by the server when reading and by the purge job.
- Burn room
- Deletes every message, the presence rows and the room record at once.
- Purge on empty
- A member stops counting as present when they leave, or 15 seconds after their last heartbeat. Stale presence rows, and the messages of a room nobody is present in, are deleted on the room's next request or by the 30-minute cleanup job, whichever comes first. Claimed rooms are forgotten after 7 idle days.
The server sees message sizes and timing, and a count of who is online and typing. Presence is a random client id, a typing flag and a last-seen time.
Notepad and offline tools
The notepad uses the original v1 scheme: AES-256-GCM with a key from PBKDF2-SHA256 at 210,000 iterations. Local mode keeps the vault in your browser. Synced mode uploads ciphertext, stores your username as a SHA-256 hash, and authorizes saves with a password proof (described under v1 below). A note can be exported as a self-decrypting HTML file that opens offline.
Encrypt text seals text in password mode (600,000 PBKDF2 iterations) into a block between -----BEGIN PROTECTEDSHARE MESSAGE----- lines, carrying salt, IV and ciphertext. It makes no network request. The password generator draws from crypto.getRandomValues with rejection sampling, so no character is more likely than another.
Older links (v1)
Links created before this version carry a password after # with no k=. They still open:
- Encryption
- AES-256-GCM, key from PBKDF2-SHA256 at 210,000 iterations with a random salt.
- Read check
- A password proof: SHA-256 of PBKDF2-SHA256(password, fixed salt
protectedshare-proof-salt-v1, 100,000 iterations), sent as?proof=.
The fixed salt makes the proof the same for the same password everywhere, and a query string is more likely to be logged than a header. That's why v2 replaced it. New links never use it. Since shares last at most 30 days, the remaining v1 notes and secrets expire on their own; the check stays only for them and for notepad workspaces.
Threat model
Protects you against
- Someone with a copy of our database or server reading your content. They get ciphertext and hashes.
- Network observers. Traffic is TLS, the content is already encrypted, and the key isn't in any request.
- Link previews burning a note. Unfurlers fetch without the fragment, so they can't produce the read token, and the note page waits for a click.
- Us reading what you send in normal operation.
Does not protect against
- A compromised device or browser, or a malicious extension on either end.
- Anyone who gets the full link. It is the key. Use a separate password if the channel worries you.
- A malicious server serving modified JavaScript. Every in-browser encryption app has to trust the code it loads. Mitigation: open source, and self-hosting.
- Metadata: Cloudflare, the web app's host and we can see IP addresses, timing, sizes, and which ids are read.
- A weak password in password mode. It can be guessed against a copy of the database, or online against a known note id, since reads aren't rate limited yet.
- The recipient keeping a copy once it's decrypted.
Retention
| Data | Deleted |
|---|---|
| Secure notes | On the burning read, or at expiry (30 days at most). |
| EnvShare secrets | On the last allowed read (1–100), or at expiry. |
| File drops and chunks | Once the last allowed download finishes or its session expires, or at expiry. Unfinished uploads after 6 hours. |
| Download sessions | 60 minutes after the download starts, or when it finishes. |
| Secret requests | When the reply is collected, or at expiry. |
| Read receipts | 30 days after the share's expiry. No content, ever. |
| Chat messages | Once the room is empty (on its next request, or within 30 minutes), at the message's own expiry, on delete, or when the room is burned. |
| Chat rooms (claimed) | After 7 idle days. Deletion markers after 10 minutes. |
| Chat presence | When the member leaves. Otherwise it stops counting 15 seconds after the last heartbeat, and the row is deleted on the room's next request or within 30 minutes. |
| Notepad workspaces | When you delete the workspace. |
| Contact form messages | Not deleted automatically. Ask and we'll remove yours. |
A job runs every 30 minutes to delete expired rows. Between runs, expired shares are already refused: every read checks the expiry.
Hosting and logs
The hosted API is a Cloudflare Worker with a D1 database. Your browser never calls it directly: every /api request goes to the web app's host, which relays it to the Worker. So two hosts see IP addresses, request metadata and the ciphertext passing through: whichever host runs the web app, and Cloudflare. Neither sees a key, which never leaves the fragment. Workers observability logging is on for the API: it records request metadata and the errors the code logs, and the code doesn't log request bodies.
Google Analytics loads only when the operator sets an analytics id at build time; a self-hosted copy doesn't load it unless you set one. Even then it never loads on pages that open or hold a share (note, EnvShare and file links, requests and their inboxes, chat, My links, the notepad), and page views report the path only, never the query string or the fragment.
The contact form stores the name, email, company and message you send, so we can reply.
Browser protections
Every page is served with a Content-Security-Policy. Scripts, styles, fonts, workers and connections are limited to this site (plus Google's analytics hosts, only when analytics is on). Images may also come from data: and blob: URLs, which is how decrypted previews are shown. object-src and base-uri are 'none', forms can only post here, and frame-ancestors 'none' with X-Frame-Options: DENY stops other sites from framing the app.
Stated honestly: the policy still allows inline scripts ('unsafe-inline'), because the framework's page bootstrap is inline and we don't use per-request nonces. So the policy doesn't stop injected inline script from running. What it does is keep script from loading off other hosts and block fetches, images and form posts to other hosts, which are the usual ways to send a key or plaintext out of the page. Pages also send X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin, HSTS, and a Permissions-Policy that turns off camera, microphone, geolocation and topics.
Report a vulnerability
Email admin@protectedshare.me with what you found and how to reproduce it. Please give us a chance to fix it before you publish. For anything that isn't exploitable, a GitHub issue is fine.