This article explains only the encryption and decryption parts of nullroom. Nothing else (UI, timers, networking details) is covered except where it's needed to understand how the keys move around.

The goal: a developer who is not a cryptography expert should finish this and fully understand what is happening and why.

We'll do two things:

  1. First, a plain‑English glossary of the building blocks (key, salt, derive, AES‑GCM 256, IV, hash, HKDF, ML‑KEM, HMAC).
  2. Then, a step‑by‑step walkthrough from room creation all the way to two people sending encrypted messages and files.

Part 1 — The building blocks, in plain words

What is a "key"?

A key is just a secret number (a big random sequence of bytes). If you have the key, you can lock (encrypt) and unlock (decrypt) data. If you don't have the key, the data looks like meaningless noise. Everything in nullroom comes down to: who has the key? The whole design is built so that only the two people in the room ever have it — never the server.

AES‑GCM 256‑bit — the actual lock

This is the algorithm that does the real encryption of your messages and files. Break the name down:

  • AES = "Advanced Encryption Standard." A very well‑tested, industry‑standard way to scramble data. It's what banks, governments, and HTTPS use.
  • 256‑bit = the size of the key. 256 bits is a 32‑byte secret. The number of possible keys is so astronomically large that guessing one is considered impossible with today's (and the foreseeable future's) computers.
  • GCM = "Galois/Counter Mode." This is the mode AES runs in. GCM does two jobs at once:

    1. It encrypts the data (turns it into noise).
    2. It adds a built‑in tamper check. When you decrypt, if even a single byte was changed in transit, decryption fails loudly instead of silently handing back corrupted data. This is why you'll see decryption "fail" rather than return garbage if someone messes with the message.

So AES‑GCM 256‑bit = "scramble this data with a 32‑byte secret, in a way that also detects tampering." This is the core lock used everywhere in nullroom.

In code, the browser's built‑in crypto.subtle (the Web Crypto API) does the heavy lifting. nullroom never ships its own hand‑written encryption math for AES — it asks the browser, which is exactly what you want for safety.

IV (Initialization Vector) — the "salt for each message"

If you encrypt the same message twice with the same key, you do not want the output to look identical — that would leak information ("these two messages are the same"). The IV prevents this.

The IV is a small random value (12 bytes in nullroom) that is mixed in every time you encrypt one piece of data. It makes each encryption come out different even if the key and the message are identical.

Important facts about the IV:

  • It is not secret. It's fine for anyone to see it.
  • It must be fresh and random for every single encryption.
  • The receiver needs it to decrypt, so nullroom simply glues the IV onto the front of the encrypted data: the stored/sent blob is IV (12 bytes) + ciphertext. On the way in, it slices the first 12 bytes back off to get the IV, and the rest is the actual encrypted content. You'll see this exact pattern in encryption.js (encrypt/decrypt) and handshake_crypto.js.

Hash (SHA‑256) — a one‑way fingerprint

A hash takes any input (a word, a sentence, a file) and produces a fixed‑size "fingerprint" — for SHA‑256, always 64 hex characters. Two key properties:

  • One‑way: you can go input → fingerprint, but you cannot go fingerprint → input. The fingerprint reveals nothing about the original.
  • Deterministic: the same input always gives the same fingerprint.

nullroom uses this for the "short code" feature: it can publish the fingerprint of a secret phrase as a public lookup ID, without ever revealing the phrase itself. (More on this in Part 2.)

Salt — making secrets "unique to this app"

A salt is an extra fixed or random value that you add to a password/phrase before turning it into a key. Why?

Imagine attackers have pre‑computed a giant table of "common phrase → resulting key." A salt poisons that table: because your phrase is combined with the salt before key creation, the attacker's pre‑computed table is useless unless they also knew your exact salt and rebuilt the whole table around it.

In nullroom's short‑code feature (handshake_crypto.js), the salt is a fixed label:

const SALT = new TextEncoder().encode("nullroom-handshake")

This is a fixed application salt (the same for everyone), not a per‑user random salt. That's a deliberate, acceptable choice here because the phrase itself is randomly generated with high entropy (4 random words) and the encrypted data only lives on the server for 180 seconds and can be read only once — so the classic "pre‑computed table" attack isn't the threat model. The salt's role here is mainly to bind the key‑derivation to this app's purpose.

"Derive" a key — turning a phrase into a real key

A human‑typed phrase like neon-zebra-piano-rocket is not, by itself, a good encryption key. Deriving means running that phrase through a special function that stretches and transforms it into a proper, full‑strength key.

