Skip to content

fix(security): a link cannot take over the app, and a stray frame cannot kill it - #12

Merged
frahlg merged 1 commit into
mainfrom
no-silent-takeover
Aug 5, 2026
Merged

fix(security): a link cannot take over the app, and a stray frame cannot kill it#12
frahlg merged 1 commit into
mainfrom
no-silent-takeover

Conversation

@frahlg

@frahlg frahlg commented Aug 5, 2026

Copy link
Copy Markdown
Member

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.svelte ran the fragment on arrival — no screen, no tap. A link is
something 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.

storeSite also no longer moves localStorage['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 times

A 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.

…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>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 5, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
ftw-webapp a42a346 Aug 05 2026, 05:56 PM

@frahlg
frahlg merged commit 5f30e93 into main Aug 5, 2026
2 checks passed
@frahlg
frahlg deleted the no-silent-takeover branch August 5, 2026 17:57

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/lib/carrier/noise.ts
Comment on lines +234 to +235
if (this.#closed || this.#handshake !== handshake) return
this.#log?.(err instanceof NoiseError ? err.message : 'handshake frame ignored')

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Comment thread src/lib/carrier/noise.ts
clearTimeout(this.#deadline)
this.#deadline = setTimeout(() => {
if (this.#closed || !this.#awaitingReply) return
this.#fail('the box did not answer', true)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant