Skip to content

Pin three claims the code was keeping by accident - #5

Merged
stan-ely merged 3 commits into
mainfrom
fix/wire-secret-verify-mismatch-exposure-window
Sep 10, 2026
Merged

Pin three claims the code was keeping by accident#5
stan-ely merged 3 commits into
mainfrom
fix/wire-secret-verify-mismatch-exposure-window

Conversation

@stan-ely

Copy link
Copy Markdown
Owner

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.mjs grows sent (every namespace, taken before target filtering, since a relay sees a send regardless of who it was addressed to) and joins (the Trystero config, previously discarded as _config, and the one place a derived value could be swapped for the secret with no payload changing). announced is 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 — deriveTopic returning the secret, then derivePassword returning 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.

  • verifyStatus gains "They don't match" → new verify:reject intent → _rejectVerification, which ends the session and lands on done with a new mismatch outcome. Not _reset() (the silent bounce being fixed) and not _failTransfer() (nothing failed; an error sheet is the wrong register for a decision made correctly).
  • Restart reads "Start over with a fresh code", not "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 is a strict-mode throw in the suite that needs two browsers and a relay to run. New .btn.danger is outlined rather than filled and reuses the --bad pair already defined in both palettes.

test/view.test.mjs is new, and is the first Node unit coverage src/web/view.js has ever hadrender() is pure and h() builds plain objects, so the tree walks in bare Node with no jsdom and no new dependency. It fires every onclick on 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-layout 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.

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.md gains 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 clean
  • check-layout.mjsboth deployed trees, both engines, all four viewports with the pointer axis crossed, including the new done-mismatch fixture
  • Screenshots reviewed at 390px and 800×360, then deleted

Not run: test:e2e needs public relays and is out of CI; test:e2e:interop is known-flaky. The new #verify-status single-.primary assertion is the cheap stand-in for the e2e selector.

Closes #2
Closes #3
Closes #4

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
stan-ely merged commit 33e9f11 into main Sep 10, 2026
7 checks passed
@stan-ely
stan-ely deleted the fix/wire-secret-verify-mismatch-exposure-window branch September 10, 2026 18:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant