From 7ee3ea241b315b13720e4867647213d7bec971c0 Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Sat, 17 Oct 2026 08:00:49 +0200
Subject: [PATCH 1/2] feat(client): connect through the FTW Cloud tunnel
Signed-off-by: Fredrik Ahlgren
---
.changeset/cloud-tunnel.md | 5 +
README.md | 47 ++--
docs/architecture.md | 44 ++--
docs/protocol.md | 7 +
public/_headers | 2 +-
src/App.svelte.test.ts | 10 +-
src/lib/carrier/carrier.ts | 3 +
src/lib/carrier/cloud.test.ts | 149 ++++++++++++
src/lib/carrier/cloud.ts | 296 ++++++++++++++++++++++++
src/lib/carrier/noise.ts | 2 +
src/lib/crypto/transport.test.ts | 7 +
src/lib/crypto/transport.ts | 7 +-
src/lib/identity/enrollment.ts | 2 +-
src/lib/identity/escrow.ts | 2 +
src/lib/identity/pairing.ts | 8 +
src/lib/identity/recovery-blob.test.ts | 10 +
src/lib/identity/recovery-blob.ts | 19 +-
src/lib/protocol/cloud.test.ts | 56 +++++
src/lib/protocol/cloud.ts | 83 +++++++
src/lib/protocol/contract.ts | 3 +
src/lib/protocol/messages.ts | 2 +
src/lib/protocol/types.ts | 2 +-
src/lib/sim/box.ts | 8 +-
src/lib/state/connect.test.ts | 69 ++++++
src/lib/state/connect.ts | 64 +++--
src/lib/store/db.ts | 4 +
src/lib/ui/FreshnessBand.svelte | 11 +-
src/lib/ui/FreshnessBand.svelte.test.ts | 16 ++
src/views/Pair.svelte | 5 +-
src/views/Pair.svelte.test.ts | 1 +
tests/csp-contract.test.ts | 5 +-
tests/entry-bundle.test.ts | 3 +
tests/escrow-e2e.test.ts | 1 +
tests/fixtures/NOTICE | 5 +
tests/fixtures/cloud-app-vectors.json | 78 +++++++
tests/live-cloud.test.ts | 62 +++++
tests/support/cloud-harness.ts | 108 +++++++++
37 files changed, 1113 insertions(+), 93 deletions(-)
create mode 100644 .changeset/cloud-tunnel.md
create mode 100644 src/lib/carrier/cloud.test.ts
create mode 100644 src/lib/carrier/cloud.ts
create mode 100644 src/lib/protocol/cloud.test.ts
create mode 100644 src/lib/protocol/cloud.ts
create mode 100644 src/lib/state/connect.test.ts
create mode 100644 tests/fixtures/NOTICE
create mode 100644 tests/fixtures/cloud-app-vectors.json
create mode 100644 tests/live-cloud.test.ts
create mode 100644 tests/support/cloud-harness.ts
diff --git a/.changeset/cloud-tunnel.md b/.changeset/cloud-tunnel.md
new file mode 100644
index 0000000..d005a38
--- /dev/null
+++ b/.changeset/cloud-tunnel.md
@@ -0,0 +1,5 @@
+---
+"ftw-webapp": minor
+---
+
+Connect through FTW Cloud with the same Noise tunnel as iPhone. Existing web installations pair again with a current QR. There is no relay fallback.
diff --git a/README.md b/README.md
index 521de6b..51a5f02 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@ Your home's energy, from wherever you are.
This is FTW's installable web client. It talks to the FTW box in your home,
which is the authority on your
-energy system. Sourceful's relay carries the traffic and cannot read it.
+energy system. Its encrypted session runs through FTW Cloud, as on iPhone.
## Product direction and contributions
@@ -22,24 +22,12 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
## The shape of it
```
-┌──────────────────────────────────────────────────────┐
-│ FTW webapp │
-│ UI · local cache · encrypted keys · passkey │
-└──────────────┬─────────────────────┬─────────────────┘
- │ │
- LAN (later) encrypted relay
- │ │
- ▼ ▼
-┌──────────────────────┐ ┌──────────────────────────┐
-│ FTW at home │ │ Sourceful cloud plane │
-│ │ │ │
-│ control · safety │ │ serves this bundle │
-│ full history │ │ blind connection relay │
-│ devices · optimizer │ │ opaque site presence │
-│ identity · grants │ │ │
-└──────────────────────┘ └──────────────────────────┘
+webapp → Noise to FTW Cloud → tun → Noise to the home box
+ │
+ state, safety, control
```
+
Three claims hold this together:
**The box is the record.** It measures, controls, stores the full history and
@@ -50,15 +38,16 @@ cached projection of it, never the original.
on screen in the first frame, timestamped honestly, while fresher data is
fetched behind them. There is no spinner on a white background.
-**The relay is blind.** Sourceful serves this bundle, and its relay carries
-encrypted frames and knows which opaque handle is online. The relay cannot read
-a watt, a device name or a command. This is enforced by the protocol, and a CI
-test dumps the relay's database and fails if anything recognisable is in it.
+**The box session stays encrypted.** FTW Cloud cannot read or forge the
+session inside `tun`. It sees a stable home id, the box's address and traffic
+timing. Remote access works with the readable copy off. If an owner enables
+that copy, Sourceful can read the data it contains; this web client still
+reads through its own box session. [ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
+and the [Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md)
+describe that boundary. The relay and escrow remain blind.
-This app does not use FTW Cloud. FTW Cloud is a separate service for the
-iPhone app, proposed and not yet built. If a box owner turns it on, Sourceful
-can read what the owner enabled; it never sends control.
-[ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md) records it, and the relay and escrow stay blind either way.
+Pairing needs a current box and QR v3. Existing web installations must pair
+again. The passkey origin stays `app.ftw.energy`. There is no relay fallback.
If a household asks, the escrow will also hold one sealed copy of its home, so a
new phone gets back in with a passkey instead of a trip to the box. Sourceful
@@ -75,8 +64,8 @@ record earlier delivery stages; use the current implementation, tests and
release evidence to establish what a particular box and app support.
The product vision is a target, not an availability list.
-Not yet built: cloud MCP agent access and the WebRTC LAN carrier. The vision
-sets the direction for agent access; the architecture records the LAN work.
+Not yet built in this web client: the WebRTC LAN carrier, Cloud push for
+browsers and reading the optional signed copy. This change carries the box session.
## Running it
@@ -100,14 +89,14 @@ Runs type checks, tests and a production build. Green before every handoff.
| Path | What lives there |
|---|---|
| `src/lib/protocol` | Wire format, capability negotiation, snapshot and deltas |
-| `src/lib/carrier` | The carrier abstraction: relay, cache, and later WebRTC |
+| `src/lib/carrier` | The Cloud tunnel and local cache; relay code remains for server tests |
| `src/lib/crypto` | Noise IK, the passkey key hierarchy |
| `src/lib/identity` | Enrollment, principals, grants |
| `src/lib/store` | IndexedDB — the local projection |
| `src/lib/ui` | Components |
| `src/views` | Screens |
| `contract/` | The shared registry: scopes, capabilities, error codes, field ids |
-| `relay/` | The blind relay itself — the server this app's frames pass through |
+| `relay/` | The old blind relay, pending service retirement |
| `escrow/` | The sealed escrow — one slot per household, and it cannot open any of them |
`contract/registry.yaml` is the single source for every name shared with the
diff --git a/docs/architecture.md b/docs/architecture.md
index 759ecbe..e906491 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -22,7 +22,7 @@ and temporary control that needs renewal.
## The model
-**Edge-authoritative, client-local, cloud-blind.**
+**The box decides; the client caches; the tunnel stays encrypted.**
The box at home is the system of record. It measures, controls, stores the
full history, holds identity and decides what is safe. It works with the
@@ -32,14 +32,24 @@ This app is a cached projection of the box. It renders from its own store
first and replaces that with fresher data when a carrier answers. It expresses
intent; it never decides.
-Sourceful's cloud serves this bundle and relays encrypted frames. On that path
-it knows that an opaque handle is online and roughly how much traffic passed.
-It cannot read a watt, a device name or a command.
-
-FTW Cloud is a separate service for the iPhone app, proposed in
-[srcfl/ftw ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md) and not yet built. If a box owner turns it on, it
-holds a readable copy of what the owner enabled, and it never sends control.
-This app does not use it. The relay and escrow stay blind either way.
+The web app and iPhone reach the same FTW Cloud service. The browser opens
+`/v1/app?home=…` over WebSocket and runs Noise IK to the service's pinned key.
+Its own Noise session with the box rides inside that session as `tun`. The
+box's first handshake may ride in `now.tun`, as on iPhone. Cloud cannot read
+or forge that inner session. It sees the stable home id, addresses and timing.
+
+The readable copy is an owner opt-in, separate from remote access. Sourceful
+can read that copy when enabled; this client uses the tunnel for reads,
+history and commands, and does not display the copy's envelopes. See
+[srcfl/ftw ADR 0008](https://github.com/srcfl/ftw/blob/master/docs/adr/0008-ftw-cloud.md)
+and the normative [Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md).
+The relay and escrow remain blind.
+
+Pairing requires QR v3, with the Cloud home id. Existing installations without
+it pair again; no bridge or relay fallback is built. The origin and RP ID stay
+`app.ftw.energy`. A visible tab holds one Web Lock for its home's connection;
+a second tab waits. A hidden tab closes its session and releases the lock.
+A replaced session does not try to take the device slot back in a loop.
A separate daily fleet-report endpoint is the stated exception. Unless the box
owner opts out, it receives six coarse labels with no box id and reduces them
@@ -58,7 +68,7 @@ Behind a common session abstraction sit three carriers:
| Carrier | Role | Status |
|---|---|---|
-| Relay | Default, and the only remote path | v1 |
+| Cloud tunnel | The remote box session, over WebSocket | current |
| Cache | Read-only view when nothing reaches the box | v1 |
| WebRTC DataChannel | Opportunistic LAN shortcut | after measurement |
@@ -223,18 +233,18 @@ cover FTW Cloud, which can read what a box owner turns on;
### What is not yet true
An adversarial review of the crypto, identity and relay layers found the
-content half of that promise sound and the metadata half not. These are open,
-and listed here rather than in a tracker because a privacy claim with quiet
-exceptions is worse than no claim.
+content half of that promise sound and the metadata half not. The relay
+findings below record that earlier path; the web client now uses the Cloud
+tunnel described above.
- **The rendezvous secret cannot yet be rotated from a session.** The v2 QR
payload carries one, so the handle is no longer a hash of a key that never
changes, but replacing it still means scanning a new code. The field exists
so that a `rendezvous.rotate` command can arrive later without another
payload version.
-- **The box authenticates nobody.** Noise IK proves the box's identity to the
- app, not the reverse. The pairing code that would close it is parsed and
- then unused. So today the guarantee is one-way.
+- **Device admission now runs at both ends.** Cloud admits the device key
+ from the signed roster, or once from a pending pairing proof. The box
+ checks the actual code inside its own Noise handshake.
- **The app-to-box direction has constant frame size but not constant
cadence.** Frames leave only on user action, so the relay can see when
someone opened the app and how many times — a much thinner channel than the
@@ -290,7 +300,7 @@ remove it. Its claim is:
> beside it.**
The copy is the recovery blob: the device's Noise static and, per home, the box
-key and the rendezvous secret. It is AES-GCM under a key derived from PRF
+key, Cloud home id and the QR's rendezvous secret. It is AES-GCM under a key derived from PRF
output and padded to a fixed 512 bytes, so its length says nothing about how
many homes are inside. The id is HKDF over the same PRF secret under a
different `info` string — a sibling of the key, never derived from it — so a
diff --git a/docs/protocol.md b/docs/protocol.md
index f11a741..746a4b6 100644
--- a/docs/protocol.md
+++ b/docs/protocol.md
@@ -51,6 +51,13 @@ fragments. Cadence changes only after an explicit visibility `sub`: 1 Hz while
the document is visible and 0.2 Hz while it is hidden. It never changes with
the telemetry it carries. If the box has nothing to say it sends `tick` anyway.
+Through FTW Cloud's tunnel the box sends a padded frame when a reading lands,
+plus a beat while quiet. The relay's fixed cadence applies only to the relay.
+The tunnel's envelope, limits and session ids follow Core's
+[Cloud protocol](https://github.com/srcfl/ftw/blob/master/docs/protocol-cloud.md#tunnel).
+The browser uses the same Noise and CBOR as iPhone over the service's existing
+WebSocket endpoint; its custom QUIC transport is not a WebTransport endpoint.
+
This constancy is a privacy control, not a performance choice. A delta that
does not fit sends the highest-priority fields and sets `TRUNC`; the rest
follows on the next tick or as a fresh snapshot on the bulk lane. It never
diff --git a/public/_headers b/public/_headers
index 9bb9736..a110e01 100644
--- a/public/_headers
+++ b/public/_headers
@@ -16,7 +16,7 @@
# was once missing from this list, and the recovery flow's fetch died in
# the browser as "could not be reached" while every probe from outside
# passed; tests/csp-contract.test.ts now holds the list to the code.
- Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://relay.ftw.energy https://escrow.ftw.energy; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; manifest-src 'self'
+ Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' wss://cloud.ftw.energy https://escrow.ftw.energy; font-src 'self'; object-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; manifest-src 'self'
Referrer-Policy: no-referrer
X-Content-Type-Options: nosniff
# The camera is used to scan the pairing code and nothing else asks for
diff --git a/src/App.svelte.test.ts b/src/App.svelte.test.ts
index 0f71542..7da82a1 100644
--- a/src/App.svelte.test.ts
+++ b/src/App.svelte.test.ts
@@ -149,6 +149,7 @@ async function pairPhone(siteId: string) {
const row: StoredSite = {
siteId,
label: 'Home',
+ cloudHome: 'fjzyr67buzpyncqmjliwgezvde',
boxStaticKey: new Uint8Array(32).fill(7),
rendezvousSecret: new Uint8Array(32).fill(9),
addedAtMs: Date.UTC(2026, 6, 1),
@@ -222,6 +223,7 @@ describe('switching homes while the first carrier is still being built', () => {
await pairPhone(HOME_A)
const offered = buildEnrollmentUrl({
+ home: 'fjzyr67buzpyncqmjliwgezvde',
boxStaticPublic: new Uint8Array(32).fill(8),
pairingCode: new Uint8Array(16).fill(4),
rendezvousSecret: new Uint8Array(32).fill(5),
@@ -708,7 +710,7 @@ describe('the way back, for a phone that cannot get in', () => {
siteId: HOME_ID,
label: 'Home',
boxStaticKey: new Uint8Array(32).fill(7),
- ...(opts.rendezvous === false ? {} : { rendezvousSecret: new Uint8Array(32).fill(9) }),
+ ...(opts.rendezvous === false ? {} : { cloudHome: 'fjzyr67buzpyncqmjliwgezvde', rendezvousSecret: new Uint8Array(32).fill(9) }),
addedAtMs: Date.UTC(2026, 6, 1),
lastSeenAtMs: Date.now(),
}
@@ -850,13 +852,11 @@ describe('the way back, for a phone that cannot get in', () => {
})
it('reaches it from a row that cannot find its box at all', async () => {
- // Paired before the rendezvous secret existed, so this phone cannot derive
- // a handle and there is nothing to connect to. Only a fresh scan or the
- // sealed copy carries the missing bytes.
+ // A row from before Cloud has no home id. The owner pairs again.
await homeOnDisk({ rendezvous: false })
render(App)
- await vi.waitFor(() => expect(said()).toMatch(/before this app could reach it privately/i), {
+ await vi.waitFor(() => expect(said()).toMatch(/pair this home again/i), {
timeout: 4_000,
})
await takeTheWayBack(/get this phone back in/i)
diff --git a/src/lib/carrier/carrier.ts b/src/lib/carrier/carrier.ts
index e374f81..9898c1a 100644
--- a/src/lib/carrier/carrier.ts
+++ b/src/lib/carrier/carrier.ts
@@ -34,6 +34,9 @@ export interface Carrier {
send(frame: Uint8Array): void
+ /** Draw a new tunnel id before each box handshake. Other carriers need none. */
+ beginSession?(): void
+
/** Returns an unsubscribe function. */
onFrame(handler: (frame: Uint8Array) => void): () => void
onStatus(handler: (status: CarrierStatus) => void): () => void
diff --git a/src/lib/carrier/cloud.test.ts b/src/lib/carrier/cloud.test.ts
new file mode 100644
index 0000000..e05e1da
--- /dev/null
+++ b/src/lib/carrier/cloud.test.ts
@@ -0,0 +1,149 @@
+import { describe, it, expect, afterEach, vi } from 'vitest'
+import { CloudTunnelCarrier } from './cloud'
+import { NoiseCarrier } from './noise'
+import { Session } from '$lib/protocol/session'
+import { OP_SET_MODE } from '$lib/protocol/messages'
+import { cloudHarness, HOME } from '../../../tests/support/cloud-harness'
+
+const cleanup: (() => void)[] = []
+afterEach(() => { cleanup.splice(0).forEach(close => close()); vi.useRealTimers(); vi.unstubAllGlobals(); vi.restoreAllMocks() })
+const settle = async () => { for (let i = 0; i < 25; i++) await new Promise(r => setTimeout(r, 1)) }
+
+function chain() {
+ const h = cloudHarness()
+ const inner = new CloudTunnelCarrier({ home: HOME, staticKey: h.device, pairingCode: h.code,
+ WebSocketImpl: h.WebSocketImpl, serviceKeys: [h.service.publicKey], random: () => 0.5 })
+ const carrier = new NoiseCarrier({ inner, staticKey: h.device, remoteStatic: h.boxKey.publicKey,
+ prologue: new Uint8Array([...new TextEncoder().encode('ftw.session.v1:'), ...h.boxKey.publicKey]), handshakePayload: h.code })
+ const session = new Session({ build: 'cloud-test' })
+ session.connect(carrier)
+ cleanup.push(() => { session.close(); h.close() })
+ return { ...h, inner, carrier, session }
+}
+
+describe('the box session through FTW Cloud, without a relay', () => {
+ it('pairs, streams and answers a command with the readable copy off', async () => {
+ const h = chain()
+ await settle()
+ expect(h.errors).toEqual([])
+ expect(h.session.state.phase).toBe('streaming')
+ expect(h.session.state.carrier).toBe('tunnel')
+ expect(h.sockets).toHaveLength(1)
+ expect(h.sockets[0]!.url).toBe(`wss://cloud.ftw.energy/v1/app?home=${HOME}`)
+ expect(h.now[0]!['pair']).toBeInstanceOf(Uint8Array)
+ expect(h.now[0]!['tun']).toBeDefined()
+ const command = h.session.command(OP_SET_MODE, { mode: 'idle' })
+ const result = await command.promise
+ expect(result.state).toBe('applied')
+ const fields = new Map(h.session.state.fields)
+ h.sockets[0]!.reading()
+ await settle()
+ expect(h.session.state.fields).toEqual(fields)
+ expect(h.errors).toEqual([])
+ })
+
+ it('keeps API reads and history on the same box session', async () => {
+ const h = chain(); await settle()
+ const response = await h.session.api({ method: 'GET', path: '/api/status' })
+ expect(response.status).toBe(200)
+ let chunks = 0
+ await h.session.history({ series: ['grid_w'], res: '5m', fromMs: Date.now() - 3_600_000, toMs: Date.now() }, () => { chunks++ })
+ expect(chunks).toBeGreaterThan(0)
+ expect(h.sockets).toHaveLength(1)
+ expect(h.errors).toEqual([])
+ })
+
+ it('tries the second pinned service key without losing the box handshake', async () => {
+ const h = cloudHarness()
+ const inner = new CloudTunnelCarrier({ home: HOME, staticKey: h.device, pairingCode: h.code,
+ WebSocketImpl: h.WebSocketImpl, serviceKeys: [new Uint8Array(32).fill(5), h.service.publicKey] })
+ const carrier = new NoiseCarrier({ inner, staticKey: h.device, remoteStatic: h.boxKey.publicKey,
+ prologue: new Uint8Array([...new TextEncoder().encode('ftw.session.v1:'), ...h.boxKey.publicKey]), handshakePayload: h.code })
+ const session = new Session({ build: 'rotation' })
+ cleanup.push(() => { session.close(); h.close() })
+ session.connect(carrier)
+ await settle()
+ expect(h.errors).toEqual([])
+ expect(h.sockets).toHaveLength(2)
+ expect(session.state.phase).toBe('streaming')
+ expect(h.now[0]!['tun']).toBeDefined()
+ })
+
+ it('uses a new tunnel id after wake and ignores a late end from the old session', async () => {
+ const h = chain(); await settle()
+ const sid = h.sockets[0]!.sid
+ h.carrier.wake(); await settle()
+ expect(h.sockets).toHaveLength(2)
+ expect(h.sockets[0]!.closed).toBe(true)
+ expect(h.sockets[1]!.sid).not.toBe(sid)
+ h.sockets[1]!.tunnel({ sid, d: new Uint8Array() })
+ await settle()
+ expect(h.session.state.phase).toBe('streaming')
+ expect(h.errors).toEqual([])
+ })
+
+ it('backs off a refused device and never dials a relay', async () => {
+ const h = chain(); h.refuse(4403); await settle()
+ expect(h.inner.status.phase).toBe('closed')
+ expect(h.sockets).toHaveLength(1)
+ expect(h.errors).toEqual([])
+ })
+
+ it('does not take a replaced device session back in a retry loop', async () => {
+ const h = chain(); await settle()
+ h.sockets[0]!.end(4408)
+ vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(300_000)
+ expect(h.sockets).toHaveLength(1)
+ expect(h.inner.status).toMatchObject({ phase: 'closed', retryable: false })
+ })
+
+ it('bounds repeated tunnel endings and never replays an old box frame', async () => {
+ const h = chain(); await settle()
+ const socket = h.sockets[0]!
+ const before = h.forwarded.length
+ vi.useFakeTimers()
+ socket.tunnel({ sid: socket.sid, d: new Uint8Array() })
+ await vi.advanceTimersByTimeAsync(0)
+ expect(h.inner.status.phase).toBe('closed')
+ expect(h.forwarded).toHaveLength(before)
+ })
+
+ it('shares the device slot across tabs through Web Locks', async () => {
+ let tail = Promise.resolve()
+ const locks = { request: (_name: string, options: { signal: AbortSignal }, run: () => Promise) => {
+ const next = tail.then(async () => {
+ if (options.signal.aborted) throw new DOMException('cancelled', 'AbortError')
+ await run()
+ })
+ tail = next.catch(() => {})
+ return next
+ } }
+ vi.stubGlobal('navigator', { locks })
+ const first = chain()
+ const second = chain()
+ await settle()
+ expect(first.session.state.phase).toBe('streaming')
+ expect(second.sockets).toHaveLength(0)
+ first.session.close()
+ await settle()
+ expect(second.session.state.phase).toBe('streaming')
+ expect(second.sockets).toHaveLength(1)
+ })
+
+ it('closes in the background and reconnects without replaying a command', async () => {
+ const h = chain(); await settle()
+ h.silent(true)
+ const pending = h.session.command(OP_SET_MODE, { mode: 'idle' }).promise.catch(error => error)
+ const visibility = vi.spyOn(document, 'visibilityState', 'get').mockReturnValue('hidden')
+ document.dispatchEvent(new Event('visibilitychange'))
+ expect(h.sockets[0]!.closed).toBe(true)
+ expect(await pending).toBeInstanceOf(Error)
+ h.silent(false)
+ visibility.mockReturnValue('visible')
+ document.dispatchEvent(new Event('visibilitychange'))
+ await settle()
+ expect(h.sockets).toHaveLength(2)
+ expect(h.session.state.phase).toBe('streaming')
+ expect(h.errors).toEqual([])
+ })
+})
diff --git a/src/lib/carrier/cloud.ts b/src/lib/carrier/cloud.ts
new file mode 100644
index 0000000..16f9802
--- /dev/null
+++ b/src/lib/carrier/cloud.ts
@@ -0,0 +1,296 @@
+import { CarrierBase, type Carrier, type CarrierStatus } from './carrier'
+import { HandshakeState, type StaticKey, type KeyPair } from '$lib/crypto/noise'
+import { NoiseTransport } from '$lib/crypto/transport'
+import { decodeBase64url } from '$lib/identity/base64url'
+import {
+ CLOUD_URL, CLOUD_KEYS, TUN_DATA_MAX, cloudPrologue, pairingHash,
+ unsigned, cloudFrame, readCloudFrame, readCloudSnapshot, readTunnel,
+} from '$lib/protocol/cloud'
+
+export interface CloudOptions {
+ home: string
+ staticKey: StaticKey | KeyPair
+ pairingCode?: Uint8Array
+ /** Injected by tests and local development, never read from a QR or server. */
+ url?: string
+ serviceKeys?: Uint8Array[]
+ WebSocketImpl?: typeof WebSocket
+ random?: () => number
+}
+
+/** One Cloud socket carries the device's unchanged Noise session with its box. */
+export class CloudTunnelCarrier extends CarrierBase implements Carrier {
+ readonly kind = 'tunnel' as const
+ readonly rttMs = null
+ #status: CarrierStatus = { phase: 'connecting' }
+ #opts: CloudOptions
+ #keys: Uint8Array[]
+ #WS: typeof WebSocket
+ #random: () => number
+ #ws: WebSocket | null = null
+ #socketOpen = false
+ #transport: NoiseTransport | null = null
+ #handshake: HandshakeState | null = null
+ #generation = 0
+ #keyIndex = 0
+ #sid = 0
+ #first: Uint8Array | null = null
+ #riding: { sid: number; d: Uint8Array } | null = null
+ #deadline: ReturnType | undefined
+ #retry: ReturnType | undefined
+ #attempt = 0
+ #refusals = 0
+ #closed = false
+ #frames = 0
+ #carryingAt = 0
+ #lockRequest: AbortController | null = null
+ #release: (() => void) | null = null
+
+ constructor(opts: CloudOptions) {
+ super()
+ cloudPrologue(opts.home)
+ this.#opts = opts
+ this.#keys = opts.serviceKeys ?? CLOUD_KEYS.map(decodeBase64url)
+ if (!this.#keys.length || this.#keys.some(k => k.length !== 32)) throw new Error('missing Cloud key')
+ this.#WS = opts.WebSocketImpl ?? globalThis.WebSocket
+ this.#random = opts.random ?? Math.random
+ globalThis.addEventListener?.('online', this.wake)
+ globalThis.document?.addEventListener('visibilitychange', this.#visibility)
+ this.#acquire()
+ }
+
+ get status(): CarrierStatus { return this.#status }
+
+ beginSession(): void {
+ if (this.#sid && this.#transport) this.#sendTun(new Uint8Array())
+ this.#sid = crypto.getRandomValues(new Uint32Array(1))[0]! || 1
+ this.#first = null
+ this.#frames = 0
+ this.#carryingAt = 0
+ }
+
+ send(frame: Uint8Array): void {
+ if (this.#closed || this.#status.phase !== 'open' || frame.length > TUN_DATA_MAX) return
+ if (this.#transport) this.#sendTun(frame)
+ else if (this.#first === null) {
+ this.#first = frame.slice()
+ if (this.#socketOpen) void this.#message1(this.#generation)
+ }
+ }
+
+ wake = (): void => {
+ if (this.#closed || globalThis.document?.visibilityState === 'hidden') return
+ if (this.#status.phase === 'open' && !this.#transport) return
+ this.#attempt = this.#refusals = this.#keyIndex = 0
+ clearTimeout(this.#retry)
+ if (this.#release || !globalThis.navigator?.locks) this.#dial()
+ else this.#acquire()
+ }
+
+ close(reason = 'closed by client'): void {
+ if (this.#closed) return
+ if (this.#sid && this.#transport) this.#sendTun(new Uint8Array())
+ this.#closed = true
+ clearTimeout(this.#retry)
+ this.#drop()
+ this.#unlock()
+ globalThis.removeEventListener?.('online', this.wake)
+ globalThis.document?.removeEventListener('visibilitychange', this.#visibility)
+ this.#setStatus({ phase: 'closed', reason, retryable: false })
+ this.clearHandlers()
+ }
+
+ #visibility = (): void => {
+ if (globalThis.document?.visibilityState !== 'hidden') { this.wake(); return }
+ if (this.#sid && this.#transport) this.#sendTun(new Uint8Array())
+ clearTimeout(this.#retry)
+ this.#drop()
+ this.#unlock()
+ this.#setStatus({ phase: 'closed', reason: 'app in background', retryable: true })
+ }
+
+ #acquire(): void {
+ if (this.#closed || this.#lockRequest || globalThis.document?.visibilityState === 'hidden') return
+ const locks = globalThis.navigator?.locks
+ if (!locks) { this.#dial(); return }
+ const request = new AbortController()
+ this.#lockRequest = request
+ // The roster allows one Cloud session per device. A second tab waits
+ // rather than making both tabs take that session back in a retry loop.
+ void locks.request(`ftw-cloud:${this.#opts.home}`, { signal: request.signal }, async () => {
+ if (this.#closed || request.signal.aborted) return
+ await new Promise(resolve => {
+ this.#release = resolve
+ this.#dial()
+ })
+ this.#release = null
+ }).catch((err: unknown) => {
+ if (!(err instanceof DOMException && err.name === 'AbortError')) {
+ this.#setStatus({ phase: 'closed', reason: 'could not open this home', retryable: false })
+ }
+ }).finally(() => { if (this.#lockRequest === request) this.#lockRequest = null })
+ }
+
+ #unlock(): void {
+ this.#lockRequest?.abort()
+ this.#lockRequest = null
+ this.#release?.()
+ this.#release = null
+ }
+
+ #dial(preserveFirst = false): void {
+ if (this.#closed) return
+ const riding = preserveFirst ? this.#riding : null
+ const first = preserveFirst ? this.#first : null
+ this.#drop()
+ this.#riding = riding
+ this.#first = first
+ if (!preserveFirst) {
+ this.#setStatus({ phase: 'connecting' })
+ // Open for the box's message 1 to wait for the outer handshake.
+ this.#setStatus({ phase: 'open', sinceMs: Date.now() })
+ }
+ const mine = this.#generation
+ const url = new URL(this.#opts.url ?? CLOUD_URL)
+ url.searchParams.set('home', this.#opts.home)
+ let ws: WebSocket
+ try { ws = new this.#WS(url.toString()) }
+ catch { this.#ended(1006, ''); return }
+ this.#ws = ws
+ ws.binaryType = 'arraybuffer'
+ let received = Promise.resolve()
+ let queuedBytes = 0
+ let queuedMessages = 0
+ ws.onopen = () => {
+ if (mine !== this.#generation) return
+ this.#socketOpen = true
+ void this.#message1(mine)
+ }
+ ws.onmessage = event => {
+ if (mine !== this.#generation) return
+ if (!(event.data instanceof ArrayBuffer) || event.data.byteLength > 65565) { this.#fail(); return }
+ const bytes = new Uint8Array(event.data)
+ // Bound work waiting behind an async handshake to 4 MiB / 256 frames.
+ if (queuedBytes + bytes.length > 4 * 1024 * 1024 || queuedMessages >= 256) { this.#fail(); return }
+ queuedBytes += bytes.length
+ queuedMessages++
+ received = received.then(async () => {
+ try { if (mine === this.#generation) await this.#receive(bytes, mine) }
+ finally { queuedBytes -= bytes.length; queuedMessages-- }
+ }).catch(() => { if (mine === this.#generation) this.#fail() })
+ }
+ ws.onerror = () => { if (mine === this.#generation) this.#ended(1006, '') }
+ ws.onclose = event => { if (mine === this.#generation) this.#ended(event.code, event.reason) }
+ this.#deadline = setTimeout(() => { if (mine === this.#generation) this.#fail() }, 10_000)
+ }
+
+ async #message1(mine: number): Promise {
+ if (this.#handshake || this.#transport || (!this.#first && !this.#riding)) return
+ try {
+ const hs = HandshakeState.initiator({ staticKey: this.#opts.staticKey,
+ remoteStatic: this.#keys[this.#keyIndex]!, prologue: cloudPrologue(this.#opts.home) })
+ this.#handshake = hs
+ if (!this.#riding && this.#first) {
+ this.#riding = { sid: this.#sid, d: this.#first }
+ this.#first = null
+ }
+ const now = unsigned('now', { hz: 0,
+ ...(this.#opts.pairingCode ? { pair: pairingHash(this.#opts.home, this.#opts.pairingCode) } : {}),
+ ...(this.#riding ? { tun: this.#riding } : {}) })
+ const msg = await hs.writeMessage(now)
+ if (mine === this.#generation) this.#ws?.send(msg as Uint8Array)
+ } catch { if (mine === this.#generation) this.#fail() }
+ }
+
+ async #receive(bytes: Uint8Array, mine: number): Promise {
+ if (!this.#transport) {
+ if (!this.#handshake) throw new Error('Cloud handshake missing')
+ const hs = this.#handshake
+ const payload = await hs.readMessage(bytes)
+ if (mine !== this.#generation) return
+ readCloudSnapshot(payload)
+ this.#transport = new NoiseTransport(hs.split())
+ this.#handshake = null
+ this.#riding = null
+ clearTimeout(this.#deadline)
+ this.#attempt = this.#refusals = 0
+ if (this.#first) {
+ const first = this.#first
+ this.#first = null
+ this.#sendTun(first)
+ }
+ return
+ }
+ const envelope = readCloudFrame(this.#transport.decrypt(bytes))
+ if (envelope.t !== 'tun') return
+ if (envelope.sig.length !== 0) throw new Error('signed tunnel envelope')
+ const tun = readTunnel(envelope.b)
+ if (tun.sid !== this.#sid) return
+ if (!tun.d.length) {
+ if (this.#carryingAt && Date.now() - this.#carryingAt >= 30_000) this.#attempt = 0
+ this.#first = this.#riding = null
+ this.#setStatus({ phase: 'closed', reason: 'the box ended the session', retryable: true })
+ this.#schedule(this.#backoff(), () => this.#setStatus({ phase: 'open', sinceMs: Date.now() }))
+ return
+ }
+ if (++this.#frames === 2) this.#carryingAt = Date.now()
+ this.emitFrame(tun.d)
+ }
+
+ #sendTun(d: Uint8Array): void {
+ if (!this.#transport || !this.#ws || !this.#sid) return
+ if (this.#ws.bufferedAmount > 4 * 1024 * 1024) { this.#fail(); return }
+ try {
+ this.#ws.send(this.#transport.encrypt(cloudFrame(unsigned('tun', { sid: this.#sid, d }))) as Uint8Array)
+ } catch { this.#fail() }
+ }
+
+ #ended(code: number, reason: string): void {
+ if (code === 4403 && !this.#transport && this.#keyIndex + 1 < this.#keys.length) {
+ this.#keyIndex++
+ this.#dial(true)
+ return
+ }
+ this.#drop()
+ this.#keyIndex = 0
+ this.#setStatus({ phase: 'closed', reason: `Cloud connection closed (${code})`, retryable: code !== 4408 })
+ if (code === 4408) return
+ if (code === 4403) {
+ const ceiling = Math.min(300_000, 15_000 * 2 ** Math.min(this.#refusals++, 5))
+ this.#schedule(ceiling / 2 + this.#random() * ceiling / 2)
+ } else if (code === 4503) {
+ const value = /^\d+$/.test(reason.trim()) ? Number(reason) : 5000
+ this.#schedule(this.#random() * Math.min(60_000, Math.max(1000, value)))
+ } else this.#schedule(this.#backoff())
+ }
+
+ #fail(): void { this.#ended(4400, '') }
+
+ #backoff(): number {
+ return this.#random() * Math.min(60_000, 500 * 2 ** Math.min(this.#attempt++, 16))
+ }
+
+ #schedule(ms: number, run = () => this.#dial()): void {
+ clearTimeout(this.#retry)
+ this.#retry = setTimeout(() => { this.#retry = undefined; if (!this.#closed) run() }, ms)
+ }
+
+ #drop(): void {
+ this.#generation++
+ clearTimeout(this.#deadline)
+ const ws = this.#ws
+ this.#ws = null
+ this.#socketOpen = false
+ if (ws) { ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null; ws.close() }
+ this.#transport?.close()
+ this.#transport = null
+ this.#handshake = null
+ this.#first = this.#riding = null
+ }
+
+ #setStatus(status: CarrierStatus): void {
+ if (this.#closed && status.phase !== 'closed') return
+ this.#status = status
+ this.emitStatus(status)
+ }
+}
diff --git a/src/lib/carrier/noise.ts b/src/lib/carrier/noise.ts
index feef216..471f5b2 100644
--- a/src/lib/carrier/noise.ts
+++ b/src/lib/carrier/noise.ts
@@ -214,6 +214,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
#beginHandshake(): void {
if (this.#closed || this.#awaitingReply || this.#transport) return
+ this.#inner.beginSession?.()
+
// A fresh handshake per connection. Reusing the old one would mint a
// second cipher pair from the same chaining key — the same key at nonce
// zero twice, which breaks ChaCha20-Poly1305 outright.
diff --git a/src/lib/crypto/transport.test.ts b/src/lib/crypto/transport.test.ts
index 2244240..0b50ade 100644
--- a/src/lib/crypto/transport.test.ts
+++ b/src/lib/crypto/transport.test.ts
@@ -91,6 +91,13 @@ describe('tampering', () => {
})
describe('replay', () => {
+ it('accepts control overtaken by a hundred bulk frames on the box’s QUIC uplink', async () => {
+ const { app, box } = await connect()
+ const control = box.encrypt(tick())
+ for (let i = 0; i < 100; i++) app.decrypt(box.encrypt(delta({ 2: i })))
+ expect(app.decrypt(control)).toEqual(tick())
+ expect(() => app.decrypt(control)).toThrow(NoiseError)
+ })
it('rejects a frame it has already accepted', async () => {
const { app, box } = await connect()
const wire = app.encrypt(tick())
diff --git a/src/lib/crypto/transport.ts b/src/lib/crypto/transport.ts
index 8270635..4e72984 100644
--- a/src/lib/crypto/transport.ts
+++ b/src/lib/crypto/transport.ts
@@ -31,11 +31,10 @@ export const TRANSPORT_OVERHEAD = SEQ_BYTES + TAG_BYTES
/**
* How far out of order a frame may arrive and still be accepted.
*
- * At 1 Hz on lane 0, 64 frames is a minute of tolerance — far beyond any
- * reordering a DataChannel produces, and small enough that the window is one
- * machine word.
+ * The box's Cloud uplink may use QUIC. Bulk can overtake control there even
+ * when this client uses WebSocket. Match Go and iOS's minimum of 1024.
*/
-export const REPLAY_WINDOW = 64
+export const REPLAY_WINDOW = 1024
const WINDOW = BigInt(REPLAY_WINDOW)
const WINDOW_MASK = (1n << WINDOW) - 1n
diff --git a/src/lib/identity/enrollment.ts b/src/lib/identity/enrollment.ts
index 8843dea..a6052ea 100644
--- a/src/lib/identity/enrollment.ts
+++ b/src/lib/identity/enrollment.ts
@@ -6,7 +6,7 @@
* v3 is v2 with the home id at FTW Cloud, base64url of its 26 characters, so a
* new phone can reach the box through FTW Cloud before any session with it
* (srcfl/ftw docs/protocol-cloud.md, "Pairing through the service"). Both are
- * read; this app does not use the home id yet.
+ * read by the codec; pairing requires v3 to reach FTW Cloud.
*
* Everything after the `#` is a URL fragment, and a fragment is never sent in
* an HTTP request. That is the whole design: the trust anchor reaches the app
diff --git a/src/lib/identity/escrow.ts b/src/lib/identity/escrow.ts
index 46b22e0..8bcb3d6 100644
--- a/src/lib/identity/escrow.ts
+++ b/src/lib/identity/escrow.ts
@@ -145,6 +145,7 @@ export async function escrowedHomes(): Promise {
.map((row) => ({
siteId: row.siteId,
label: row.label,
+ ...(row.cloudHome ? { cloudHome: row.cloudHome } : {}),
boxStaticKey: row.boxStaticKey,
rendezvousSecret: row.rendezvousSecret,
}))
@@ -441,6 +442,7 @@ export async function adoptRecoveredHome(home: RecoveredHome): Promise {
await database.put('sites', {
siteId: each.siteId,
label: each.label,
+ ...(each.cloudHome ? { cloudHome: each.cloudHome } : {}),
boxStaticKey: each.boxStaticKey,
rendezvousSecret: each.rendezvousSecret,
escrow: true,
diff --git a/src/lib/identity/pairing.ts b/src/lib/identity/pairing.ts
index efec34d..fb70a7a 100644
--- a/src/lib/identity/pairing.ts
+++ b/src/lib/identity/pairing.ts
@@ -39,6 +39,7 @@ import { db, requestPersistence, type StoredSite } from '$lib/store/db'
export interface PairedSite {
siteId: string
label: string
+ cloudHome?: string
/** Pinned optically. This is what stops the relay impersonating a box. */
boxStaticKey: Uint8Array
/** Single use. Goes in handshake message 1 so the box can accept us. */
@@ -104,6 +105,10 @@ export async function pairWithBox(
opts: PairOptions = {}
): Promise {
const enrollment = parseScanned(scanned)
+ if (!enrollment.home) {
+ throw new EnrollmentError('Cloud home missing', 'E_QR_HOME',
+ 'This code is from before FTW Cloud. Update your box, then show a new pairing QR in Settings → FTW app.')
+ }
const store = opts.store ?? openVaultStore()
// One prompt. A device that already has a passkey unlocks; a new one enrolls.
@@ -122,6 +127,7 @@ export async function pairWithBox(
const site: PairedSite = {
siteId,
label: 'Home',
+ ...(enrollment.home ? { cloudHome: enrollment.home } : {}),
boxStaticKey: enrollment.boxStaticPublic,
pairingCode: enrollment.pairingCode,
rendezvousSecret: enrollment.rendezvousSecret,
@@ -178,6 +184,7 @@ async function holdSealedCopy(
const outcome = await saveEscrowCopy(store, wrapping, escrow ?? {}, {
siteId: site.siteId,
label: site.label,
+ ...(site.cloudHome ? { cloudHome: site.cloudHome } : {}),
boxStaticKey: site.boxStaticKey,
rendezvousSecret: site.rendezvousSecret,
})
@@ -210,6 +217,7 @@ async function storeSite(site: PairedSite): Promise {
const row: StoredSite = {
siteId: site.siteId,
label: site.label,
+ ...(site.cloudHome ? { cloudHome: site.cloudHome } : {}),
boxStaticKey: site.boxStaticKey,
// Kept because the handle has to keep rotating long after the pairing
// code is spent. The QR is the only place it ever appears.
diff --git a/src/lib/identity/recovery-blob.test.ts b/src/lib/identity/recovery-blob.test.ts
index 00879d1..fe35fc5 100644
--- a/src/lib/identity/recovery-blob.test.ts
+++ b/src/lib/identity/recovery-blob.test.ts
@@ -27,6 +27,16 @@ import { ESCROW_BLOB_BYTES } from '../../../escrow/src/escrow.ts'
const SCALAR = new Uint8Array(32).fill(0x44)
+it('restores the Cloud route from a fixed-size sealed copy', async () => {
+ const key = await sealingKey()
+ const original = blob(1)
+ original.homes[0]!.cloudHome = 'fjzyr67buzpyncqmjliwgezvde'
+ const sealed = await sealRecoveryBlob(key, original, 7)
+ expect(sealed).toHaveLength(512)
+ expect((await openRecoveryBlob(key, sealed, 7)).homes[0]!.cloudHome).toBe(original.homes[0]!.cloudHome)
+})
+
+
function blob(homes: number): RecoveryBlob {
return {
deviceScalar: SCALAR,
diff --git a/src/lib/identity/recovery-blob.ts b/src/lib/identity/recovery-blob.ts
index 350ce7b..18ef557 100644
--- a/src/lib/identity/recovery-blob.ts
+++ b/src/lib/identity/recovery-blob.ts
@@ -34,6 +34,7 @@
*/
import { BOX_KEY_BYTES, RENDEZVOUS_SECRET_BYTES } from './enrollment'
+import { validHome } from '$lib/protocol/cloud'
/** The X25519 scalar. Not a choice — it is what Noise needs to be this device. */
const SCALAR_BYTES = 32
@@ -51,13 +52,14 @@ const SCALAR_BYTES = 32
export const RECOVERY_BLOB_MAX_BYTES = 512
/**
- * The layout, version 2.
+ * The layout, version 3.
*
* Sealed: [version 1 B][nonce 12 B][ciphertext + tag]
*
* Plain: [device scalar 32 B][home count 1 B]
* then per home: [id length 1 B][id][name length 1 B][name]
* [box static key 32 B][rendezvous secret 32 B]
+ * [Cloud home length 1 B][Cloud home, 0 or 26 B]
* then zeroes, out to RECOVERY_BLOB_PLAIN_BYTES.
*
* Additional data: [version 1 B][escrow version 4 B, big-endian]
@@ -79,7 +81,7 @@ export const RECOVERY_BLOB_MAX_BYTES = 512
* put a code the box has already refused into a handshake that does not need
* one.
*/
-export const RECOVERY_BLOB_VERSION = 2
+export const RECOVERY_BLOB_VERSION = 3
/** No escrow behind this blob. Reserved so a version is never absent. */
export const ESCROW_VERSION_NONE = 0
@@ -103,6 +105,7 @@ export const MAX_NAME_CHARS = 32
export interface RecoverableHome {
siteId: string
label: string
+ cloudHome?: string
/** The box's static Noise key. The trust anchor, pinned optically once. */
boxStaticKey: Uint8Array
/** Long-lived; the rotating relay handle is derived from it and only from it. */
@@ -162,13 +165,15 @@ export function encodeRecoveryBlob(blob: RecoveryBlob): Uint8Array
}
const siteId = encoder.encode(home.siteId)
const label = encoder.encode(cutToCharacters(home.label, MAX_NAME_CHARS))
+ if (home.cloudHome !== undefined && !validHome(home.cloudHome)) throw format('invalid Cloud home')
if (siteId.length > 0xff || label.length > 0xff) throw format('a name is longer than a byte')
return { siteId, label, home }
})
let size = SCALAR_BYTES + 1
- for (const { siteId, label } of homes) {
+ for (const { siteId, label, home } of homes) {
size += 1 + siteId.length + 1 + label.length + BOX_KEY_BYTES + RENDEZVOUS_SECRET_BYTES
+ size += 1 + (home.cloudHome ? 26 : 0)
}
if (size > RECOVERY_BLOB_PLAIN_BYTES) {
throw new RecoveryBlobError(
@@ -196,6 +201,10 @@ export function encodeRecoveryBlob(blob: RecoveryBlob): Uint8Array
at += BOX_KEY_BYTES
out.set(home.rendezvousSecret, at)
at += RENDEZVOUS_SECRET_BYTES
+ const cloud = encoder.encode(home.cloudHome ?? '')
+ out[at++] = cloud.length
+ out.set(cloud, at)
+ at += cloud.length
}
return out
}
@@ -228,7 +237,9 @@ export function decodeRecoveryBlob(plain: Uint8Array): RecoveryBlob {
at += BOX_KEY_BYTES
const rendezvousSecret = plain.slice(at, at + RENDEZVOUS_SECRET_BYTES)
at += RENDEZVOUS_SECRET_BYTES
- homes.push({ siteId, label, boxStaticKey, rendezvousSecret })
+ const cloudHome = decoder.decode(readSlice())
+ if (cloudHome && !validHome(cloudHome)) throw format('invalid Cloud home')
+ homes.push({ siteId, label, boxStaticKey, rendezvousSecret, ...(cloudHome ? { cloudHome } : {}) })
}
// The tail is padding and has to look like it. Anything else is a payload
diff --git a/src/lib/protocol/cloud.test.ts b/src/lib/protocol/cloud.test.ts
new file mode 100644
index 0000000..20c4f99
--- /dev/null
+++ b/src/lib/protocol/cloud.test.ts
@@ -0,0 +1,56 @@
+import { describe, it, expect } from 'vitest'
+import { HandshakeState, keyPairFromSecret } from '$lib/crypto/noise'
+import { NoiseTransport } from '$lib/crypto/transport'
+import { cloudFrame, cloudPrologue, readCloudFrame, readCloudSnapshot, readTunnel, unsigned, pairingHash } from './cloud'
+import vectors from '../../../tests/fixtures/cloud-app-vectors.json'
+
+const bytes = (hex: string) => Uint8Array.from(hex.match(/../g) ?? [], x => parseInt(x, 16))
+const hex = (value: Uint8Array) => [...value].map(x => x.toString(16).padStart(2, '0')).join('')
+
+describe('FTW Cloud vectors from srcfl/ftw go/protocol/cloudwire', () => {
+ it('matches the Go and Swift handshake and both transport directions', async () => {
+ const hs = HandshakeState.initiator({
+ staticKey: keyPairFromSecret(bytes(vectors.phoneStatic)),
+ remoteStatic: bytes(vectors.servicePublic),
+ ephemeral: keyPairFromSecret(bytes(vectors.phoneEphemeral)),
+ prologue: cloudPrologue(vectors.home),
+ })
+ expect(hex(unsigned('now', { hz: 1 }))).toBe(vectors.msg1Payload)
+ expect(hex(await hs.writeMessage(bytes(vectors.msg1Payload)))).toBe(vectors.msg1)
+ const snapshot = await hs.readMessage(bytes(vectors.msg2))
+ expect(hex(snapshot)).toBe(vectors.msg2Payload)
+ readCloudSnapshot(snapshot)
+ const transport = new NoiseTransport(hs.split())
+ expect(hex(transport.handshakeHash)).toBe(vectors.handshakeHash)
+ for (const message of vectors.transport) {
+ const frame = bytes(message.frame)
+ if (message.from === 'phone') expect(hex(transport.encrypt(frame))).toBe(message.message)
+ else expect(hex(transport.decrypt(bytes(message.message)))).toBe(message.frame)
+ const envelope = readCloudFrame(frame)
+ if (envelope.t === 'tun') {
+ const tun = readTunnel(envelope.b)
+ expect(hex(cloudFrame(unsigned('tun', tun)))).toBe(message.frame)
+ }
+ }
+ transport.close()
+ })
+
+ it('matches the pending-pairing proof and piggybacked box handshake', () => {
+ const pair = pairingHash(vectors.home, bytes(vectors.pairing.code))
+ expect(hex(pair)).toBe(vectors.pairing.hash)
+ expect(hex(unsigned('now', { hz: 1, pair }))).toBe(vectors.pairing.msg1Now)
+ expect(hex(unsigned('now', { hz: 1, pair, tun: { sid: 1, d: new Uint8Array(112).fill(0x9b) } }))).toBe(vectors.pairing.msg1NowTun)
+ })
+
+ it('rejects flags, padding, lane 0 and oversized tunnels', () => {
+ const frame = cloudFrame(unsigned('tun', { sid: 1, d: new Uint8Array() }))
+ for (const index of [0, 1, 2, 3, 5]) {
+ const wrong = frame.slice(); wrong[index] = wrong[index]! ^ 1
+ expect(() => readCloudFrame(wrong)).toThrow()
+ }
+ expect(() => readCloudFrame(new Uint8Array([...frame, 0]))).toThrow()
+ expect(() => readTunnel(readCloudFrame(cloudFrame(unsigned('tun', { sid: 0, d: new Uint8Array() }))).b)).toThrow()
+ expect(() => readTunnel(readCloudFrame(cloudFrame(unsigned('tun', { sid: 1, d: new Uint8Array(16409) }))).b)).toThrow()
+ expect(() => cloudPrologue('../other-home')).toThrow()
+ })
+})
diff --git a/src/lib/protocol/cloud.ts b/src/lib/protocol/cloud.ts
new file mode 100644
index 0000000..ac4e351
--- /dev/null
+++ b/src/lib/protocol/cloud.ts
@@ -0,0 +1,83 @@
+// FTW Cloud's outer session. The box session inside tun keeps its own codec.
+// Normative wire and vectors: srcfl/ftw go/protocol/cloudwire.
+import { encode, decode } from 'cbor2'
+import { sha256 } from '@noble/hashes/sha2.js'
+
+export const CLOUD_URL = 'wss://cloud.ftw.energy/v1/app'
+// Public X25519 keys, in the same rotation order as FTWKit/Identity/CloudKeys.swift.
+export const CLOUD_KEYS = ['6-1WbbDzKvbwODSwhUN24MPemfLNRhcbMP2tpU_QAEk']
+export const TUN_DATA_MAX = 16384 + 24
+export interface CloudEnvelope { t: string; b: Uint8Array; sig: Uint8Array }
+export interface TunnelMessage { sid: number; d: Uint8Array }
+
+export function validHome(home: unknown): home is string {
+ return typeof home === 'string' && /^[a-z2-7]{26}$/.test(home)
+}
+
+export function cloudPrologue(home: string): Uint8Array {
+ if (!validHome(home)) throw new Error('invalid Cloud home')
+ return new TextEncoder().encode(`ftw.cloud.v1:${home}`)
+}
+
+export function pairingHash(home: string, code: Uint8Array): Uint8Array {
+ const tag = new TextEncoder().encode(`ftw.cloud.pair.v1\0${home}`)
+ const input = new Uint8Array(tag.length + code.length)
+ input.set(tag)
+ input.set(code, tag.length)
+ return sha256(input)
+}
+
+export function unsigned(t: string, body: unknown): Uint8Array {
+ return encode({ t, b: encode(body, { cde: true }), sig: new Uint8Array() }, { cde: true })
+}
+
+export function cloudFrame(envelope: Uint8Array): Uint8Array {
+ if (envelope.length > 65535) throw new Error('Cloud envelope too large')
+ const frame = new Uint8Array(envelope.length + 6)
+ frame.set([1, 1, 0, 0, envelope.length >> 8, envelope.length & 255])
+ frame.set(envelope, 6)
+ return frame
+}
+
+export function cloudBody(bytes: Uint8Array): Record {
+ const value: unknown = decode(bytes, { rejectDuplicateKeys: true })
+ if (!value || typeof value !== 'object' || Array.isArray(value) || value instanceof Uint8Array || value instanceof Map) {
+ throw new Error('invalid Cloud body')
+ }
+ return value as Record
+}
+
+export function cloudEnvelope(bytes: Uint8Array): CloudEnvelope {
+ const value = cloudBody(bytes)
+ if (typeof value['t'] !== 'string' || !value['t'] || !(value['b'] instanceof Uint8Array) || !(value['sig'] instanceof Uint8Array)) {
+ throw new Error('invalid Cloud envelope')
+ }
+ return { t: value['t'], b: value['b'], sig: value['sig'] }
+}
+
+export function readCloudFrame(frame: Uint8Array): CloudEnvelope {
+ if (frame.length < 6 || frame[0] !== 1 || frame[1] !== 1 || frame[2] !== 0 || frame[3] !== 0 ||
+ ((frame[4]! << 8) | frame[5]!) !== frame.length - 6) throw new Error('invalid Cloud frame')
+ return cloudEnvelope(frame.subarray(6))
+}
+
+export function readTunnel(body: Uint8Array): TunnelMessage {
+ const value = cloudBody(body)
+ const sid = value['sid']
+ const d = value['d']
+ if (typeof sid !== 'number' || !Number.isInteger(sid) || sid <= 0 || sid > 0xffffffff ||
+ !(d instanceof Uint8Array) || d.length > TUN_DATA_MAX) throw new Error('invalid tunnel message')
+ return { sid, d }
+}
+
+export function readCloudSnapshot(payload: Uint8Array): void {
+ const envelope = cloudEnvelope(payload)
+ if (envelope.t !== 'snapshot' || envelope.sig.length !== 0) throw new Error('invalid Cloud snapshot')
+ const body = cloudBody(envelope.b)
+ if (!Array.isArray(body['envelopes']) || typeof body['online'] !== 'boolean' ||
+ !Number.isSafeInteger(body['lastSeenMs']) || !Number.isSafeInteger(body['more']) || Number(body['more']) < 0) {
+ throw new Error('invalid Cloud snapshot body')
+ }
+ // This carrier uses only the end-to-end box session. The readable copy is
+ // not projected into UI state; its signatures and freshness need their own view.
+}
diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts
index 7b7069b..d0a0b37 100644
--- a/src/lib/protocol/contract.ts
+++ b/src/lib/protocol/contract.ts
@@ -94,6 +94,9 @@ export const SCOPE_MODE_WRITE: Scope = 'ftw.mode.write'
/** The capability a box offers when its optimizer accepts dispatch. */
export const CAP_PLAN_DISPATCH = 'plan.dispatch'
+/** The box accepts its own session through FTW Cloud. */
+export const CAP_CLOUD_TUN = 'cloud.tun'
+
/** The capability a box offers when a charger is among its resources. */
export const CAP_DER_EV = 'der.ev'
diff --git a/src/lib/protocol/messages.ts b/src/lib/protocol/messages.ts
index 0a7ee9c..c2a42af 100644
--- a/src/lib/protocol/messages.ts
+++ b/src/lib/protocol/messages.ts
@@ -51,6 +51,8 @@ export const ROLE_OWNER: Role = 'owner'
export const ROLE_VIEWER: Role = 'viewer'
export interface HelloOk {
+ /** Box-signed copy identity, learned only inside the box's Noise session. */
+ cloud?: { home: string; pk: Uint8Array; boot: Uint8Array; sources: Record; loadpoints: Record }
/** Negotiated version. PROTO_FLOOR means the app is too old for full mode. */
proto: number
mode: BoxMode
diff --git a/src/lib/protocol/types.ts b/src/lib/protocol/types.ts
index c587b7b..7b03e9a 100644
--- a/src/lib/protocol/types.ts
+++ b/src/lib/protocol/types.ts
@@ -8,7 +8,7 @@
*/
/** How frames are reaching us. Orthogonal to SourceState — see FreshnessBand. */
-export type CarrierState = 'webrtc' | 'relay' | 'cloud' | 'cache' | 'none'
+export type CarrierState = 'webrtc' | 'relay' | 'tunnel' | 'cloud' | 'cache' | 'none'
/** Whether a box-side source is answering. Orthogonal to CarrierState. */
export type SourceState = 'live' | 'lagging' | 'stale' | 'down' | 'never'
diff --git a/src/lib/sim/box.ts b/src/lib/sim/box.ts
index f5f3a64..bc96065 100644
--- a/src/lib/sim/box.ts
+++ b/src/lib/sim/box.ts
@@ -58,7 +58,7 @@ import {
} from '$lib/protocol/messages'
import { SAFETY_K_DEFAULT, clampSafetyK, trustFromSafetyK, type BatteryExport } from '$lib/format/plan-prefs'
import { SimApi, evPluggedIn } from './api'
-import { roleHasScope, ROLE_SCOPES } from '$lib/protocol/contract'
+import { roleHasScope, ROLE_SCOPES, CAP_CLOUD_TUN } from '$lib/protocol/contract'
import { buildPlan, priceAt, importTotalMinor } from './planner'
import {
RESOLUTIONS,
@@ -329,6 +329,7 @@ export interface SimBoxOptions {
scopes?: string[]
/** False models a box from before hello could carry a subscription. */
inlineSubscribe?: boolean
+ cloud?: HelloOk['cloud']
/**
* Starting dispatch mode. Defaults to FTW's own default (passive
* arbitrage). Tests that open the Plan screen already on a manual
@@ -373,6 +374,7 @@ export class SimBox {
#bucket: 256 | 512 = 512
#hz: Sub['hz'] = 1
#inlineSubscribe: boolean
+ #cloud: HelloOk['cloud']
#lastReading: Reading | null = null
#lastSent = new Map()
#lastSourcesJson = ''
@@ -408,6 +410,7 @@ export class SimBox {
this.#role = opts.role ?? ROLE_OWNER
this.#scopes = opts.scopes ?? null
this.#inlineSubscribe = opts.inlineSubscribe ?? true
+ this.#cloud = opts.cloud
if (opts.mode && MODE_KEYS.includes(opts.mode)) this.#mode = opts.mode
this.#api = new SimApi({
house: this.house,
@@ -634,7 +637,8 @@ export class SimBox {
mode: this.faults.booting ? 'booting' : proto === PROTO_FLOOR ? 'floor' : 'full',
box: { id: 'sim-0001', build: 'sim', tz: 'Europe/Stockholm' },
clock: { source: 'ntp', syncedAtMs: this.#startedAtMs, uptimeMs: this.uptimeMs },
- caps: proto === PROTO_FLOOR ? ['status.core'] : CAPS,
+ caps: proto === PROTO_FLOOR ? ['status.core'] : this.#cloud ? [...CAPS, CAP_CLOUD_TUN] : CAPS,
+ ...(this.#cloud ? { cloud: this.#cloud } : {}),
capsHash: 'sim',
// Sent so the app knows what to draw, and for nothing else. Hiding a
// button is presentation; every refusal above is the box's.
diff --git a/src/lib/state/connect.test.ts b/src/lib/state/connect.test.ts
new file mode 100644
index 0000000..f33dd9f
--- /dev/null
+++ b/src/lib/state/connect.test.ts
@@ -0,0 +1,69 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import 'fake-indexeddb/auto'
+import { installMockAuthenticator } from '../../../tests/support/passkey'
+import { cloudHarness, HOME } from '../../../tests/support/cloud-harness'
+import { CloudTunnelCarrier } from '$lib/carrier/cloud'
+import { pairWithBox } from '$lib/identity/pairing'
+import { encodeBase64url } from '$lib/identity/base64url'
+import { Session } from '$lib/protocol/session'
+import { connectToSite, loadSite } from './connect'
+import { db } from '$lib/store/db'
+import { openVaultStore, resetIdentity } from '$lib/identity/vault'
+
+const cleanup: (() => void)[] = []
+const settle = async () => { for (let i = 0; i < 30; i++) await new Promise(r => setTimeout(r, 2)) }
+beforeEach(async () => {
+ await resetIdentity(openVaultStore())
+ const database = await db()
+ for (const name of ['meta', 'keys', 'sites', 'snapshot'] as const) await database.clear(name)
+ const auth = installMockAuthenticator()
+ cleanup.push(() => auth.uninstall())
+})
+afterEach(() => { cleanup.splice(0).reverse().forEach(close => close()) })
+
+function setup() {
+ const h = cloudHarness()
+ cleanup.push(() => h.close())
+ const link = (v3: boolean) => `https://app.ftw.energy/p#${v3 ? 'v3' : 'v2'}.${encodeBase64url(h.boxKey.publicKey)}.${encodeBase64url(h.code)}..${encodeBase64url(new Uint8Array(32).fill(4))}${v3 ? '.' + encodeBase64url(new TextEncoder().encode(HOME)) : ''}`
+ return { ...h, link,
+ cloud: (opts: ConstructorParameters[0]) => new CloudTunnelCarrier({ ...opts,
+ serviceKeys: [h.service.publicKey], WebSocketImpl: h.WebSocketImpl }),
+ }
+}
+
+describe('the shipped connect and pairing path', () => {
+ it('keeps QR v3 home and pairs through Cloud without constructing a relay', async () => {
+ const h = setup()
+ const paired = await pairWithBox(h.link(true), { holdCopy: false })
+ expect((await loadSite(paired.site.siteId))!.cloudHome).toBe(HOME)
+ const carrier = await connectToSite(paired.site.siteId, { makeCloud: h.cloud })
+ const session = new Session({ build: 'new-home' })
+ cleanup.push(() => session.close())
+ session.connect(carrier)
+ await settle()
+ expect(h.errors).toEqual([])
+ expect(session.state.phase).toBe('streaming')
+ expect(session.state.carrier).toBe('tunnel')
+ expect((await loadSite(paired.site.siteId))!.cloudKey).toHaveLength(65)
+ })
+
+ it('asks an old installation to pair again before opening any socket', async () => {
+ const h = setup()
+ const paired = await pairWithBox(h.link(true), { holdCopy: false })
+ const database = await db()
+ const row = (await loadSite(paired.site.siteId))!
+ delete row.cloudHome
+ await database.put('sites', row)
+ await expect(connectToSite(paired.site.siteId, { makeCloud: h.cloud })).rejects.toMatchObject({
+ code: 'stale_pairing', help: expect.stringMatching(/pair this home again/i),
+ })
+ expect(h.sockets).toHaveLength(0)
+ })
+
+ it('refuses an old QR before asking for a passkey', async () => {
+ const h = setup()
+ await expect(pairWithBox(h.link(false), { holdCopy: false })).rejects.toMatchObject({ code: 'E_QR_HOME' })
+ expect(await (await db()).getAll('sites')).toHaveLength(0)
+ expect(h.sockets).toHaveLength(0)
+ })
+})
diff --git a/src/lib/state/connect.ts b/src/lib/state/connect.ts
index 3846257..e7647fb 100644
--- a/src/lib/state/connect.ts
+++ b/src/lib/state/connect.ts
@@ -1,6 +1,6 @@
/* Building the carrier stack for a paired site.
*
- * Session → NoiseCarrier → RelayCarrier → the relay
+ * Session → NoiseCarrier → CloudTunnelCarrier → FTW Cloud
*
* Everything above the session sees state; everything below the Noise carrier
* sees opaque bytes. This file is the one place the three are wired together,
@@ -8,8 +8,11 @@
*/
import { NoiseCarrier } from '$lib/carrier/noise'
-import { RelayCarrier } from '$lib/carrier/relay'
+import { CloudTunnelCarrier, type CloudOptions } from '$lib/carrier/cloud'
import type { Carrier } from '$lib/carrier/carrier'
+import { validHome } from '$lib/protocol/cloud'
+import { CAP_CLOUD_TUN } from '$lib/protocol/contract'
+import { decodeFrame } from '$lib/protocol/frame'
import {
openVaultStore,
silentWrappingKey,
@@ -19,16 +22,11 @@ import {
isEnrolled,
} from '$lib/identity/vault'
import { db, type StoredSite } from '$lib/store/db'
-import { RELAY_URL as DEFAULT_RELAY_URL } from '$lib/identity/origin'
import { markLinkPhase } from '$lib/perf/link'
-/** Overridable only for development against a local relay. */
-export const RELAY_URL = import.meta.env['VITE_RELAY_URL'] ?? DEFAULT_RELAY_URL
-
export interface ConnectOptions {
- /** Injected by tests. Defaults to a real relay carrier. */
- makeInner?: (opts: { url: string; secret: Uint8Array }) => Carrier
- relayUrl?: string
+ /** Injected by tests. Production always opens FTW Cloud. */
+ makeCloud?: (opts: CloudOptions) => Carrier
}
/**
@@ -82,15 +80,10 @@ export async function connectToSite(siteId: string, opts: ConnectOptions = {}):
throw new ConnectError('not_enrolled', 'This device has no key for that home.')
}
- // Read from the QR, never derived. A handle derived from the box's public
- // key would be stable for the life of the box, which hands the relay
- // operator one household identifier good for years — the exact thing the
- // hourly rotation exists to deny it.
- const secret = site.rendezvousSecret
- if (!secret) {
+ if (!validHome(site.cloudHome)) {
throw new ConnectError(
'stale_pairing',
- 'This home was paired before this app could reach it privately.'
+ 'Pair this home again. Open your box’s local dashboard, then Settings → FTW app → Show pairing code, and scan the new QR.'
)
}
@@ -104,12 +97,13 @@ export async function connectToSite(siteId: string, opts: ConnectOptions = {}):
const device = await deviceKey(vault, wrapping)
markLinkPhase('keys-ready')
- const relayUrl = opts.relayUrl ?? RELAY_URL
- const inner = opts.makeInner
- ? opts.makeInner({ url: relayUrl, secret })
- : new RelayCarrier({ url: relayUrl, secret })
+ const cloudOptions: CloudOptions = {
+ home: site.cloudHome ?? '', staticKey: device,
+ ...(site.pairingCode ? { pairingCode: site.pairingCode } : {}),
+ }
+ const inner = opts.makeCloud ? opts.makeCloud(cloudOptions) : new CloudTunnelCarrier(cloudOptions)
- return new NoiseCarrier({
+ const noise = new NoiseCarrier({
inner,
// The non-extractable handle, not raw bytes. Its scalar is never readable
// by script where the browser has X25519.
@@ -126,6 +120,34 @@ export async function connectToSite(siteId: string, opts: ConnectOptions = {}):
// is ignored.
...(site.pairingCode ? { handshakePayload: site.pairingCode } : {}),
})
+ // Only the authenticated box session may supply the box's signing key.
+ noise.onFrame(bytes => {
+ try {
+ const frame = decodeFrame(bytes)
+ if (frame.envelope.t !== 'hello_ok') return
+ void rememberCloudKey(siteId, frame.envelope.b).catch(() => {
+ // A failed cache write leaves the existing route usable.
+ })
+ } catch { /* Session owns malformed box frames. */ }
+ })
+ return noise
+}
+
+async function rememberCloudKey(siteId: string, hello: unknown): Promise {
+ if (!hello || typeof hello !== 'object') return
+ const value = hello as { caps?: unknown; cloud?: { home?: unknown; pk?: unknown } }
+ if (!Array.isArray(value.caps) || !value.caps.includes(CAP_CLOUD_TUN)) return
+ const home = value.cloud?.home
+ const pk = value.cloud?.pk
+ if (!validHome(home) || !(pk instanceof Uint8Array) || pk.length !== 65 || pk[0] !== 4) return
+ const database = await db()
+ const tx = database.transaction('sites', 'readwrite')
+ const row = await tx.store.get(siteId)
+ if (row?.cloudHome === home) {
+ const { pairingCode: _spent, ...kept } = row
+ await tx.store.put({ ...kept, cloudKey: pk })
+ }
+ await tx.done
}
function prologueFor(boxStaticKey: Uint8Array): Uint8Array {
diff --git a/src/lib/store/db.ts b/src/lib/store/db.ts
index 02da39c..c544728 100644
--- a/src/lib/store/db.ts
+++ b/src/lib/store/db.ts
@@ -44,6 +44,10 @@ export interface StoredSite {
label: string
/** Box's static Noise key, pinned optically at enrollment. */
boxStaticKey: Uint8Array
+ /** From QR v3 or the box's authenticated hello, never from Cloud. */
+ cloudHome?: string
+ /** The box's P-256 signing key, learned over its own Noise session. */
+ cloudKey?: Uint8Array
/**
* The rendezvous secret from the QR fragment. Optional only because rows
* written before the v2 payload existed do not have one; a site without it
diff --git a/src/lib/ui/FreshnessBand.svelte b/src/lib/ui/FreshnessBand.svelte
index 548f809..133081d 100644
--- a/src/lib/ui/FreshnessBand.svelte
+++ b/src/lib/ui/FreshnessBand.svelte
@@ -70,16 +70,16 @@
)
const tone = $derived.by(() => {
- if (carrier === 'relay' || carrier === 'webrtc') {
+ if (carrier === 'relay' || carrier === 'webrtc' || carrier === 'tunnel') {
return srcState === 'live' ? 'live' : 'stale'
}
return reaching ? 'reaching' : 'lost'
})
const message = $derived.by(() => {
- if (carrier === 'relay' || carrier === 'webrtc') {
- const liveWhere = carrier === 'webrtc' ? 'Live at home' : 'Live via encrypted relay'
- const connected = carrier === 'webrtc' ? 'Home link connected' : 'Encrypted relay connected'
+ if (carrier === 'relay' || carrier === 'webrtc' || carrier === 'tunnel') {
+ const liveWhere = carrier === 'webrtc' ? 'Live at home' : carrier === 'tunnel' ? 'Live, encrypted via FTW Cloud' : 'Live via encrypted relay'
+ const connected = carrier === 'webrtc' ? 'Home link connected' : carrier === 'tunnel' ? 'Encrypted link connected' : 'Encrypted relay connected'
switch (srcState) {
case 'live':
return liveWhere
@@ -110,6 +110,7 @@
if (phase === 'subscribing') return 'Waiting for live readings'
if (phase === 'handshaking' && transport === 'relay') return 'Securing encrypted relay'
if (phase === 'handshaking' && transport === 'webrtc') return 'Securing home link'
+ if (phase === 'handshaking' && transport === 'tunnel') return 'Securing your box connection'
return 'Connecting to your box'
}
return "Can't reach your box"
@@ -131,7 +132,7 @@
* whole design is built to avoid.
*/
const ageText = $derived.by(() => {
- if (carrier === 'relay' || carrier === 'webrtc') {
+ if (carrier === 'relay' || carrier === 'webrtc' || carrier === 'tunnel') {
if (srcState === 'live') return null
if (srcState === 'never') return null
}
diff --git a/src/lib/ui/FreshnessBand.svelte.test.ts b/src/lib/ui/FreshnessBand.svelte.test.ts
index f42ce9c..39e9e30 100644
--- a/src/lib/ui/FreshnessBand.svelte.test.ts
+++ b/src/lib/ui/FreshnessBand.svelte.test.ts
@@ -62,6 +62,22 @@ describe('the freshness band on a phone whose access ended', () => {
expect(document.body.textContent).not.toMatch(/\blive\b/i)
})
+ it('names the encrypted Cloud tunnel and still ages stale readings', () => {
+ render(FreshnessBand, {
+ props: { carrier: 'tunnel', srcState: 'live', ageMs: 0, phase: 'streaming' },
+ })
+ expect(document.body.textContent).toContain('Live, encrypted via FTW Cloud')
+ })
+
+ it('never calls stale tunnel readings live', () => {
+ render(FreshnessBand, {
+ props: { carrier: 'tunnel', srcState: 'stale', ageMs: 60_000, phase: 'streaming' },
+ })
+ expect(document.body.textContent).toContain('Encrypted link connected')
+ expect(document.body.textContent).not.toMatch(/\blive\b/i)
+ expect(document.body.textContent).toContain('1 min ago')
+ })
+
it('agrees with the starting screen and shows the box progress', () => {
render(FreshnessBand, {
props: {
diff --git a/src/views/Pair.svelte b/src/views/Pair.svelte
index 6258541..dfa0606 100644
--- a/src/views/Pair.svelte
+++ b/src/views/Pair.svelte
@@ -57,6 +57,7 @@
* may well come back to this screen more than once before they finish.
*/
const inSafariTab = isIosSafariTab(currentEnvironment())
+ const cloudNotice = "FTW Cloud connects this app to your box. The connection stays encrypted. The service sees your home's id, internet addresses and when data passes."
type Stage =
| 'intro'
@@ -101,7 +102,7 @@
])
if (!(await isEnrolled(openVaultStore()))) return
const sites = await pairedSites()
- const first = sites[0]
+ const first = sites.find(row => row.cloudHome)
if (first) known = { siteId: first.siteId, label: first.label }
})()
})
@@ -374,6 +375,7 @@
{/if}
+ {cloudNotice}
{#if message}
{message}
{/if}
@@ -442,6 +444,7 @@
copy that Sourceful cannot open. If not, pairing still works; a new
Settings QR is the way back.
+ {cloudNotice}
{#if canScan()}
{/if}
diff --git a/src/views/Pair.svelte.test.ts b/src/views/Pair.svelte.test.ts
index fafca94..633ce00 100644
--- a/src/views/Pair.svelte.test.ts
+++ b/src/views/Pair.svelte.test.ts
@@ -174,6 +174,7 @@ describe('a home already saved on this phone', () => {
await database.put('sites', {
siteId: SITE,
label: 'Home',
+ cloudHome: 'fjzyr67buzpyncqmjliwgezvde',
boxStaticKey: new Uint8Array(32).fill(7),
rendezvousSecret: new Uint8Array(32).fill(9),
addedAtMs: 1,
diff --git a/tests/csp-contract.test.ts b/tests/csp-contract.test.ts
index fa4a41c..b538f3b 100644
--- a/tests/csp-contract.test.ts
+++ b/tests/csp-contract.test.ts
@@ -11,7 +11,7 @@
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { ESCROW_ORIGIN } from '../src/lib/identity/escrow'
-import { RELAY_HOST } from '../src/lib/identity/origin'
+import { CLOUD_URL } from '../src/lib/protocol/cloud'
function connectSrc(): string {
const headers = readFileSync('public/_headers', 'utf8')
@@ -26,7 +26,8 @@ describe('the connect-src list', () => {
it('carries every origin the code calls', () => {
const list = connectSrc()
expect(list, 'the escrow is unreachable from a browser').toContain(ESCROW_ORIGIN)
- expect(list, 'the relay is unreachable from a browser').toContain(RELAY_HOST)
+ expect(list).not.toContain('relay.ftw.energy')
+ expect(list, 'FTW Cloud is unreachable from a browser').toContain(new URL(CLOUD_URL).origin)
})
it('never grows a wildcard', () => {
diff --git a/tests/entry-bundle.test.ts b/tests/entry-bundle.test.ts
index 790f4fc..1d7d7ce 100644
--- a/tests/entry-bundle.test.ts
+++ b/tests/entry-bundle.test.ts
@@ -109,6 +109,9 @@ function chunkWith(marker: string): string {
}
describe('what a cold start downloads before it can paint', () => {
+ it('ships no Frankfurt relay connection or fallback', () => {
+ for (const code of chunks.values()) expect(code).not.toContain('relay.ftw.energy')
+ })
it('is really the launch path and not the whole build', () => {
// The control. Every rule below is an absence, and an absence proves
// nothing if the thing being searched is empty or is everything.
diff --git a/tests/escrow-e2e.test.ts b/tests/escrow-e2e.test.ts
index 36c2231..eff5565 100644
--- a/tests/escrow-e2e.test.ts
+++ b/tests/escrow-e2e.test.ts
@@ -132,6 +132,7 @@ async function wipeThisDevice(): Promise {
function enrollmentUrl(): string {
return buildEnrollmentUrl({
+ home: 'fjzyr67buzpyncqmjliwgezvde',
boxStaticPublic: BOX_KEY,
pairingCode: new Uint8Array(16).fill(0x11),
rendezvousSecret: RENDEZVOUS,
diff --git a/tests/fixtures/NOTICE b/tests/fixtures/NOTICE
new file mode 100644
index 0000000..6fbe1da
--- /dev/null
+++ b/tests/fixtures/NOTICE
@@ -0,0 +1,5 @@
+cloud-app-vectors.json is copied unchanged from:
+https://github.com/srcfl/ftw/blob/3789764a92d23043d8380e15d609cf787040d2a8/go/protocol/cloudwire/testdata/cloud-app-vectors.json
+
+Copyright 2026 Sourceful Labs AB and contributors.
+Licensed under Apache-2.0. The Apache license text is retained in /NOTICE.
diff --git a/tests/fixtures/cloud-app-vectors.json b/tests/fixtures/cloud-app-vectors.json
new file mode 100644
index 0000000..feb481e
--- /dev/null
+++ b/tests/fixtures/cloud-app-vectors.json
@@ -0,0 +1,78 @@
+{
+ "serviceStatic": "02d86370739892ce589fb0dac0416128cb716a667f6b3baf2979ed03a39be145",
+ "servicePublic": "4bcf8ca8663797d826934ad341de6e223114105a1f513b78da30ef3f32cd8557",
+ "serviceEphemeral": "f4e90c05a93a8b3e2e3741a3c3df44681e9829b95332b4a406e3325d3cce6bd3",
+ "phoneStatic": "ea39659c38ef7cf936ca64fc18edccb9889785bf85e202593bd192680b425f37",
+ "phonePublic": "f7d081a5e8884ade9cf55aeb6cf767afabf70d3c0b195a013651055908128f08",
+ "phoneEphemeral": "df36c6700bc874c62773997c6e95704050bac93a3b649e0883a7f5791a358ebc",
+ "home": "fjzyr67buzpyncqmjliwgezvde",
+ "prologue": "6674772e636c6f75642e76313a666a7a7972363762757a70796e63716d6a6c697767657a766465",
+ "msg1Payload": "a3616245a162687a016174636e6f776373696740",
+ "msg1": "38869cb4f47eb17ca6a251b657a79943bca4b3a22813caac1578296e837a0273dd35e67fe141738f375ec80128d8e3c18dae31dcc5fe42a0273b9cbe300164e9e903d823a7c1bab23d562d1bf2879379e0a974150000f0b93b6e0c0fe2d0f58b1d814d5427606b9ab58d548696d231eab9369a86",
+ "msg2Payload": "a36162590771a4646d6f726500666f6e6c696e65f569656e76656c6f70657385a361625901fdaa63736571036461676573a36b41436648743444574c37671903986b4455595757516d6e2d586719e68c6b662d6f484d4471562d6d41190a3c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a76131006132190730613339090561341903b66135190264613619044c62313019083466737263526576016677616c6c4d731b000001a130d7a01467736f7572636573a36b41436648743444574c3767a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e6dc6c7374616c6541667465724d731927106b4455595757516d6e2d5867a4646b696e6466647269766572657374617465676c616767696e67686c6173744f6b4d731903e86c7374616c6541667465724d731975306b662d6f484d4471562d6d41a4646b696e6466647269766572657374617465646c697665686c6173744f6b4d7319e0386c7374616c6541667465724d73193a9868757074696d654d7319ea746c6669656c64536f7572636573a66132816b41436648743444574c37676133816b662d6f484d4471562d6d416134816b662d6f484d4471562d6d416135816b662d6f484d4471562d6d416136836b4455595757516d6e2d58676b662d6f484d4471562d6d416b41436648743444574c3767623130816b4455595757516d6e2d5867716469737061746368426c6f636b65644279806174646c69766563736967584002a8fa0091fbaa932e9ce91bf214e7b1e09f1b17767f49e330bebfdec31c0d7ae738f0a34e3c3f9331a1a8ddf7195152821318c815a61891dc3cd91610516b95a3616258a1a863736571046461676573a26b41436648743444574c37671901906b662d6f484d4471562d6d4119083464626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0666669656c6473a7613100613219070361333908f161341903b66135190264613619040b62313019083466737263526576016677616c6c4d731b000001a130d7b19468757074696d654d7319fbf4716469737061746368426c6f636b65644279806174646c6976656373696758400dc02a9f9207e8694654a8e64125db8b165f7302b8b952c1d5f3d90e8d8de7f9d7969ad8006334681b9b20732e24efddbdf46904686dd01092dd2cf7edfa49f6a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79a36162588caa637365710664626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06474304d731b000001a130d7a000647a6f6e656353453366736c6f744d731a000dbba06677616c6c4d731b000001a130d7b1a86863757272656e63796353454b68757074696d654d7319fc086973706f744d696e6f7284182a1828f6226a746f74616c4d696e6f728418761873f61847617466707269636573637369675840e53619e522b5665e9599d2e04c4a62a1040f797a5742f39fde5b56b250b60c5426e008ae623bcaf0def871c596b371763f589cf280cd7dee1381064fdca2c7e3a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd86a6c6173745365656e4d731b000001a130d8ad88617468736e617073686f746373696740",
+ "msg2": "a0719d669e99f8ba82522bf6f2e6614d99bccb5616ae3e2af7e4635267531c148d96093e3a658c9843d6ba1321e2fc10ea6f9dc9a4ff98256ab2edf4dd8f9d8d29fc50ed8ce63ab3d409e5740f425a84b8331523b6aebb7bc37080cf41080144beee9311a8089e341d3fb4886247e9114f2990333e5e29e7f3d91f338d0fdb89bb0ff5424c533b8a6cb800dd263eb08c5995540efdad9b729ff8d63e1c15df4cfe7079a8354fee4141d7ac43a0d44cc6b820444920c760983413caefbd1a31037003228381c291226f4cead6db97fe6023e8c0c1a503e8467c6726d1a045b456971a2ba19df457439481c218b95a5a94628ca3a813ee741501e475b6039f5614ab8a5e71ffe8377d3e06fc253f106a4133709fbfdccb6c6ca754ba375e12c924cb5c52351cf78ee6645f007a439ef55774f69b8e458d93cec2a1b369759ba67772b8c4f45737a82aef39f434c956b34a8a1978e261999904b80d39cd619cda27cbc983b45385b8c2569677f76d888439e7dae4df77a4e1d05ee11526f1a6276de5a53266d13fcc620e77594115f90d1b9e802ee942c5705aecb8bcd7ca2d410474dcd89d368f5411645a69bf364e67abc04e7638028117f577c016d008ba9eea84215a921b67dab3e16ec3c11b716574c332a5047e824b0232b97a1bdd210056b4a936cf70221aef0e40fd7ae1341abf7ab9b09a4424203c2f821ca2e9360b2f2130b28fa7d28cf7ac89d7013dd086f4f7aa88f3667cd25d279b0697224504f05bb5dd15098759a86cf046521fddf4010323fa2bfd88bd58b3e6babaf32d77a607b220aa613b4a0dacc1141556d8aae189d218f9f402e57b3ededb3f8b46d5d18267566fa0c309ef988333c416218579247abd47634d6631a4c6e81ecb0c5946370b1686b08509ff35aee5a6b4aed4ff56bb1c4fd7742377f8da760867a8c8e482abd062326085f62b2343957c6826e15d8a5025a584ca3b1eb31d2f48c5c0234ec9f63b68d589d4b7ad4727bb5141e2e2d3a3a7cfbe15eae129a6cdaaab7df28eb05d8ce4cf5d65761c4f5132dc717cae2c7045860224743780aca762c3e77fec18db5c7bb73bfd66f90df934afaf0b6f4f7d6102bae17b083e47a4aa63db614aa0441c0fd45193d7bc02194d9d93241f6ce83feda0f6f16d79cd13a3aadfb1fad0e8db8a40a1434f85634e923759363a71b37579f28c3795fba377a59c719937b56d44aed8bad1fd3a7be959b80ae0e12fcf1d8f2a016a64663a9f197eb35c1d1948dea05b88ff7cf25aa794a98e01383ab4dd5e4de1aceaa0dae29b90c776b4b8c97ef95201f1b466b5a19841a427a4b74123341f763770661c4fb8b01837a785c86f065d258801fac607d886d0775b32c8856e47f05be7c0f672c886c218371104c237c95c9e149bf2a5ab68882737919351429a0982da7304bb3d2643a11f12105ac393a5167fdf30a163b175c3bfe2aea7cb89cf13a60fac27fc447e98c530726d5ed4ec0d2355488a5c0ee8b265c95a3ec9381a2e1f156740bfe1346143e227d4ed6a78adbd1447f9e2690fb878f3c7bdbaefa366edbdb4c2b99c4735847beaaeada651140497d4dc82133797dd1f3333579b93aeef3a485dc3a658a20d22584b54bd5efac1d1542e37212465b51c0ccebb55d81f68729e77db8ea5eb001abd46fd4ea13d2e67114be82976a748368948bd9f3bb44784830eb8723d37e1bb12785fdcd2b02ce60d7ad655000d5abbdbbae9db3f215308e56f378b736cd101236221dbfb5c00a15c97161b53c144de4fdfac0f1e349536a37065259b86630b2f7351568878832c2a8c8c5669f70d8430b6a22c80c898da82ccd6e0b0cb902eda4b5294981a3d037cdf4da8ea31e511fdd142546e4c01e1c0154d1c098f5b36dee0d4732ad19032e9416eed93addea024bc422e5dad3783af218534b255d5dbde9202d0931230b7c6678c66165e05f0e98099b9ebe45ae7d83a1e0f4bc15845cbe4012a807632819d8d5762286c2868056b0670c26b7faa765047b42f89d7fe6e012a262c354a485419b0fb236a4332130707728e25b323f76dfcd6d89fcff4ffa274f988bcd09207f0e24f12f69a83f448b814c5eb0d7d87f993593f27ea3189a61f111d89c9d9e6668abd495253340741b31b34234e3d89106cda2a4dd06e3bc96ad3ed9dede19f1d387b1c7e094733b9e85571d9393fa23efdef0e64af8a53d4ab314b0c0f2a0f15d16bb2db9f8e57816b10d0e8a3d3406a08a68d24d0fdb1589ef951c7e98f77f9ad558da0a9a927ad9d85779bec3376f51188d12accf7fe20a019861a3c8bf0cf5135a093a7ab2cb7b2db0ca548b5e677a3bf8e0add303e3567d71826c4fd38a65a95aeeb8c166846090ee9a173fe0a753041f9df779f693ae686b75fe9854f76d0405bd2e4e5d98f3d0ee74747229c73f2964e72861a4b8df60fe638e8c450b80de929b1d612afc9c8fe537ffad8eed8f176c4d53cd1149e9566da2ff321905ea640938df4f972cb887c66c1a7828da36a014b09f111c1464ffe85f74d6e50a8428c4384579d0180ecd00209012545755e9b1c584817446d5a930e775460bb6edb1f19f6185e63685debd622b293657d3a560ef47c53aff422488f8cb09013290f08433e0f8284fa0e5105f84a2aa2882e75b7b846f19bf6dc89894a3fd773f1804f8cfa5e8a134ba15003472bacb2e5ccae391e8f08ae52a05ee1d4c2f850f94f096087bb4d26c5a6dacde4189ce9b76aeb738021092857b36c84037356d7f3431ac041c41072c57377ea",
+ "handshakeHash": "572f4062a19964d6417d27e070137f6e012ac705c073841669685158a36a0de7",
+ "snapshot": [
+ "live full",
+ "live delta",
+ "lp",
+ "prices",
+ "hb"
+ ],
+ "transport": [
+ {
+ "from": "service",
+ "name": "hb",
+ "frame": "0101000000d0a361625880a963736571076461676573a26b41436648743444574c37671905786b662d6f484d4471562d6d41190c1c64626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b065636c6f636b636e7470656c705365710566737263526576016677616c6c4d731b000001a130d7c33c676c6976655365710468757074696d654d731a00010d9c6174626862637369675840e82b54b463aaf1c315cb0a049e304609482ecf9b3f6a1f5ea9c727d618d9f69ff7c256f1ac88cef858d5168ccec7528951f987e360891aacb00dd03370403fd8",
+ "message": "00000000000000009bc932e703b7e50790aabe40832bc82ce30862451e6dbe237412298bdaec184f5bce9309f5e4823f6dc191ab5ae312c7308284bda5b4c01cebc376c65fde035767f581515047a7d463eda5e9849ee2278db80301aa88813981e80f9f3f4988de7cf8869719a5160e41e2cca4c4b0deedcff9c5706ca531452abcee947966643389f57790b7c899cf6fa4945d70b455d3270a6484d978440b91d3e23ce3055f1ef260a148ba4570bb51cb58ef3190066f2b1443d6ecd11a015b96966e0caf32236a4df15140e6ae6e131ede59ff51403400bcd325f075f7d2a5000f847a6a7996480bbbc63d08"
+ },
+ {
+ "from": "service",
+ "name": "lp",
+ "frame": "010100000250a361625901ffa7637365710564626f6f7450b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b06677616c6c4d731b000001a130d7b19e68666565646261636b81a9637372636b4455595757516d6e2d5867646b696e64626576656167654d73190384656672657368f566726561736f6e64706c616e6673746174757369666f6c6c6f77696e676761637475616c571908346865766964656e6365686d65617375726564687365766572697479626f6b68757074696d654d7319fbfe6a636f6e74726f6c52657619032c6a6c6f6164706f696e747381b66269646b6a33485a4f524e43574873637372636b4455595757516d6e2d586765626f6f7374f4666d616e75616cf6666e6578745768191ce866706f7765725719083467706c7567676564f56773657373696f6e62733167736f6341676553190708696e657874456e644d731b000001a133030b8069726561647942794d731b000001a133a1bc8069726563757272696e67f56973657373696f6e5768190d4869736f63536f7572636568696e6665727265646a636f6d6d616e646564571908166b6e65787453746172744d731b000001a132952e806b706c616e50656e64696e67f46b736f635065726d696c6c651901ae6b737572706c75734f6e6c79f46c677269644465666572726564f46e7461726765745065726d696c6c651903206f636f6d6d616e646564526561736f6e64706c616e6174626c70637369675840b3754b61b1e615d66e94d1e78bbb04f06d466710276ff5807be02499af3863bb27bd183f773095e145e3415b54ec49991c7f6e4e70e2f2db235cd7d80c379c79",
+ "message": "000000000000000148cf8f560097201c885cbfb4654f3007e1146c3b6c39ce8a4b98af3c76347a1def28cbf0bf20db92705fcc9bff3ee7bd78220f315ebbfebc2b4371edd2b9823910351899a8b3ef8e2a26c61eaa56fd4d71fff10af0aef235390740ad8357756cbe3ccbbf667c02d3c36d056d8f2ea29dcf4dd3be678e9d19abc308d6f940c45053b6633a336e0e86ba1accd5ac2ac60a7b7471115b9c539ee973acc104a71b71c42724ed43335f9eb3eff4d447b52d989605ab2a0e3250fbf19ec53367f11f1fbaa633b1bbd7422bc0740f7d44ffc6e7fc1b1e6594fae2a3027eb58b5a63089a328878c377ef69d5b1e194606836b5db97e9da4cd5e6bac5214f17b7ef161d0a04605251de29bfab442788332d5dcfc716bc04f604544368a87cfb77d7d57b89dae49c8378b2cf2372b6fe1108076edd6adbec3fa25df23d210bb22281f91564367ec7bfb17d5b6c218eda569d65bf5a99a9a242ff72190419e2ebaa34919457c9163458d6eec3e42b67e395b6d96b771a82ca8e4cf5faf3db7493c02d1695e2b2a6e40d3156cb570f5d4880ca294fd8ac70d8deddd605cdc9fbf5cb4c0b3de38a66ff3e1ef0d35c01dfb576eda170a545682498c035afac02f29ce0e3b7c0c3d0ad1536d04481cbc90e18376c57348ff1f38b1b83000e343966b52cf82f0d243c0d225360c6c1034f0dfbc2ed08f09ff2844c578ac10f27e5fa85e77fe84b98e6fff0609e82409f9b32b5809f50355007f7284bc8d60d612d60b46627668de3d4e7966b6c1f40435bd4d0fbba103a13c29db8fb74aa1c6ca06ea20979e3093d3a88f8fbef1bba2e15cfd81822a573820f3ca87e9f9519fbb8c536f23831f69e7be75b869835"
+ },
+ {
+ "from": "phone",
+ "name": "watch away",
+ "frame": "010100000016a3616245a162687a0061746577617463686373696740",
+ "message": "000000000000000018d9d2f74604f3ec3b405b61c46ab22f3f16d31530953cc94eec8cade0d64f32ba349e2f7835559709c008f3"
+ },
+ {
+ "from": "phone",
+ "name": "suspend",
+ "frame": "010100000026a3616253a1686465766963654964685a6630397851326161746773757370656e646373696740",
+ "message": "00000000000000018ef98c7df1fa903675ab1bf0e8aff5cd342be0cdc21b8ead3d04a153cf0fe6ce0d28f5fdf88e409e68eadc1827bfc61039294263085fc27f2b9b980b"
+ },
+ {
+ "from": "phone",
+ "name": "tun",
+ "frame": "01010000004aa36162583aa2616458309a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a9a637369640761746374756e6373696740",
+ "message": "00000000000000022731255fc9340e3fc8fe0933b3cea6ee0a7bade6b3019c5e9b09696018bb2c57b1cc7cf32d3d556cc16ab7187ed6cba0c662469026d4c99a61f40e131cbd35f3b3d25ed22d445a9664f429271c8539fa0401884a10d87cdca3c10fd0e3273a2c"
+ },
+ {
+ "from": "service",
+ "name": "tun",
+ "frame": "01010000008aa36162587aa26164587099999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999637369640761746374756e6373696740",
+ "message": "00000000000000026cf288f8e3930b62ce5f00bda38eb90f3dc6fe833042f9cd777b8369dbb658abdf10ea42bdac4e40bae9482ae4e95a43e64a0d34d9c6aa7afc59c2455e7c3f8d1b4b081b2a8353f22e6cf6be6340e10a9dd0b9af405ad53d46feb2d6985d8426b76a8c887ff6f9b25c16be90dd9ba24455db43b6f869a1d7a51866bd4e104e9faacce088903a41f9a3c4d95c6d4dd2db54cdbf607a569cf1956cce138dc461c9"
+ },
+ {
+ "from": "service",
+ "name": "tun end",
+ "frame": "010100000018a3616249a2616440637369640761746374756e6373696740",
+ "message": "0000000000000003237e42f082e81124d36ef77e2467933a52c8c41f3ecf41b2247ee239a9dd9dfd72d74d129a742d70765cbaf6e61c"
+ }
+ ],
+ "pairing": {
+ "code": "88888888888888888888888888888888",
+ "hash": "b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa3563",
+ "msg1Now": "a36162582ca262687a0164706169725820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636174636e6f776373696740",
+ "msg1NowTun": "a3616258aaa362687a016374756ea2616458709b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b9b637369640164706169725820b4e707c0ac2241df0e0325b42b951b3c71bbd5099bc3c6f2894a48ee23fa35636174636e6f776373696740",
+ "deviceId": "99CBpeiI"
+ },
+ "notes": {
+ "keys": "Secrets are SHA-256 of the labels in app_vectors_test.go. X25519 clamps them.",
+ "snapshot": "Message 2 holds the cloud-vectors.json envelopes named here, in this order.",
+ "transport": "Each message is appwire's: u64 BE seq, then ChaCha20-Poly1305 over the frame with the seq as associated data. Each direction counts from 0."
+ }
+}
diff --git a/tests/live-cloud.test.ts b/tests/live-cloud.test.ts
new file mode 100644
index 0000000..f1210aa
--- /dev/null
+++ b/tests/live-cloud.test.ts
@@ -0,0 +1,62 @@
+/** @vitest-environment node */
+// Run Core's TestLiveBoxForTheiPhone with its relay unreachable and a local
+// ftwcloudd. FTW_LIVE_CLOUD_DIR holds pair.url, ca.pem and noise.pub.
+import { describe, it, expect } from 'vitest'
+import { readFileSync } from 'node:fs'
+import WebSocket from 'ws'
+import 'fake-indexeddb/auto'
+import { installMockAuthenticator } from './support/passkey'
+import { pairWithBox } from '$lib/identity/pairing'
+import { connectToSite } from '$lib/state/connect'
+import { CloudTunnelCarrier } from '$lib/carrier/cloud'
+import { decodeBase64url } from '$lib/identity/base64url'
+import { openVaultStore, resetIdentity } from '$lib/identity/vault'
+import { Session } from '$lib/protocol/session'
+import { OP_SET_MODE } from '$lib/protocol/messages'
+
+const DIR = process.env['FTW_LIVE_CLOUD_DIR']
+const live = DIR ? describe : describe.skip
+live('the web client through real ftwcloudd and Core, with the relay unreachable', () => {
+ it('pairs, streams, confirms 30 commands and resumes five times', async () => {
+ const read = (name: string) => readFileSync(`${DIR}/${name}`, 'utf8').trim()
+ const ca = read('ca.pem')
+ class Socket extends WebSocket {
+ constructor(url: string) { super(url, { ca, origin: 'https://app.ftw.energy' }) }
+ }
+ const auth = installMockAuthenticator()
+ await resetIdentity(openVaultStore())
+ const session = new Session({ build: 'cloud-e2e' })
+ try {
+ const { site } = await pairWithBox(read('pair.url'), { holdCopy: false })
+ const start = performance.now()
+ const carrier = await connectToSite(site.siteId, { makeCloud: opts => new CloudTunnelCarrier({ ...opts,
+ url: process.env['FTW_LIVE_CLOUD_URL'] ?? 'wss://127.0.0.1:18843/v1/app',
+ serviceKeys: [decodeBase64url(read('noise.pub'))], WebSocketImpl: Socket as unknown as typeof globalThis.WebSocket,
+ }) })
+ session.connect(carrier)
+ await streaming(session)
+ const firstMs = performance.now() - start
+ const taps: number[] = []
+ for (let i = 0; i < 30; i++) {
+ const then = performance.now()
+ const result = await session.command(OP_SET_MODE, { mode: 'self_consumption' }).promise
+ expect(result.state).toBe('applied')
+ taps.push(performance.now() - then)
+ }
+ for (let i = 0; i < 5; i++) { carrier.wake?.(); await streaming(session) }
+ expect(session.state.carrier).toBe('tunnel')
+ expect(site.cloudHome).toBeDefined()
+ taps.sort((a, b) => a - b)
+ console.log(JSON.stringify({ firstMs: Math.round(firstMs), commands: taps.length,
+ resultMedianMs: Math.round(taps[15]!), resultP95Ms: Math.round(taps[28]!), resumes: 5 }))
+ } finally { session.close(); auth.uninstall() }
+ }, 60_000)
+})
+
+async function streaming(session: Session): Promise {
+ const end = Date.now() + 15_000
+ while (session.state.phase !== 'streaming') {
+ if (Date.now() > end) throw new Error(`no stream: ${session.state.phase}`)
+ await new Promise(resolve => setTimeout(resolve, 10))
+ }
+}
diff --git a/tests/support/cloud-harness.ts b/tests/support/cloud-harness.ts
new file mode 100644
index 0000000..1af71d8
--- /dev/null
+++ b/tests/support/cloud-harness.ts
@@ -0,0 +1,108 @@
+import { keyPairFromSecret, HandshakeState, type KeyPair } from '$lib/crypto/noise'
+import { NoiseTransport } from '$lib/crypto/transport'
+import { cloudBody, cloudPrologue, unsigned, cloudFrame, readCloudFrame, readTunnel, type TunnelMessage } from '$lib/protocol/cloud'
+import { SimBox } from '$lib/sim/box'
+import { NoiseBox } from '$lib/sim/noise-box'
+
+export const HOME = 'fjzyr67buzpyncqmjliwgezvde'
+export const key = (n: number): KeyPair => keyPairFromSecret(new Uint8Array(32).fill(n))
+
+/** A Cloud protocol peer and a separate box, with both real Noise layers. */
+export function cloudHarness() {
+ const service = key(1)
+ const boxKey = key(2)
+ const device = key(3)
+ const code = new Uint8Array(16).fill(9)
+ const sim = new SimBox({ now: () => Date.now(), cloud: { home: HOME,
+ pk: new Uint8Array([4, ...new Uint8Array(64).fill(7)]), boot: new Uint8Array(16), sources: {}, loadpoints: {} } })
+ let paired = false
+ const box = new NoiseBox({ box: sim, staticKey: boxKey,
+ prologue: new Uint8Array([...new TextEncoder().encode('ftw.session.v1:'), ...boxKey.publicKey]),
+ acceptPairing: p => {
+ if (paired && !p.length) return true
+ paired = p.length === code.length && p.every((x, i) => x === code[i])
+ return paired
+ },
+ })
+ const errors: unknown[] = []
+ const sockets: Socket[] = []
+ const now: Record[] = []
+ const forwarded: TunnelMessage[] = []
+ const cloudReadings: Uint8Array[] = []
+ let refuse = 0
+ let silent = false
+
+ class Socket {
+ binaryType = 'arraybuffer'
+ bufferedAmount = 0
+ onopen: ((event: Event) => void) | null = null
+ onmessage: ((event: MessageEvent) => void) | null = null
+ onclose: ((event: CloseEvent) => void) | null = null
+ onerror: ((event: Event) => void) | null = null
+ closed = false
+ hs = HandshakeState.responder({ staticKey: service, prologue: cloudPrologue(HOME) })
+ transport: NoiseTransport | null = null
+ input = Promise.resolve()
+ sid = 0
+ unsub: () => void
+ constructor(readonly url: string) {
+ sockets.push(this)
+ this.unsub = box.onFrame(d => {
+ if (this.transport && !this.closed) this.tunnel({ sid: this.sid, d })
+ })
+ queueMicrotask(() => this.onopen?.(new Event('open')))
+ }
+ send(data: Uint8Array): void {
+ if (this.closed) return
+ this.input = this.input.then(async () => {
+ if (silent || this.closed) return
+ if (!this.transport) {
+ if (refuse) { this.end(refuse); return }
+ const envelope = cloudBody(await this.hs.readMessage(data))
+ const body = cloudBody(envelope['b'] as Uint8Array)
+ now.push(body)
+ const reply = await this.hs.writeMessage(unsigned('snapshot', { envelopes: [], online: true, lastSeenMs: 0, more: 0 }))
+ this.transport = new NoiseTransport(this.hs.split())
+ this.deliver(reply)
+ if (body['tun']) this.forward(body['tun'] as TunnelMessage)
+ } else {
+ const envelope = readCloudFrame(this.transport.decrypt(data))
+ if (envelope.t === 'tun') this.forward(readTunnel(envelope.b))
+ }
+ }).catch(error => {
+ if (error?.code === 'E_NOISE_AUTH') this.end(4403)
+ else errors.push(error)
+ })
+ }
+ forward(tun: TunnelMessage): void {
+ forwarded.push(tun)
+ if (!tun.d.length) { box.reset(); return }
+ if (tun.sid !== this.sid) box.reset()
+ this.sid = tun.sid
+ box.receive(tun.d)
+ }
+ tunnel(tun: TunnelMessage): void { this.deliver(this.transport!.encrypt(cloudFrame(unsigned('tun', tun)))) }
+ reading(): void {
+ // A service can lie about a readable copy. It must never enter the
+ // end-to-end Session, even if it says the house is live.
+ const msg = this.transport!.encrypt(cloudFrame(unsigned('live', { fields: { '2': 999999 } })))
+ cloudReadings.push(msg)
+ this.deliver(msg)
+ }
+ deliver(data: Uint8Array): void {
+ queueMicrotask(() => { if (!this.closed) this.onmessage?.(new MessageEvent('message', { data: data.slice().buffer })) })
+ }
+ end(code = 1006, reason = ''): void {
+ this.closed = true
+ this.unsub()
+ this.onclose?.(new CloseEvent('close', { code, reason }))
+ }
+ close(): void { this.closed = true; this.unsub() }
+ }
+
+ return { service, boxKey, device, code, sim, box, sockets, now, forwarded, errors,
+ WebSocketImpl: Socket as unknown as typeof WebSocket,
+ refuse(code: number) { refuse = code }, silent(on: boolean) { silent = on },
+ close() { sockets.forEach(s => s.close()); box.destroy() },
+ }
+}
From 9dea3741c3d533f9afbd98c229898383db919d83 Mon Sep 17 00:00:00 2001
From: Fredrik Ahlgren
Date: Sat, 17 Oct 2026 08:05:07 +0200
Subject: [PATCH 2/2] docs(client): describe the Cloud path and recovery size
Signed-off-by: Fredrik Ahlgren
---
src/lib/carrier/noise.ts | 4 ++--
src/lib/crypto/transport.ts | 9 ++++-----
src/lib/identity/recovery-blob.ts | 6 +++---
3 files changed, 9 insertions(+), 10 deletions(-)
diff --git a/src/lib/carrier/noise.ts b/src/lib/carrier/noise.ts
index 471f5b2..f53e335 100644
--- a/src/lib/carrier/noise.ts
+++ b/src/lib/carrier/noise.ts
@@ -1,10 +1,10 @@
/* The carrier that makes every other carrier private.
*
- * Wraps an inner carrier — relay today, a WebRTC DataChannel later — and runs
+ * Wraps an inner carrier, now the FTW Cloud tunnel, and runs
* the Noise IK handshake across it before a single application frame moves.
* Everything above sees an ordinary Carrier; everything below sees opaque
* bytes. That is the whole reason the relay can be blind without the session
- * layer knowing a relay exists.
+ * layer knowing which service carries it.
*
* The box's static key is pinned optically from the QR code at enrollment,
* which is what IK needs and what stops the relay presenting itself as a box:
diff --git a/src/lib/crypto/transport.ts b/src/lib/crypto/transport.ts
index 4e72984..f26efe3 100644
--- a/src/lib/crypto/transport.ts
+++ b/src/lib/crypto/transport.ts
@@ -7,11 +7,10 @@
* 8 body ChaCha20-Poly1305 over the frame, tag included
*
* Why a sequence number is on the wire at all: Noise's own counter is implicit
- * and assumes an ordered, lossless carrier. The relay is a WebSocket and gives
- * us that, but the LAN carrier is a WebRTC DataChannel, which may reorder and
- * may drop. Carrying the counter lets the receiver set the nonce for whatever
- * actually arrived, so the same code serves both carriers and a lost frame
- * costs one frame instead of the session.
+ * and assumes an ordered, lossless carrier. The box's Cloud uplink can use
+ * QUIC and reorder frames before they reach this client's WebSocket. Carrying
+ * the counter lets the receiver set the nonce for whatever arrived, so a lost
+ * frame costs one frame instead of the session.
*
* The eight bytes are a monotonic counter and nothing else. Frame size stays
* constant, which is the property lane 0 depends on.
diff --git a/src/lib/identity/recovery-blob.ts b/src/lib/identity/recovery-blob.ts
index 18ef557..44f6dc9 100644
--- a/src/lib/identity/recovery-blob.ts
+++ b/src/lib/identity/recovery-blob.ts
@@ -42,9 +42,9 @@ const SCALAR_BYTES = 32
/**
* The one length every sealed blob is written at.
*
- * One home costs 66 bytes plus its id and its name — 86 for a site id and
- * "Home". The fixed cost is 33 for the device key and the count, so five homes
- * with ordinary names still fit inside the payload budget below.
+ * Each home costs 93 bytes plus its site id and name when it names a Cloud
+ * home. The device key and count add 33 bytes. The encoder checks the total
+ * against the payload budget below.
*
* The escrow refuses a blob of any other length, and a test pins its constant
* to this one.