Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions contract/registry.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,9 @@ capabilities:
- der.v2x
- plan.dispatch
- net.webrtc
# Electricity prices, when the box has a zone configured and rows stored.
# Absent means the app draws no price view rather than an empty one.
- price.spot

# ---------------------------------------------------------------------------
# Scopes. One object axis, two verb axes: <object>.<read|write>.
Expand Down
5 changes: 5 additions & 0 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,11 @@
}

function onPaired(pairedSiteId: string) {
// The decision the user just made, recorded here rather than as a side
// effect of storing the row — see setCurrentSite.
void import('$lib/identity/pairing').then(({ setCurrentSite }) =>
setCurrentSite(pairedSiteId)
)
siteId = pairedSiteId
// The /p#… URL just did its one job. Left in place it becomes the URL
// the browser reloads and restores — with a spent code — which is how
Expand Down
106 changes: 106 additions & 0 deletions src/lib/carrier/handshake-resilience.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NoiseCarrier } from './noise'
import { CarrierBase, type Carrier, type CarrierStatus } from './carrier'
import { generateKeyPair } from '$lib/crypto/noise'
import type { CarrierState } from '$lib/protocol/types'

/* A stray frame during the handshake must not end the carrier.
*
* 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. Those frames are not message 2. Feeding them to
* readMessage used to close the carrier non-retryably — a household where
* the first phone works and the second never can, and a one-packet kill
* switch for anyone able to write to the socket.
*/

/** An inner carrier a test can push arbitrary bytes through. */
class FakeInner extends CarrierBase implements Carrier {
readonly kind: CarrierState = 'relay'
readonly sent: Uint8Array[] = []
#status: CarrierStatus = { phase: 'connecting' }

get rttMs(): number | null {
return null
}
get status(): CarrierStatus {
return this.#status
}
send(frame: Uint8Array): void {
this.sent.push(frame)
}
close(): void {
this.#status = { phase: 'closed', reason: 'test', retryable: true }
this.emitStatus(this.#status)
}
open(): void {
this.#status = { phase: 'open', sinceMs: Date.now() }
this.emitStatus(this.#status)
}
deliver(bytes: Uint8Array): void {
this.emitFrame(bytes)
}
}

function carrierUnderTest() {
const inner = new FakeInner()
const app = generateKeyPair()
const box = generateKeyPair()
const carrier = new NoiseCarrier({
inner,
staticKey: app,
remoteStatic: box.publicKey,
})
const seen: CarrierStatus[] = []
carrier.onStatus((s) => seen.push(s))
return { inner, carrier, seen }
}

describe('a handshake meeting somebody else’s frames', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())

it('ignores a telemetry-sized frame instead of dying on it', async () => {
const { inner, carrier, seen } = carrierUnderTest()
inner.open()
await vi.advanceTimersByTimeAsync(10)

// A lane 0 frame from the other phone's session: right shape for the
// wire, wrong thing entirely for this handshake.
inner.deliver(new Uint8Array(512))
inner.deliver(new Uint8Array(280))
await vi.advanceTimersByTimeAsync(10)

expect(
seen.some((s) => s.phase === 'closed'),
'a broadcast frame closed the carrier'
).toBe(false)
expect(carrier.status.phase).not.toBe('closed')
})

it('ignores a wrong-key reply of the right length and keeps waiting', async () => {
const { inner, carrier, seen } = carrierUnderTest()
inner.open()
await vi.advanceTimersByTimeAsync(10)

// 48 bytes: exactly message 2's shape, but not from the pinned box.
inner.deliver(new Uint8Array(48).fill(9))
await vi.advanceTimersByTimeAsync(10)

expect(seen.some((s) => s.phase === 'closed')).toBe(false)
expect(carrier.status.phase).toBe('connecting')
})

it('gives up retryably when the box never answers at all', async () => {
// A box that refuses a handshake stays silent on purpose — a reply would
// confirm a box is on this handle. Without a deadline a revoked phone
// sits on "Reaching your box" forever, socket open, never reconnecting.
const { inner, seen } = carrierUnderTest()
inner.open()
await vi.advanceTimersByTimeAsync(15_000)

const closed = seen.find((s) => s.phase === 'closed')
expect(closed, 'silence never ended the handshake').toBeDefined()
expect(closed && 'retryable' in closed && closed.retryable).toBe(true)
})
})
50 changes: 44 additions & 6 deletions src/lib/carrier/noise.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,24 @@

import { CarrierBase, type Carrier, type CarrierStatus } from './carrier'
import type { CarrierState } from '$lib/protocol/types'
import { HandshakeState, NoiseError, type StaticKey, type KeyPair } from '$lib/crypto/noise'
import { DH_BYTES, TAG_BYTES, HandshakeState, NoiseError, type StaticKey, type KeyPair } from '$lib/crypto/noise'
import { NoiseTransport } from '$lib/crypto/transport'