nullroom uses PBKDF2 (Password‑Based Key Derivation Function 2) for this:

crypto.subtle.deriveKey(
  { name: "PBKDF2", salt: SALT, iterations: 100_000, hash: "SHA-256" },
  keyMaterial,                       // the phrase
  { name: "AES-GCM", length: 256 },  // produce a 256-bit AES-GCM key
  ...
)

What PBKDF2 does, in plain terms:

  • It takes the phrase + the salt.
  • It runs the hashing process 100,000 times in a row (the iterations).
  • Out comes a 32‑byte AES‑GCM key.

Why repeat 100,000 times? To make it deliberately slow. A legitimate user runs it once and waits a few milliseconds — no problem. But an attacker trying to guess millions of phrases now has to do 100,000× the work per guess, which makes brute‑forcing impractically expensive. This is called "key stretching."

The key insight: "derive" = deterministically turn a weak human input into a strong cryptographic key. Same phrase + same salt → always the same key. That's exactly why the receiver, who only knows the phrase, can reproduce the identical key and decrypt.

HKDF — combining two secrets into one new key

HKDF (HMAC‑based Key Derivation Function) is another "derive a key" tool, but it's used for a different situation: you already have some good secret material (or two separate secrets) and you want to mix them into one fresh key.

nullroom uses HKDF to fuse two keys together during the post‑quantum upgrade (explained in Part 2). Conceptually:

new hybrid key = HKDF( ingredient A + ingredient B )

If either ingredient is unknown to an attacker, the resulting key is safe.

ML‑KEM‑768 — the "future‑proof / quantum‑safe" key exchange

Here's a problem the two people in a room have: they need to agree on a shared secret while talking over a network a stranger might be recording. Classic methods exist, but a future quantum computer could potentially break some of them retroactively ("record now, decrypt later").

ML‑KEM (formerly called "Kyber") is a newer, standardized algorithm designed to resist quantum computers. KEM stands for "Key Encapsulation Mechanism" — it's a structured way for two parties to end up sharing the same secret number without ever transmitting that number directly. The 768 is the security‑level variant.

How a KEM works, simply:

  1. Person A generates a key pair: a public key (shareable) and a secret key (private).
  2. A sends the public key to B.
  3. B uses A's public key to "encapsulate" — this produces two things: a shared secret (B keeps it) and a ciphertext (a sealed box) that B sends back to A.
  4. A "decapsulates" the ciphertext using A's secret key, and out comes the exact same shared secret B has.

Now both sides hold an identical secret that was never sent over the wire in the clear. An eavesdropper who recorded the public key and the ciphertext still can't compute the shared secret.

HMAC — "are we both really holding the same secret?"

After the KEM exchange, each side wants to prove to the other that they truly derived the same shared secret (and that nobody tampered in the middle). HMAC is a keyed fingerprint: it takes a secret + a label and produces a tag. If both sides compute the tag over the same secret, the tags match. If an attacker sat in the middle and the secrets don't actually match, the tags won't match and the connection is rejected.

nullroom uses HMAC‑SHA‑256 with role‑specific labels (one label for the "initiator," a different one for the "responder") so the two confirmation messages can't be confused or replayed back at the sender.

Part 2 — Step by step: from room creation to encrypted chat

Now we follow the whole journey. There are two separate keys/secrets that come into play, so watch which one is active at each stage:

  • Key #1 — the "classical" room key: a random AES‑GCM 256 key created in the browser and carried in the URL.
  • Key #2 — the post‑quantum shared secret: produced over the live connection.
  • These two are later fused into the final "hybrid key" that actually protects your chat and files.

There's also an optional short‑code phrase used only as a convenient way to hand someone the link. It's its own little encryption sidebar.

Step 0 — The golden rule

The server's job is to introduce two browsers to each other and pass tiny connection‑setup messages between them. The server is designed to never see the encryption key or any plaintext. Keep that in mind throughout.

