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.