Pin three claims the code was keeping by accident - #5
Merged
Conversation
The room-ID bug had a shape the suite could not see. The transfer completed, the file arrived, every demo passed, and the only thing that broke was a claim in the README. Features have tests; promises usually do not, and this file's two existing invariants are both feature-shaped -- a hoisted keypair fails the announce-a-fresh-key test, a stranger's frame fails the peer-filter test, and neither would have noticed the secret being used as the rendezvous topic. So the promise is written down: the secret is the whole credential and must never be a value a relay operator can read. It is checked against every encoding it could plausibly leak as -- base64url, the qrdrop: code form, standard base64, hex in both cases, and a raw byte-run scan over the binary payloads -- rather than only the one it happens to use, because the failure being guarded is somebody passing the secret somewhere that re-encodes it on the way out. fake-network.mjs grows two records to make that reachable. `sent` is every payload on every namespace, taken at the single choke point in send() before any target filtering, since a relay sees a send regardless of who it was addressed to. `joins` captures the Trystero config, which was discarded as `_config` and is the one place a derived value could be swapped for the secret with no payload changing -- derivePassword's output goes through it. `announced` is left exactly as it was: it is a narrow view of `sent`, and it is load-bearing in a passing test that asserts a count. The test passes trivially today, which is the point. It exists for whoever later reads deriveTopic, notices it is hashing 32 bytes that were already random, and concludes the hash is ceremony. Verified it can fail by injecting both halves of the bug in turn -- deriveTopic returning the secret, then derivePassword returning it -- and in each case this test failed alone while the other four stayed green. Closes #2
…e secret The SAS is four symbols out of 64 -- 24 bits -- and that number is strength only if an attacker gets one attempt at it. Bits times shots, where the shots are a property of how the screens are wired rather than of any crypto: a user who can re-scan the same QR after a mismatch lets the attacker keep rolling, and by the third try that user has been taught to read a re-pair as ordinary flakiness. We already had one roll per secret, but emergently. Cancel routes to _reset(), which lands on the file picker, and the only way back to a pairing is _startSend(), which mints fresh bytes. Nothing stated it and no test pinned it, so a "Try again" button on verify would have reintroduced the loop -- and that is a tempting button, because a genuine pairing failure and an active attack look identical from that screen. The screen also never said "they don't match" at all: it offered one primary and a neutral Cancel, so a user staring at four symbols that differ had nothing to press meaning what they saw, and Cancel dropped them on the file picker with no word about it. So verifyStatus gains "They don't match" beside the confirm, dispatching a new verify:reject intent to _rejectVerification. That ends the session and lands on 'done' with a new 'mismatch' outcome -- not _reset(), which is the silent bounce being fixed, and not _failTransfer(), because nothing failed and an error sheet is the wrong register for a decision the user made correctly. The restart label reads "Start over with a fresh code" rather than "Send another file", which would be true and would quietly invite the one thing that must not happen. Reusing state.outcome rather than adding a screen keeps SCREENS, builders, STEP_INDEX and the vdom's positional child matching untouched. The button is deliberately not .primary: e2e/transfer.e2e.mjs clicks '#verify-status button.primary', and a second one would make that selector ambiguous rather than wrong -- a strict-mode throw in the suite that needs two browsers and a public relay to run. The new .btn.danger is outlined rather than filled, since two filled buttons in one row ask the user to choose between equally weighted calls to action when only one is a decision about their file, and it reuses the --bad pair that .outcome.bad and .callout.danger already define in both palettes rather than adding a token. test/view.test.mjs is new, and is the first Node unit coverage src/web/view.js has ever had -- render() is pure and h() builds plain objects, so the tree can be walked in bare Node with no jsdom and no new dependency. It fires every onclick on the verify screen at a recording dispatch and fails on any pairing intent by name. Verified it trips by adding exactly the button the issue warns about; it failed alone, quoting the reason. It also pins that #verify-status holds one .primary, which currently costs two browsers and a relay to discover. One thing only the screenshots could show, and check-layout was green throughout: the mismatch screen drew the "Local network" path badge directly beside "something is relaying between them" -- accurate about the route to the peer we paired with, and reading as reassurance about the connection the user has just been told not to trust. _rejectVerification clears path, alone among the outcomes, and the fixture sets it null rather than inheriting it. Sender-only, matching where the control renders. The receiver sees the same symbols and a mismatch means the same thing there, but its verify screen already carries Accept and Decline and the copy is a separate question. check-layout.mjs passes on both trees, both engines, all four viewports with the pointer axis crossed, including the new done-mismatch fixture. Closes #3
…tches on first arrival Because the QR is the entire credential, the leak paths stop being network paths: a screen share, an OBS scene, a recorded standup, a screenshot that syncs to a photo library. Unlike a password there is nothing to rotate afterwards. The docs answered that with an instruction -- "show the QR to a person, not to a room" -- and an instruction is the weakest control available. What they did not say is how long the code stays useful, and the honest answer is more interesting than either "one-shot" or "live forever". The topic is HKDF(secret, "topic"), so it is deterministic and stable for the life of the secret; it is not a nonce and it does not expire. Pairing latches on first arrival, so a code is spent for pairing purposes once a pairing settles -- but that latch is the only thing closing the window, and first-to-arrive can be the attacker rather than the intended phone, which is what the SAS exists to surface. There is no TTL, so the window is exactly as long as the sender's tab sits on the QR screen unpaired. Net, and stated plainly because it is the sentence someone needs before hitting record: a QR in a recording is a live code precisely while the sender is still waiting for someone to scan, which is the same moment a screen share is typically running. Cross-referenced to the SAS section, and specifically to why a mismatch now asks for a fresh code rather than offering a retry -- 24 bits holds only at one attempt per secret, and this window is what a retry would reopen. SECURITY.md's "anyone holding the code can join" gains one sentence pointing here rather than a second copy of the reasoning. Not documented, because it is not what this repository does: rotating the code on a timer. That would close the window properly, at the cost of tearing down and re-establishing a room mid-screen with a race if a peer arrives during the rotation. It is worth its own issue rather than a paragraph promising it. Closes #4
stan-ely
deleted the
fix/wire-secret-verify-mismatch-exposure-window
branch
September 10, 2026 18:19
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three issues from the dev.to thread, all raised by @superfunicular. None is a bug report. Each names a promise the code currently keeps by accident rather than by construction, and asks for the thing that makes it survive the next edit.
test(room)— the secret never appears on the wire (#2)The room-ID bug had a shape the suite could not see: the transfer completed, the file arrived, and the only casualty was a claim in the README. This file's two existing invariants are both feature-shaped and neither would have noticed it.
So the promise is written down, checked against every encoding it could plausibly leak as — base64url, the
qrdrop:code form, standard base64, hex in both cases, and a raw byte-run scan over the binary payloads.fake-network.mjsgrowssent(every namespace, taken before target filtering, since a relay sees a send regardless of who it was addressed to) andjoins(the Trystero config, previously discarded as_config, and the one place a derived value could be swapped for the secret with no payload changing).announcedis untouched — it is load-bearing in a passing test that asserts a count.It passes trivially today, which is the point: it exists for whoever later reads
deriveTopic, notices it is hashing 32 bytes that were already random, and concludes the hash is ceremony.Verified it can fail. Injected both halves of the bug in turn —
deriveTopicreturning the secret, thenderivePasswordreturning it — and in each case this test failed alone while the other four stayed green.feat(web)— name the mismatch, pin the invariant (#3)Four symbols out of 64 is 24 bits, and that is strength only at one attempt per secret. Bits × shots, where the shots are a property of how the screens are wired.
We already had one roll per secret, emergently:
cancel→_reset()→choose, and the only way back is_startSend(), which mints fresh bytes. Nothing stated it and no test pinned it. The screen also never said "they don't match" at all — one primary and a neutral Cancel, so a user staring at four symbols that differ had nothing to press meaning what they saw.verifyStatusgains "They don't match" → newverify:rejectintent →_rejectVerification, which ends the session and lands ondonewith a newmismatchoutcome. Not_reset()(the silent bounce being fixed) and not_failTransfer()(nothing failed; an error sheet is the wrong register for a decision made correctly).state.outcomerather than adding a screen keepsSCREENS,builders,STEP_INDEXand the vdom's positional child matching untouched..primary:e2e/transfer.e2e.mjsclicks#verify-status button.primary, and a second one is a strict-mode throw in the suite that needs two browsers and a relay to run. New.btn.dangeris outlined rather than filled and reuses the--badpair already defined in both palettes.test/view.test.mjsis new, and is the first Node unit coveragesrc/web/view.jshas ever had —render()is pure andh()builds plain objects, so the tree walks in bare Node with no jsdom and no new dependency. It fires everyonclickon verify at a recording dispatch and fails on any pairing intent by name.Verified it can fail. Added exactly the "Try again" button the issue warns about; it failed alone, quoting the reason.
One thing only the screenshots could show, with
check-layoutgreen throughout: the mismatch screen drew the "Local network" path badge directly beside "something is relaying between them" — accurate about the route to the peer we paired with, and reading as reassurance about the connection the user has just been told not to trust._rejectVerificationclearspath, alone among the outcomes.Sender-only, matching the issue's scope. The receiver sees the same symbols and a mismatch means the same thing there, but its verify screen already carries Accept and Decline and the copy is a separate question — worth its own issue.
docs(security)— the exposure window (#4)The docs answered this with an instruction, and an instruction is the weakest control available. The threat model now states it precisely: the topic is
HKDF(secret, "topic")and does not expire; pairing latches on first arrival, so a code is spent for pairing purposes once a pairing settles; that latch is the only thing closing the window, and first-to-arrive can be the attacker; there is no TTL, so the window is the sender's unpaired tab lifetime.Net, stated plainly because it is the sentence someone needs before hitting record: a QR in a recording is a live code precisely while the sender is still waiting for someone to scan — the same moment a screen share is typically running.
SECURITY.mdgains one sentence pointing at it rather than a second copy of the reasoning. Out of scope, deliberately: rotating the code on a timer. It would close the window properly, at the cost of tearing down and re-establishing a room mid-screen with a race if a peer arrives during rotation — worth its own issue rather than a paragraph promising it. Happy to open one.CLAUDE.md's existing "two safety gestures cannot be softened" invariant absorbs the one-roll rule, rather than a second bullet elsewhere.Verification
npm test— 161 pass (was 158)npm run typecheck— both passes cleancheck-layout.mjs— both deployed trees, both engines, all four viewports with the pointer axis crossed, including the newdone-mismatchfixtureNot run:
test:e2eneeds public relays and is out of CI;test:e2e:interopis known-flaky. The new#verify-statussingle-.primaryassertion is the cheap stand-in for the e2e selector.Closes #2
Closes #3
Closes #4