The lock is not the hard part.
The key is.
Every message in a room is encrypted in your browser, with a key derived from twelve words we generate, show you once, and never receive. What reaches our servers is ciphertext and nothing else that can be turned back into words.
This page is the whole construction — every primitive, every parameter, and what each one is actually doing. Nothing here is a secret. A cipher that depends on its design staying hidden is not a cipher.
Read left to right: the mono column is how a cryptographer would write it, the serif column is what it means. Both say the same thing.
What stays in your browser, and what we hold
The whole design comes down to which side of this line each value lives on. Everything on the left exists only in your tab's memory. Everything on the right is what we store, and what a stolen database contains.
The construction, layer by layer
This is a genuine sequence — each step consumes what the one before it produced. The parameters are the real ones, not illustrative.
-
01
Generate the room key
12 words drawn uniformly with replacement from the 2048-word BIP-39 list, indices from crypto.getRandomValues().
log₂(2048) = 11 bits/word · 12 × 11 = 132 bits
Uint16 % 2048 is unbiased: 65536 / 2048 = 32 exactly.We roll dice for you — twelve times, from a fixed list of 2,048 words. The list is public; the rolls are not. Because 2,048 is exactly 211, every word is worth precisely eleven yes-or-no answers, and twelve of them come to 132. You never choose the words. People pick memorable ones, and memorable is the same thing as guessable.
-
02
Mix in a published constant
salt = "bdr-rooms-v1" — a fixed string in the source, identical for every room, and no secret.
Salts defeat precomputation against LOW-entropy secrets. Against a uniformly random 132-bit key there is nothing to precompute: the table would need 2¹³² rows.
BIP-39 does exactly this — its salt is the literal string "mnemonic", published in the spec and shared by every wallet in existence.A fixed label, not a secret. Its job is to separate this application from every other one that might derive a key the same way, and to give us a version number to bump when the settings below change. It is deliberately not unique per room: a per-room salt would have to be fetched before we could work out which room you meant, and the twelve words alone would stop being enough to get you in.
-
03
Stretch it into a key
Argon2id — m = 64 MiB, t = 3, p = 1, 32-byte output. Runs in a Web Worker (kdf-worker.js), about 0.6s on a laptop.
Winner, Password Hashing Competition 2015. Memory-hard, so GPU and ASIC parallelism is bounded by RAM, not by arithmetic.
64 MiB rather than 256: comfortably above the OWASP baseline, and chosen to survive mobile Safari. A setting that fails to allocate has blocked the person, not the attacker.Deliberately, expensively slow. Turning your twelve words into the actual key takes about a second and needs a quarter-gigabyte of memory — once, for you. An attacker guessing has to pay that same cost on every single guess, and cannot dodge it with faster chips, because the bottleneck is memory rather than maths.
-
04
Split into two keys
HKDF-SHA-256 over the Argon2id output, two distinct info strings:
k_enc ← "enc"
k_room ← "room"One key doing two jobs is how keys leak. So the derived key is split into two unrelated ones — one to encrypt with, one to address the room. Learning either tells you nothing at all about the other.
-
05
The key is the address
room_id = HMAC-SHA-256(k_room, "room-id"), truncated to 16 bytes.
Deterministic: the same twelve words always produce the same room_id, so creating a room and opening one are a single operation the server cannot distinguish. There is no room name anywhere in the system.
room_id is also a bearer capability — you cannot post to a room you cannot address — and it doubles as the key commitment: a wrong phrase yields a room_id that does not exist.There is no room name. The twelve words are the address as well as the key: your browser turns them into a 16-byte identifier and asks us for whatever is filed under it. Typing them the first time creates the room, typing them again opens it, and from where we stand those are the same act. It also means nobody can post into your room without the words, because they cannot work out where to post — and that a mistyped phrase comes back as "no such room" rather than a room full of garbage.
-
06
Encrypt the message
XChaCha20-Poly1305 (AEAD, libsodium) — 256-bit k_enc, 192-bit random nonce, 128-bit Poly1305 tag.
A 192-bit nonce is wide enough to draw at random forever; the collision bound is not reachable.The actual lock. A stream cipher with a built-in tamper seal: the same construction that carries most modern encrypted traffic. Each message gets its own single-use random number so two identical messages never look alike, and if a single byte of the stored ciphertext is altered, decryption fails loudly rather than returning something subtly wrong.
-
07
Chain the messages together
Each message binds SHA-256(previous ciphertext) inside its own AEAD plaintext.
Tamper-evident append-only log. Any drop, reorder, insertion or replay breaks the chain at the point of the edit.Every message carries a fingerprint of the one before it, sealed inside the encryption. So the room is a chain, not a pile: we cannot quietly delete a message, reorder two of them, or slip an old one back in, because your browser recomputes the chain and finds the break.
-
08
Store almost nothing
room_id ‖ nonce ‖ ciphertext ‖ tag ‖ created_at
That is the whole schema. There is no room table: a room is simply the set of rows sharing a room_id. No name, no salt, no commitment, no key, no derivative of a key, no plaintext, no recovery path.One opaque identifier and a pile of noise. That is the entire database — there is no second table holding the interesting part, because there is no interesting part. No password reset, no recovery email, no support route back in, not as policy but because the information required to help you does not exist anywhere on our side. Lose the twelve words and the room is gone permanently.
crypto.getRandomValues() with Math.random() in step 01. The page
would look identical, the words would look random, and the whole 132 bits would collapse to
a value an attacker can reproduce after seeing a single phrase. Everything above is
downstream of that one call.So step 01 is checked rather than trusted. The wordlist length is asserted at load, the generator is tested against itself before it is used, and every new room's identifier is checked against the ones already stored. A genuine collision is a 1-in-10²³ event and will never happen; a broken generator produces one immediately. If that check ever fires, room creation halts until a person has established why — existing rooms keep working throughout.
132 bits, and what that is
2048
Words in the list. Exactly 211, which is why each word is worth exactly 11 bits and the arithmetic comes out clean.
132 bits
Entropy in a room key. Twelve words at 11 bits each — more than the 128 bits that secures essentially all of cryptocurrency.
5.4 × 1039
Possible room keys. Written out, that is 5.4 followed by 39 digits — more than the estimated number of atoms in a hundred thousand Earths.
Against every computer we can actually build
The only attack available is guessing the twelve words, and Argon2id sets the price of a guess. At 64 MiB and three passes each attempt moves roughly 0.4 GiB through memory, so the attacker's ceiling is memory bandwidth — the figures below are derived from that, and they are generous to the attacker throughout.
| Who is attacking | Guesses per second | Time to break | In perspective |
|---|---|---|---|
| One laptop (measured: 0.6s per guess) | 2 | 8.6 × 1031 yr | 1021 × the age of the universe |
| 10,000 datacentre GPUs (~$300M of hardware) | 5 × 107 | 3.4 × 1024 yr | 1014 × the age of the universe |
| 10 million GPUs (more than have ever been made) | 5 × 1010 | 3.4 × 1021 yr | 250 billion × the age of the universe |
A quantum computer does not change the answer
Not because we did something clever about it — because of what this construction leaves out. Quantum computers break cryptography through two algorithms, and only one of them is the apocalypse everyone means.
| Primitive | Quantum attack | Outcome | Used here |
|---|---|---|---|
| RSA-2048 | Shor's algorithm | Broken outright, in hours | No |
| ECDSA / secp256k1 / P-256 | Shor's algorithm | Broken outright, in hours | No |
| Diffie–Hellman key exchange | Shor's algorithm | Broken outright | No |
| XChaCha20-Poly1305 (256-bit) | Grover's algorithm | Halved to 128 bits — still unreachable | Yes |
| SHA-256 / HMAC | Grover's algorithm | Halved to 128 bits — still unreachable | Yes |
| Argon2id (64 MiB) | Grover's algorithm | Requires 64 MiB held in coherent superposition | Yes |
| TLS transport | Shor's algorithm | Broken — and yields only ciphertext | Transport only |
| Assumption | Grover iterations | Time to break |
|---|---|---|
| 1 coherent Argon2id evaluation / second | 7.4 × 1019 | 2.3 × 1012 yr (170 × the age of the universe) |
| 1,000 / second — physically unmotivated | 7.4 × 1019 | 2.3 × 109 yr (2.3 billion years) |
What this does not protect you from
Every one of these is a real limit of the design, not a to-do. A page that lists only its guarantees is marketing; the useful half is this one.
-
We serve the code that does the encryption
This is the genuine trust assumption, and it is unavoidable for anything that runs in a browser tab. Your key never reaches our servers — but the JavaScript that touches it is downloaded from us on every visit, and a modified build could send it anywhere. We hold the ciphertext and cannot read it; we could, in principle, ship code that reads it before it becomes ciphertext. Everything else on this page is strong; treat this line as the ceiling on all of it.
-
Anyone with the twelve words reads everything, forever
There is no forward secrecy. A single shared key covers the room's entire history, past and future, so a phrase that leaks in a year exposes the messages sent today. Real forward secrecy needs per-person identity keys and a ratchet, which is a different product from "type a room name and go".
-
Anyone with the key can write as anyone
Encryption proves a message was not tampered with. It does not prove who wrote it. Everyone in a room holds the same key, so within a room, authorship is a convention rather than a guarantee.
-
Metadata is visible to us
We cannot read your messages or learn your room's name, but we can see that a room exists, how many messages it holds, roughly how long each one is, when they arrived, and the IP address they arrived from. Padding and batching would blunt this; today it is in the clear.
-
There is no way back in
Not a policy — an absence. Nothing on our side can reconstruct a room key, so no support request, court order, or engineering effort can recover a room whose phrase is lost. Write the twelve words down somewhere physical before you rely on the room.
The code Claude built
These are the files this site is running, loaded from the same origin as you read them — not a copy pasted into the page. If the description on the other tab and the code on this one ever disagree, the code is the truth.
Loading…
vendor/argon2.js is hash-wasm's Argon2id and vendor/xchacha.js
is @noble/ciphers' XChaCha20-Poly1305, each bundled with esbuild from the published
package and never hand-edited. They are served from this origin rather than a CDN so the
page's own Content-Security-Policy can refuse every third-party script.