fix(security): a link cannot take over the app, and a stray frame cannot kill it - #12
Conversation
…not kill it Two findings from an adversarial audit, both confirmed by independent verifiers, both reproduced as failing tests before being fixed. A pairing link paired the app by itself. Pair.svelte ran the fragment the moment it arrived, with no screen and no tap. A link is something anyone can send — by SMS, by email, on a sticker over the real QR — so "your box needs re-pairing, tap here" silently repointed the app at the sender's box: their readings shown as this home, every mode change sent to their hardware, and no way back without finding the physical code. On a device without PRF it cost the owner not one interaction. Worse, this repo made it easier earlier today: the leftover-fragment fix treats "a different box" as evidence of a genuine invitation, which is exactly what an attacker's link is. A link is an offer now. The box's key is shown as a six-character fingerprint, replacing an existing home is named as what it does, and nothing is trusted until someone agrees. Scanning with the camera is a deliberate act already, so that path still pairs on the spot. storeSite no longer moves localStorage['ftw.site'] as a side effect either: storing a site is a fact, making it the app's home is a decision, and only the caller who saw the user agree may take it. A stray frame killed the carrier permanently. Every inbound frame was fed to the handshake while awaiting message 2, and a read failure closed the carrier non-retryably. The relay broadcasts the box's frames to every stream in a room, so a second phone in the same house starts its handshake into a running 1 Hz telemetry stream and dies on the first frame — a household where one phone works and the second never can. It also handed anyone able to write to the socket a one-packet kill switch. Only message-2-shaped frames reach the handshake now, a frame that does not open is dropped rather than fatal, and a deadline ends a handshake that is truly going nowhere — retryably, which matters because a box refuses by staying silent, so silence had no ending at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deploying with
|
| Status | Name | Latest Commit | Updated (UTC) |
|---|---|---|---|
| ✅ Deployment successful! View logs |
ftw-webapp | a42a346 | Aug 05 2026, 05:56 PM |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a42a346742
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if (this.#closed || this.#handshake !== handshake) return | ||
| this.#log?.(err instanceof NoiseError ? err.message : 'handshake frame ignored') |
There was a problem hiding this comment.
Recreate the handshake after ignored message-2 failures
When a right-length stray frame arrives before the real reply, readMessage is not just a validation check: #readMessage2 has already mixed the peer ephemeral/DH into the HandshakeState before authentication fails. Returning here keeps that corrupted handshake, so the legitimate message 2 can no longer authenticate and the connection waits for the deadline instead of recovering from the one packet this change is meant to ignore; reset/start a fresh handshake or validate without mutating after a failed candidate message 2.
Useful? React with 👍 / 👎.
| clearTimeout(this.#deadline) | ||
| this.#deadline = setTimeout(() => { | ||
| if (this.#closed || !this.#awaitingReply) return | ||
| this.#fail('the box did not answer', true) |
There was a problem hiding this comment.
Force an inner reconnect when the deadline fires
When the box stays silent or the first reply is lost, this timeout reports a retryable close but #fail only changes the NoiseCarrier status; it does not close or re-dial the inner RelayCarrier. In the normal relay path the inner carrier remains open and will not emit another open, while Session only marks itself failed on the closed status, so no new handshake is actually started until external socket churn or a reload; the deadline needs to close/reconnect the inner carrier or explicitly begin a fresh handshake.
Useful? React with 👍 / 👎.
Two audit findings, each confirmed by independent verifiers and reproduced as
a failing test before the fix.
A pairing link paired the app by itself
Pair.svelteran the fragment on arrival — no screen, no tap. A link issomething anyone can send: by SMS, by email, on a sticker over the real
QR. "Your box needs re-pairing, tap here" silently repointed the app at the
sender's box — their readings shown as this home, every mode change sent to
their hardware, no way back without the physical code. On a device without PRF
it cost the owner zero interactions.
And this repo made it easier earlier today: #9's leftover-fragment fix treats
"a different box" as evidence of a genuine invitation, which is precisely what
an attacker's link is.
Now: the box is shown by a six-character fingerprint, replacing an existing
home is named as what it does, and nothing is trusted until someone agrees.
Camera scanning is already a deliberate act, so that path still pairs on the
spot.
storeSitealso no longer moveslocalStorage['ftw.site']as a side effect.Storing a site is a fact; making it the app's home is a decision, and only the
caller who watched the user agree may take it.
Test against the old code:
expected "vi.fn()" to not be called at all, but actually been called 1 timesA stray frame killed the carrier, permanently
Every inbound frame went to the handshake while awaiting message 2, and a read
failure closed the carrier non-retryably. The relay broadcasts the box's
frames to every stream in a room — so a second phone in the same house starts
its handshake into a running 1 Hz telemetry stream and dies on the first
frame. A household where one phone works and the second never can. It is also
a one-packet kill switch for anyone able to write to the socket.
Now: only message-2-shaped frames reach the handshake, a frame that does not
open is dropped rather than fatal, and a deadline ends a handshake going
nowhere — retryably. That last part matters: the box refuses a handshake by
staying silent (a reply would confirm a box is on that handle), so silence had
no ending at all and a revoked phone sat on "Reaching your box" forever.
Tests against the old code:
a broadcast frame closed the carrier: expected true to be false.Full suite: 351 passing.