This is the deployed build, not a plan. Every primitive below is what actually runs: Argon2id in a Web Worker, XChaCha20-Poly1305, and a database holding nothing but opaque ids and sealed bytes. What it has NOT had is an independent security review, and the limits at the bottom of this page are real. Read those before trusting it with anything you cannot afford to lose.

Rooms · how this is encrypted

← Back to Rooms
The construction, in full

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.

Where the line is

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 trust boundary between your browser and our server Your browser holds the twelve-word room key, the Argon2id-derived key, the two subkeys, and the plaintext messages. Only a derived room identifier, a nonce and ciphertext cross to our server. The room key and the plaintext never cross. Your browser In memory only. Cleared when the tab closes. 12-word room key argon2id(key, const) → 32 bytes k_enc · k_room plaintext messages the wordlist + the code None of these is ever transmitted. TRUST BOUNDARY ciphertext only Our server Durable. This is what a breach exposes. room_id = HMAC(k_room) nonce ‖ ciphertext ‖ tag Two columns. Opaque without the twelve words.
Nothing crosses right to left either. We cannot send your browser a key, because we do not have one. The only thing we can send is the ciphertext back, and the code that decrypts it — which is the one genuine trust assumption in this design, and it has its own section at the bottom.
Eight steps, in order

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.

As a cryptographer writes it What that actually means
  1. 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.

  2. 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.

  3. 03

    Stretch it into a key

    Argon2idm = 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

  8. 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.

The one line that would silently destroy all of it: replacing 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.
The size of the problem

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.

Why more words beats a bigger dictionary. Nearly quadrupling the wordlist — 2,048 up to 7,776 — buys 1.93 extra bits per word. Adding a single word buys 11. Word count is an exponent and list size is only a base, which is why every serious scheme fixes a modest, well-chosen list and adds words instead.
How long it holds · classical

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.

Exhaustive search of the 2132 keyspace. Expected time is half these figures, which changes nothing at this scale.
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
For comparison: the entire Bitcoin mining network — the largest brute-force machine ever built, purpose-designed ASICs drawing gigawatts — manages about 7 × 1020 cheap hashes per second. It cannot be pointed at this, because Argon2id is not a cheap hash; but even if it could, a single 12-word key would outlast it by an incomprehensible margin.
The one discount the shared salt allows. Because every room is stretched with the same constant, an attacker holding the whole database can run Argon2id once on a candidate phrase and test the result against every room at the same time. That does not help anyone hunting a particular room — yours still costs the full 2132 — but it makes finding whichever room falls first cheaper by however many rooms there are: roughly 2102 with a billion of them, or 3.2 × 1012 years at the rate in the last row above. It is the price of the twelve words being sufficient on their own, and it is worth paying. Breaking one room still reveals nothing whatsoever about any other, because no two rooms share anything but the constant.
How long it holds · quantum

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.

What a large fault-tolerant quantum computer would do to each primitive, and whether this design uses it.
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
Shor's algorithm is the one that ends everything — and it only attacks public-key mathematics: factoring and discrete logarithms. This design contains no public-key cryptography at all. No key exchange, no key pairs, no certificates in the cryptographic path. There is nothing here for Shor to attack, which is why we do not ship ML-KEM or any other post-quantum algorithm: they solve key exchange, and we do not do key exchange.
Grover's algorithm is the one that applies, and it is far weaker. It gives only a square-root speedup, so 132 bits of key falls to about 66 bits of effective search. That sounds alarming until you count what one Grover iteration costs here: a complete, coherent evaluation of Argon2id at 64 MiB, inside the quantum machine, without collapsing. That means roughly 5 × 108 logical qubits — call it 5 × 1011 physical qubits after error correction. The largest quantum computer built to date holds about 1,100. The requirement is hundreds of millions of times larger than anything that exists, and Grover cannot be parallelised out of the problem: a thousand such machines buy a factor of thirty-one, not a thousand.
Granting the impossible machine anyway: a fault-tolerant quantum computer that evaluates Argon2id coherently as fast as a laptop does classically.
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)
The honest summary: a quantum computer is not on the list of things that will break this. Both rows above assume a machine nobody has a credible path to building, and both still land beyond any horizon that matters. The threats worth naming are in the next section, and none of them are quantum.
The honest part

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.