The room-ID bug is the most useful thing in here, and not because of the fix. It's the failure shape: the transfer completed, the file arrived, every demo passed, and the only thing that broke was the sentence in the README. Features have tests; promises usually don't. The cheap guard for that whole class is a test that asserts a negative, something like "the announced topic is not equal to the secret and does not appear anywhere in the announcement payload." It's an odd test to write because it passes trivially on day one, and it's the only kind that fails the day someone simplifies the derivation away.
Two questions about the SAS, since it's carrying the entire MITM story. What's the alphabet, and is a mismatch fatal to the secret? Four symbols out of a 64-emoji set is 24 bits, which is plenty against an attacker who gets one shot per pairing, but SAS strength is really "bits times shots," and the shots are a UX property rather than a crypto one. If a user who sees mismatched words can just tap retry and rescan the same QR, an attacker gets to keep rolling against the same secret until the words happen to collide, and the user has been trained by then to read a re-pair as ordinary flakiness. Burning the secret on mismatch and forcing a fresh QR is what keeps the 24 bits meaning 24 bits. (Matrix went to 7-of-64 for roughly this reason.)
The other thing the credential-in-the-QR design buys is that the leak paths stop being network paths. A screen share, an OBS scene, a recorded standup, a screenshot that syncs to a photo library — any of those hands over the whole credential, and unlike a password there's nothing to rotate afterward. "Show the QR to a person, not a room" is the right instruction, but instructions are the weakest control you have. Is the derived room one-shot, or does a code stay joinable for as long as the sender's tab is open? A single-use room turns a QR sitting in a recording into a dead code rather than a live one.
Asking partly because we're on the same side of this from a different direction: we do LAN-only video off an Android phone, no cloud leg, and we hit your exact README problem. "The video never leaves the device" is unfalsifiable to the person who most needs it to be true. The only version that convinced anyone was handing them the test — block the device at the router, confirm the feature still works — which is the same move as deriving the topic: make the claim something a stranger can check without trusting you.
Thanks — "features have tests; promises usually don't" is better than anything in the post.
The negative assertion. Not there. test/room.test.mjs asserts that every pairing announces a fresh public key, and that a third peer's frames never reach the receiver — but nothing asserts the flat the secret does not appear anywhere on the wire, as topic or payload. Different test, fails earlier and for a dumber reason. The fake network the suite uses already records everything sent, so it's cheap. Adding it.
SAS: 64 emoji, 4 shown, 24 bits. Your number. Each emoji carries a spoken name (apple, cactus, owl, taco…) picked for no near-homophones and no shared first syllable — the name is the real SAS content, the emoji is decoration.
Is a mismatch fatal? Effectively yes, but by accident. There's no retry on the verify screen. Cancel unwinds the session — room closed, back to the file picker — and the only way to a second pairing is to send again, which calls generateSecret() for fresh bytes. The old code is never re-offered, so it's one roll per secret.
What I'll concede: that's emergent, not enforced. Nothing states it as an invariant, and no test would catch a future "try again" button reintroducing exactly your loop — which is a tempting button, because a genuine pairing failure looks identical to a user. It's also unnamed in the UI: there's only a neutral Cancel, no "they don't match". Both worth fixing.
Room lifetime: one-shot for pairing, with an open window before it.
Topic is HKDF(secret, "topic") — stable for the life of the secret.
Pairing latches on first arrival: first peer to send its ECDH key is committed to, later ones ignored, others' frames dropped.
But that latch is the only thing closing the window, and first-to-arrive can be the attacker. The SAS is what catches that.
The window lasts as long as the tab sits on the QR, unpaired. No TTL.
So a QR in a recording is live exactly while the sender is still waiting for a scan — which is the same moment the screen share is running. Once a pairing settles it's spent. Rotating the code on a timer would close that properly; it isn't built.
All three are filed, with your framing rather than mine: #2 the negative assertion, #3 naming the mismatch, #4 documenting the exposure window.
On the LAN video: same move. "Block it at the router and watch it still work" has the shape of deriving the topic — replace trust me with something a stranger can check. My nearest equivalent is a strict CSP plus shipped source maps, so claims can be diffed against what's running. Weaker than yours, because almost nobody will run it. I don't have a ten-second physical test for the browser path.
For further actions, you may consider blocking this person and/or reporting abuse
We're a place where coders share, stay up-to-date and grow their careers.
The room-ID bug is the most useful thing in here, and not because of the fix. It's the failure shape: the transfer completed, the file arrived, every demo passed, and the only thing that broke was the sentence in the README. Features have tests; promises usually don't. The cheap guard for that whole class is a test that asserts a negative, something like "the announced topic is not equal to the secret and does not appear anywhere in the announcement payload." It's an odd test to write because it passes trivially on day one, and it's the only kind that fails the day someone simplifies the derivation away.
Two questions about the SAS, since it's carrying the entire MITM story. What's the alphabet, and is a mismatch fatal to the secret? Four symbols out of a 64-emoji set is 24 bits, which is plenty against an attacker who gets one shot per pairing, but SAS strength is really "bits times shots," and the shots are a UX property rather than a crypto one. If a user who sees mismatched words can just tap retry and rescan the same QR, an attacker gets to keep rolling against the same secret until the words happen to collide, and the user has been trained by then to read a re-pair as ordinary flakiness. Burning the secret on mismatch and forcing a fresh QR is what keeps the 24 bits meaning 24 bits. (Matrix went to 7-of-64 for roughly this reason.)
The other thing the credential-in-the-QR design buys is that the leak paths stop being network paths. A screen share, an OBS scene, a recorded standup, a screenshot that syncs to a photo library — any of those hands over the whole credential, and unlike a password there's nothing to rotate afterward. "Show the QR to a person, not a room" is the right instruction, but instructions are the weakest control you have. Is the derived room one-shot, or does a code stay joinable for as long as the sender's tab is open? A single-use room turns a QR sitting in a recording into a dead code rather than a live one.
Asking partly because we're on the same side of this from a different direction: we do LAN-only video off an Android phone, no cloud leg, and we hit your exact README problem. "The video never leaves the device" is unfalsifiable to the person who most needs it to be true. The only version that convinced anyone was handing them the test — block the device at the router, confirm the feature still works — which is the same move as deriving the topic: make the claim something a stranger can check without trusting you.
Thanks — "features have tests; promises usually don't" is better than anything in the post.
The negative assertion. Not there.
test/room.test.mjsasserts that every pairing announces a fresh public key, and that a third peer's frames never reach the receiver — but nothing asserts the flat the secret does not appear anywhere on the wire, as topic or payload. Different test, fails earlier and for a dumber reason. The fake network the suite uses already records everything sent, so it's cheap. Adding it.SAS: 64 emoji, 4 shown, 24 bits. Your number. Each emoji carries a spoken name (
apple,cactus,owl,taco…) picked for no near-homophones and no shared first syllable — the name is the real SAS content, the emoji is decoration.Is a mismatch fatal? Effectively yes, but by accident. There's no retry on the verify screen. Cancel unwinds the session — room closed, back to the file picker — and the only way to a second pairing is to send again, which calls
generateSecret()for fresh bytes. The old code is never re-offered, so it's one roll per secret.What I'll concede: that's emergent, not enforced. Nothing states it as an invariant, and no test would catch a future "try again" button reintroducing exactly your loop — which is a tempting button, because a genuine pairing failure looks identical to a user. It's also unnamed in the UI: there's only a neutral Cancel, no "they don't match". Both worth fixing.
Room lifetime: one-shot for pairing, with an open window before it.
HKDF(secret, "topic")— stable for the life of the secret.So a QR in a recording is live exactly while the sender is still waiting for a scan — which is the same moment the screen share is running. Once a pairing settles it's spent. Rotating the code on a timer would close that properly; it isn't built.
All three are filed, with your framing rather than mine: #2 the negative assertion, #3 naming the mismatch, #4 documenting the exposure window.
On the LAN video: same move. "Block it at the router and watch it still work" has the shape of deriving the topic — replace trust me with something a stranger can check. My nearest equivalent is a strict CSP plus shipped source maps, so claims can be diffed against what's running. Weaker than yours, because almost nobody will run it. I don't have a ten-second physical test for the browser path.