Step 1 — Creating a room (Key #1 is born in the browser)

File: controllers/landing_controller.js + modules/encryption.js

When you click "Create Room":

  1. The browser asks the server to create an empty room. The server returns a room_id (just an identifier) and TURN/relay server info. No key involved yet.
  2. The browser then generates Key #1 locally:

    const key = await crypto.subtle.generateKey(
      { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]
    )

    This is a brand‑new random 256‑bit AES‑GCM key. It was created on your machine and has not touched the network.

  3. The key is exported to a compact text form (JWK → JSON → Base64) so it can fit in a URL.
  4. The browser navigates to:

    /rooms/<room_id>#<the-base64-key>

The single most important detail in the whole app: the key is placed after the # in the URL. The part after # is called the URL fragment, and browsers, by design, never send the fragment to the server. So the key lives in the link, travels only as far as whoever you give the link to, and the server literally never receives it. (See the comment in landing_controller.js: "key is born in browser and never touches HTTP".)

You now have a link like https://nullroom.io/rooms/abc123#KEY. You need to get it to the other person. There are two ways:

(a) Just send the whole link (copy/paste, QR code). The other person opens it, their browser reads the key from the # fragment, done. Simple.

(b) The short‑code handshake — a friendlier "4 words" code. This is its own mini encryption flow, described next.

Step 2b — The optional short‑code handshake (a self‑contained crypto sidebar)

Files: modules/handshake_crypto.js, controllers/handshake_controller.js, controllers/handshakes_controller.rb (server)

Problem: the full link (with the key) is long and ugly. It'd be nice to share something like neon-zebra-piano-rocket. But the link contains the secret key — so we must store the link somewhere the sender can publish and the receiver can fetch, without the server ever being able to read it. Here's the trick:

Sender side:

  1. Generate a random 4‑word phrase from a fixed word list: neon-zebra-piano-rocket. This phrase is the secret. It stays on the sender's screen.
  2. From the phrase, compute two different things:

    • An identifier = SHA‑256(phrase) (the one‑way fingerprint, 64 hex chars). This is public and reveals nothing about the phrase. It's used as a lookup key.
    • A derived key = PBKDF2(phrase + salt, 100k iterations) → AES‑GCM 256. This is the secret key.
  3. Encrypt the full room link (including the #KEY part!) with that derived key. Output is Base64(IV + ciphertext) — an unreadable blob.
  4. Send that blob to the server, stored under the identifier: POST /handshakes/<identifier> with the encrypted blob.

What does the server now hold? Only:

  • A fingerprint (identifier) that reveals nothing.
  • An encrypted blob it cannot read (it never has the phrase, so it can't derive the key).

The server stores it for only 180 seconds and allows exactly one read (see handshakes_controller.rb: setex(key, 180, blob) and REDIS.del on read). This is genuinely zero‑knowledge: the server is a blindfolded dead‑drop.

Receiver side:

  1. The receiver types the 4‑word phrase they were told (out‑of‑band — e.g. read aloud, texted).
  2. Their browser computes SHA‑256(phrase) → the same identifier, and fetches the blob: GET /handshakes/<identifier>. (The server deletes it on this read.)
  3. Their browser derives the same key from the phrase (same phrase + same salt + same PBKDF2 = same key) and decrypts the blob, revealing the full room link including #KEY.
  4. The browser navigates to that link — and now the receiver has Key #1 too.

Notice the phrase never went to the server — only its fingerprint and an encrypted blob did. Whoever knows the phrase can decrypt; the server cannot.

Step 3 — The receiver opens the room (both sides now hold Key #1)

File: controllers/room_controller.js (initializeRoom)

However the link arrived, the receiver's browser now reads the key out of the URL fragment and imports it back into a usable key object:

const keyString = window.location.hash.substring(1)  // text after '#'
this.state.encryptionKey = await importKey(keyString)  // back to an AES-GCM key

At this point both browsers hold the identical Key #1, and the server still has never seen it.

Step 4 — Connecting the two browsers directly (P2P)

Files: modules/peer_connection.js, controllers/room_controller.js

The two browsers now set up a direct peer‑to‑peer (WebRTC) connection so that messages flow browser‑to‑browser instead of through the server. The server only relays a few tiny "let's connect" setup messages (called signaling). If a direct path isn't possible (restrictive firewall), traffic is relayed through a TURN server — but even the relay only ever sees encrypted bytes, never plaintext.

This step is about networking, not encryption, so we won't go deeper — except to note the next step happens the instant this channel opens.

Step 5 — The post‑quantum upgrade (Key #2 is created, then fused with Key #1)

File: modules/pq_upgrade.js + deriveHybridKey in modules/encryption.js

So far the room is protected by Key #1, which came in via the URL. That's good, but nullroom adds a second, quantum‑resistant layer that is negotiated live between the two connected browsers. The moment the P2P data channel opens, the two sides run the ML‑KEM‑768 exchange. Three messages total:

  1. Initiator → Responder: "Here's my ML‑KEM public key." (pq-pubkey)
  2. Responder → Initiator: "I used your public key to make a shared secret. Here's the sealed ciphertext, plus an HMAC proving I have the secret." (pq-encap + confirm)
  3. Initiator → Responder: the initiator decapsulates the ciphertext (getting the same shared secret), verifies the responder's HMAC, then sends back its own HMAC. (pq-confirm)

After both HMACs verify, both sides are certain they hold the same new shared secret — call it Key #2 — and that nobody tampered in the middle. (If either HMAC fails to verify, the upgrade is aborted and the connection is rejected.)

Now the clever part — the two keys are fused into one final key using HKDF:

// deriveHybridKey() in encryption.js
HKDF(
  salt = Key #1 (the classical URL key, as raw bytes),
  ikm  = Key #2 (the quantum shared secret),
  info = "nullroom-hybrid-v1"
)  →  a brand-new AES-GCM 256 "hybrid key"

Why fuse them? Defense in depth. To break the final hybrid key, an attacker would have to break both:

  • Key #1 (the URL key) — which they'd need to have intercepted the link, and
  • Key #2 (the quantum exchange) — which is quantum‑resistant.

Compromising just one is not enough. From this moment on, the room replaces Key #1 with this hybrid key, and that is what encrypts everything afterward:

this.state.encryptionKey = hybridKey   // the classical key is now retired

Step 6 — Sending and receiving chat messages

File: controllers/room_controller.js (sendMessage / handleIncomingMessage), modules/encryption.js (encrypt / decrypt)

Now actual messaging. Every message you type:

  1. Encrypt with the hybrid key: a fresh random 12‑byte IV is generated, the text is encrypted with AES‑GCM, and the result is Base64(IV + ciphertext).
  2. Send that blob over the direct P2P data channel (never through the server).
  3. The other browser decrypts with the same hybrid key: it slices off the first 12 bytes (the IV) and decrypts the rest.

Because AES‑GCM has the built‑in tamper check, if anything altered the message in flight, decryption throws an error instead of showing corrupted text.

(Small implementation note: internal "control" messages — like syncing the countdown timer — are encrypted the exact same way; they're just tagged with a leading \x01 byte so the receiver knows to treat them as a control instruction rather than chat text. Same encryption, different label.)

Step 7 — Sending files

Files: modules/file_transfer.js, encryptBuffer/decryptBuffer in modules/encryption.js

Files use the same hybrid key and the same AES‑GCM lock, with one twist for size: a big file is broken into 64 KB chunks, and each chunk is encrypted separately with its own fresh IV.

  • Sender: reads the file slice by slice (so the whole file never has to sit in memory), encrypts each 64 KB chunk into IV + ciphertext, and streams the chunks over a dedicated file data channel.
  • Receiver: decrypts each chunk with the hybrid key and reassembles them back into the original file.

As with messages, the bytes travel directly between the two browsers, never through the server (the server is only asked to approve the size, never the content or even the filename).

Part 3 — The whole picture in one glance

StageWhat protects itWhere the secret lives
Room link sharingAES‑GCM 256 (Key #1)In the URL #fragment — never sent to the server
Optional short codePBKDF2‑derived AES‑GCM 256 from a 4‑word phrasePhrase known only to the two humans; server holds an unreadable blob for ≤180s, one read
Setting up the connection(networking only)—
Live key upgradeML‑KEM‑768 → Key #2, confirmed with HMACNegotiated live between the two browsers; never sent in the clear
Final protectionHybrid key = HKDF(Key #1 + Key #2)Exists only inside the two browsers
Chat messagesAES‑GCM 256 with the hybrid key, fresh IV each messageHybrid key, in‑browser only
File transferAES‑GCM 256 with the hybrid key, per‑64 KB‑chunk IVHybrid key, in‑browser only

The three things to remember

  1. The key is born in your browser and lives in the URL fragment, which browsers never send to the server. That single fact is why the server can't read anything.
  2. "Derive" means turning a phrase/secret into a real key deterministically — PBKDF2 for the short‑code phrase, HKDF for fusing the two keys. Same inputs always reproduce the same key, which is how both sides independently arrive at the identical key.
  3. AES‑GCM 256 is the lock used everywhere: a 32‑byte secret that both encrypts and detects tampering, always with a fresh 12‑byte IV glued to the front of each encrypted blob. The final key that operates this lock is a hybrid of a classical key and a quantum‑resistant key, so breaking one isn't enough.

Source files referenced