/**
* How long a handshake may go unanswered before it is retried.
*
* The box answers a refused handshake with silence rather than a rejection,
* so this is the only thing that distinguishes "not yet" from "never".
*/
const HANDSHAKE_DEADLINE_MS = 12_000

/**
* Message 2 of Noise_IK: the responder's ephemeral public key and one AEAD
* tag over an empty payload. Fixed by the pattern, so anything else on the
* wire is somebody else's frame.
*/
const MESSAGE_2_BYTES = DH_BYTES + TAG_BYTES

export interface NoiseCarrierOptions {
/** The transport to wrap. Its lifetime becomes ours. */
inner: Carrier
Expand Down Expand Up @@ -57,6 +72,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
#closed = false
/** True once message 1 is out and we are waiting for the reply. */
#awaitingReply = false
#deadline: ReturnType<typeof setTimeout> | undefined
#log: ((line: string) => void) | undefined

/** Kept so each reconnection can start a fresh handshake from the same input. */
#seed: { staticKey: StaticKey | KeyPair; remoteStatic: Uint8Array; prologue?: Uint8Array }
Expand Down Expand Up @@ -153,6 +170,16 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
this.#awaitingReply = true
this.#setStatus({ phase: 'connecting' })

// A box that refuses a handshake answers with silence, on purpose: a
// reply would confirm a box is on this handle. So silence needs its own
// ending, or a revoked phone sits on "Reaching your box" forever with the
// socket wide open and no reconnect ever firing.
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 👍 / 👎.

}, HANDSHAKE_DEADLINE_MS)

this.#handshake
.writeMessage(this.#payload)
.then((msg) => this.#inner.send(msg))
Expand All @@ -163,7 +190,15 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
if (this.#closed) return

if (this.#awaitingReply) {
this.#completeHandshake(bytes)
// Only something the right shape is offered to the handshake.
//
// The relay broadcasts the box's frames to every stream in the room, so
// a second phone in the same house starts its handshake into a running
// 1 Hz telemetry stream. Those frames are not message 2, and feeding
// them to readMessage used to kill the carrier outright — a household
// where the first phone works and the second never can. It also handed
// anyone who can write to the socket a one-packet kill switch.
if (bytes.length === MESSAGE_2_BYTES) this.#completeHandshake(bytes)
return
}

Expand Down Expand Up @@ -191,10 +226,13 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
this.#setStatus({ phase: 'open', sinceMs: Date.now() })
})
.catch((err) => {
// The commonest cause is the pinned key not matching what answered —
// which is exactly the check working. Not retryable: dialling again
// would meet the same wrong peer.
this.#fail(err instanceof NoiseError ? err.message : 'handshake rejected', false)
// Still not fatal, even at the right length: on a shared room another
// phone's transport frame can match by coincidence. A frame that does
// not open is a frame addressed to somebody else — drop it and keep
// waiting. The deadline below is what ends a handshake that is truly
// going nowhere, and it ends it retryably.
if (this.#closed || this.#handshake !== handshake) return
this.#log?.(err instanceof NoiseError ? err.message : 'handshake frame ignored')
Comment on lines +234 to +235

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

})
}

Expand Down
14 changes: 13 additions & 1 deletion src/lib/identity/pairing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,22 @@ async function storeSite(site: PairedSite): Promise<void> {
}
await database.put('sites', row)

}

/**
* Point the app at a site.
*
* Separate from storing it, on purpose. Storing a site is a fact; making it
* the site the app shows and controls is a decision, and it used to happen as
* a side effect of the fact — so any link that got as far as storing a row
* also silently became this phone's home. The caller makes that decision
* after the user has agreed to it.
*/
export function setCurrentSite(siteId: string): void {
// The inline boot script reads this before the bundle is parsed, which is
// what lets a cold start paint cached readings in the first frame.
try {
localStorage.setItem('ftw.site', site.siteId)
localStorage.setItem('ftw.site', siteId)
} catch {
// Blocked storage costs a slower start, not a broken pairing.
}
Expand Down
76 changes: 72 additions & 4 deletions src/views/Pair.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,52 @@

onDestroy(() => handle?.stop())

// Arrived with a link: pair straight away. Nothing to scan, and asking the
// user to press a button before doing what they already asked for is one
// tap too many.
/**
* A link is an offer, never an instruction.
*
* This used to pair the moment a fragment arrived. 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
* again. On a device without PRF it cost the owner not one tap.
*
* So the fragment is parsed and shown, and nothing is trusted until
* someone says so. Scanning a code with the camera is a deliberate act
* already, so that path still pairs on the spot.
*/
let offered = $state<{ fragment: string; fingerprint: string } | null>(null)

$effect(() => {
if (fragment && stage === 'intro') void pair(fragment)
if (!fragment || stage !== 'intro') return
void (async () => {
const { parseEnrollmentFragment } = await import('$lib/identity/enrollment')
try {
const enrollment = parseEnrollmentFragment(fragment)
offered = { fragment, fingerprint: await fingerprintOf(enrollment.boxStaticPublic) }
} catch {
stage = 'error'
message = 'That link is not an FTW pairing code.'
}
})()
})

/**
* A short, stable name for a box key.
*
* Six hex characters of its digest. Not a security control on its own —
* nobody memorises it — but it makes two different boxes visibly
* different, which is what a person needs to notice that the box being
* offered is not the one on their wall.
*/
async function fingerprintOf(key: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', key as BufferSource)
return Array.from(new Uint8Array(digest).subarray(0, 3))
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
.toUpperCase()
}

async function startScan() {
stage = 'scanning'
message = ''
Expand Down Expand Up @@ -125,6 +164,35 @@
{:else if stage === 'pairing'}
<h1>Connecting</h1>
<p>Confirming it's really your box.</p>
{:else if offered}
<!-- A link arrived. What it points at is shown before anything is
trusted, and switching an already-paired app is named as what it is
rather than happening quietly underneath. -->
<h1>{known ? 'Connect to a different box?' : 'Connect this box?'}</h1>
<p>
This link points at box <span class="num">{offered.fingerprint}</span>.
{#if known}
Connecting it replaces {known.label} as the home this app shows and
controls. Your key for {known.label} stays on this phone.
{:else}
Check it matches the code on your box before continuing.
{/if}
</p>

{#if message}
<p class="problem">{message}</p>
{/if}

<button class="primary" onclick={() => void pair(offered!.fragment)}>
{known ? `Connect ${offered.fingerprint}` : 'Connect this box'}
</button>
<button
class="quiet"
onclick={() => {
offered = null
history.replaceState(null, '', '/')
}}>Not now</button
>
{:else}
<h1>{known ? 'Welcome back' : 'Connect your box'}</h1>
<p>
Expand Down
64 changes: 64 additions & 0 deletions src/views/Pair.svelte.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { describe, it, expect, vi } from 'vitest'
import 'fake-indexeddb/auto'
import { render, screen } from '@testing-library/svelte'
import Pair from './Pair.svelte'
import { buildEnrollmentUrl } from '$lib/identity/enrollment'

/* A pairing link must never pair by itself.
*
* A link is something anyone can send — by SMS, by email, on a sticker over
* the real QR. This screen used to pair the moment a fragment arrived, 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 the physical code. On a device
* without PRF it cost the owner not one tap.
*/

const ATTACKER_KEY = new Uint8Array(32).fill(0xbb)

function fragmentFor(key: Uint8Array): string {
const url = buildEnrollmentUrl({
boxStaticPublic: key,
pairingCode: new Uint8Array(16).fill(1),
rendezvousSecret: new Uint8Array(32).fill(2),
lanHint: '',
})
return '#' + url.split('#')[1]
}

describe('a pairing link that arrives on its own', () => {
it('is shown as an offer and pairs nothing until someone agrees', async () => {
const onPaired = vi.fn()
render(Pair, { props: { fragment: fragmentFor(ATTACKER_KEY), onPaired } })

// The decisive assertion: nothing was paired by the mere arrival of a link.
await new Promise((r) => setTimeout(r, 50))
expect(onPaired, 'a link paired the app without being asked').not.toHaveBeenCalled()

// And the user is told what they would be trusting, by name.
const heading = await screen.findByRole('heading')
expect(heading.textContent).toMatch(/connect this box\?/i)
expect(await screen.findByRole('button', { name: /connect this box/i })).toBeTruthy()
expect(await screen.findByRole('button', { name: /not now/i })).toBeTruthy()
})

it('names the box it points at, so two boxes look different', async () => {
render(Pair, { props: { fragment: fragmentFor(ATTACKER_KEY), onPaired: vi.fn() } })

// Six hex characters of the key's digest. Nobody memorises it, but it is
// what makes "this is not the box on my wall" noticeable at all.
const body = document.body.textContent ?? ''
await vi.waitFor(() => expect(body.length).toBeGreaterThan(0))
await new Promise((r) => setTimeout(r, 50))
expect(document.body.textContent).toMatch(/[0-9A-F]{6}/)
})

it('refuses a link that is not an FTW code, without pairing anything', async () => {
const onPaired = vi.fn()
render(Pair, { props: { fragment: '#not-a-pairing-code', onPaired } })

await new Promise((r) => setTimeout(r, 50))
expect(onPaired).not.toHaveBeenCalled()
expect(document.body.textContent).toMatch(/not an FTW pairing code/i)
})
})