// WebSocket reconnect transport — Noise KK with both paired static keys. // First pairing remains Noise NK; reconnect never negotiates or falls back. // // The lower long-term public key is the WebSocket/Noise initiator; the higher // key holds the relay room as responder. Device type does not affect the role, // so two extensions can sync directly without a native hub. Each binary // WebSocket message carries one Noise frame (large padded application frames // are split across multiple Noise messages below). // // On connect: // 1. Open wss://// // 2. Send PEACH_RECONNECT with an opaque rotating rendezvous capability so // the listener can select paired local state without exposing identities. // 3. Run standard Noise KK using both locally-known static keys. // 4. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame // Android's SyncReconnectListener expects before it will treat the // connection as sync traffic rather than an unknown caller). Our // identity is bound to the static key authenticated by KK. // 5. Hand the caller a `SyncSession` for the SyncMessage protocol. // // Subnet probe falls back to scanning the /24 returned by `getLocalSubnet` // when the cached last-known IP fails. We use a short connection timeout // (~500ms per IP) and parallel batches of 16 to keep the worst-case scan // under ~10 seconds. import { noiseKKInitiator, noiseKKResponder, type NoiseNKSession, } from "./noise-js"; import { SYNC_LIMITS } from "./sync-limits"; import { padSyncFrame, unpadSyncFrame, MAX_NOISE_PLAINTEXT_BYTES, FRAME_CONTINUATION_MARKER, chunkPaddedSyncFrame, } from "./sync-padding"; import { hexToPubkey } from "./sync-identity"; import { buildRendezvousUrl } from "./rendezvous"; import type { DeviceType } from "./sync-types"; // E6 (session rekeying) is deliberately NOT wired, and this is a reality check // against the spec rather than an omission. E6 targets "long-lived sync // sessions"; the browser has none. Sessions opened here are one-shot — roughly // ten frames each way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then // sync-runner closes them — and every reconnect runs a fresh Noise KK handshake // with fresh ephemerals. (Android's listener does park a connection open after // a round so it can act as a relay hub for another native peer, but the browser // drives the lifetime of every session it opens, and closes it.) That already // gives stronger post-compromise security than intra-session Rekey() would, // because no two sessions share key material at all (pinned by the E6 test in // noise-kk.test.ts). // // Wiring Rekey() anyway would mean both ends rekeying at the identical frame // index with no wire signal to resynchronise on. One frame of drift between // browser and Android breaks all sync — the exact failure class this protocol // has already been burned by twice. The primitives are exposed on NoiseSession // (rekeyOutgoing/rekeyIncoming) if a genuinely long-lived session ever appears; // until then the cost is real and the benefit is zero. const DEFAULT_CONNECT_TIMEOUT_MS = 4_000; const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000; export const RELAY_WAITER_KEEPALIVE_MS = 20_000; export interface SyncSession { send(plaintext: Uint8Array): void; receive(): Promise; close(): Promise; remoteStaticPublicKey: Uint8Array; } /** Our identity, repeated in the post-handshake PAIR_IDENTITY frame and bound * to the local static key already authenticated by KK. */ export interface SyncPeerIdentity { deviceId: string; deviceName: string; deviceType: DeviceType; longTermPublicKeyHex: string; /** Local-only key material for the KK reconnect handshake; never encoded. */ longTermPrivateKey: Uint8Array; signingPublicKeyHex?: string; } export class SyncFrameTooLargeError extends Error { constructor(message = "sync frame too large (WebSocket close 1009)") { super(message); this.name = "SyncFrameTooLargeError"; } } export interface SyncSessionOptions { connectTimeoutMs?: number; handshakeTimeoutMs?: number; signal?: AbortSignal; } function encodePairIdentity(id: SyncPeerIdentity): Uint8Array { return new TextEncoder().encode(JSON.stringify({ type: "PAIR_IDENTITY", device_id: id.deviceId, device_name: id.deviceName, device_type: id.deviceType, long_term_public_key_hex: id.longTermPublicKeyHex, signing_public_key_hex: id.signingPublicKeyHex, })); } /** * Relay reconnect preserves the existing Noise KK wire protocol. The URL * builder enforces credential-free `wss://`. */ export async function openRelaySyncSession( relayUrl: string, ourIdentity: SyncPeerIdentity, expectedRemotePubkeyHex: string, rendezvousId: string, options: SyncSessionOptions = {}, ): Promise { return openSyncSessionAtUrl( buildRendezvousUrl(relayUrl, rendezvousId), "relay", ourIdentity, expectedRemotePubkeyHex, rendezvousId, options, ); } /** * Hold one relay room as the deterministic responder, then run Noise KK and * the bound PAIR_IDENTITY exchange. The zero-length liveness frame keeps a * Chrome 116+ MV3 service worker alive while the room is empty; the Peach * relay consumes that frame locally and never forwards it to the peer. */ export async function openRelaySyncResponderSession( relayUrl: string, ourIdentity: SyncPeerIdentity, expectedRemotePubkeyHex: string, rendezvousId: string, options: SyncSessionOptions & { keepaliveMs?: number } = {}, ): Promise { if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) { throw new Error("reconnect requires a valid rotating rendezvous id"); } if (ourIdentity.longTermPrivateKey.length !== 32) { throw new Error("local reconnect private key must be exactly 32 bytes"); } const ws = await connectWebSocketUrl( buildRendezvousUrl(relayUrl, rendezvousId), "relay", options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, ); const inbox: Uint8Array[] = []; const readers: Array<(b: Uint8Array) => void> = []; let closed = false; let closeError: Error = new Error("WebSocket closed"); const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS; const abortWaiter = () => { try { ws.close(1000, "relay waiter replaced"); } catch { /* ignore */ } }; const keepalive = setInterval(() => { if (ws.readyState !== WebSocket.OPEN) return; try { ws.send(new Uint8Array(0)); } catch { /* onclose owns cleanup */ } }, keepaliveMs); ws.binaryType = "arraybuffer"; ws.onmessage = (event: MessageEvent) => { if (!(event.data instanceof ArrayBuffer)) return; if (event.data.byteLength === 0) return; if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { closeError = new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ } return; } const bytes = new Uint8Array(event.data); const reader = readers.shift(); if (reader) reader(bytes); else inbox.push(bytes); }; ws.onerror = () => { try { ws.close(); } catch { /* ignore */ } }; ws.onclose = (event: CloseEvent) => { clearInterval(keepalive); options.signal?.removeEventListener("abort", abortWaiter); closed = true; if (event.code === 1009) closeError = new SyncFrameTooLargeError(); while (readers.length > 0) readers.shift()!(new Uint8Array(0)); }; // Install close handling before honoring an already-aborted signal so a // synchronous/mock close cannot strand the first receive() forever. options.signal?.addEventListener("abort", abortWaiter, { once: true }); if (options.signal?.aborted) abortWaiter(); const send = async (bytes: Uint8Array): Promise => { if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { throw new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); } ws.send(bytes as Uint8Array); }; const receive = (): Promise => { if (closed) return Promise.reject(closeError); const buffered = inbox.shift(); if (buffered) return Promise.resolve(buffered); return new Promise((resolve, reject) => { readers.push((bytes) => { if (bytes.length === 0 && closed) reject(closeError); else resolve(bytes); }); }); }; let noiseSession: NoiseNKSession | undefined; try { const selectorBytes = await receive(); const selector = JSON.parse(new TextDecoder().decode(selectorBytes)); if ( selector?.type !== "PEACH_RECONNECT" || typeof selector.rendezvous_id !== "string" || !equalAscii(selector.rendezvous_id, rendezvousId) ) { throw new Error("reconnect capability does not match relay room"); } const handshakeTimer = setTimeout(() => { closeError = new Error("sync_handshake_timeout"); try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ } }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS); try { const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex); noiseSession = await noiseKKResponder( ourIdentity.longTermPrivateKey, ourPublicKey, hexToPubkey(expectedRemotePubkeyHex), send, receive, ); const theirFrame = JSON.parse( new TextDecoder().decode( unpadSyncFrame(noiseSession.decrypt(await receive())), ), ); if (theirFrame?.type !== "PAIR_IDENTITY") { throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`); } if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) { throw new Error("peer identity does not match authenticated KK key"); } await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity)))); } finally { clearTimeout(handshakeTimer); } } catch (error) { clearInterval(keepalive); options.signal?.removeEventListener("abort", abortWaiter); noiseSession?.close(); try { ws.close(); } catch { /* ignore */ } throw error; } return buildEncryptedSyncSession( ws, noiseSession, expectedRemotePubkeyHex, receive, () => { clearInterval(keepalive); options.signal?.removeEventListener("abort", abortWaiter); }, ); } export async function openSyncSessionAtUrl( url: string, targetLabel: string, ourIdentity: SyncPeerIdentity, expectedRemotePubkeyHex: string, rendezvousId: string, options: SyncSessionOptions = {}, ): Promise { if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) { throw new Error("reconnect requires a valid rotating rendezvous id"); } const ws = await connectWebSocketUrl( url, targetLabel, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS, ); const inbox: Uint8Array[] = []; const readers: Array<(b: Uint8Array) => void> = []; let closed = false; let closeError: Error = new Error("WebSocket closed"); ws.binaryType = "arraybuffer"; ws.onmessage = (ev: MessageEvent) => { const data = ev.data; if (data instanceof ArrayBuffer) { // MV-3: refuse oversized frames before they are buffered or decrypted. // A peer that sends one is misbehaving — tear the session down rather // than letting the inbox grow toward OOM. if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { // MV-3: oversized frame — tear the session down. close() routes through // onclose, which rejects any parked receive(), so no error array is // needed (PCH-01-026 #9). console.warn( `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`, ); closeError = new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ } return; } const bytes = new Uint8Array(data); const r = readers.shift(); if (r) r(bytes); else inbox.push(bytes); } }; ws.onerror = () => { // PCH-01-026 #9: surface the error and unblock any parked receive() by // closing. Browsers usually follow error with close, but not always; an // explicit close guarantees the handshake fails fast instead of hanging. console.warn("[Peach] sync WebSocket error; closing transport"); try { ws.close(); } catch { /* ignore */ } }; ws.onclose = (event: CloseEvent) => { closed = true; if (event.code === 1009) { closeError = new SyncFrameTooLargeError(); } while (readers.length > 0) { const r = readers.shift()!; r(new Uint8Array(0)); } }; function send(bytes: Uint8Array): Promise { return new Promise((resolve, reject) => { if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { reject( new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ), ); return; } try { ws.send(bytes as Uint8Array); resolve(); } catch (err) { reject(err as Error); } }); } function receive(): Promise { if (closed) return Promise.reject(closeError); const v = inbox.shift(); if (v) return Promise.resolve(v); return new Promise((resolve, reject) => { readers.push((b) => { if (b.length === 0 && closed) reject(closeError); else resolve(b); }); }); } const handshakeTimer = setTimeout(() => { closeError = new Error("sync_handshake_timeout"); try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ } }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS); let session: NoiseNKSession; try { const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex); if (ourIdentity.longTermPrivateKey.length !== 32) { throw new Error("local reconnect private key must be exactly 32 bytes"); } // This opaque rotating capability lets Android select paired local state // without exposing either public key to a relay. It grants no identity // trust; KK still proves both private keys immediately afterwards. await send(new TextEncoder().encode(JSON.stringify({ type: "PEACH_RECONNECT", rendezvous_id: rendezvousId, }))); session = await noiseKKInitiator( ourIdentity.longTermPrivateKey, ourPublicKey, hexToPubkey(expectedRemotePubkeyHex), send, receive, ); } catch (err) { clearTimeout(handshakeTimer); try { ws.close(); } catch { /* ignore */ } throw err; } try { await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity)))); const theirFrame = JSON.parse( new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))), ); if (theirFrame?.type !== "PAIR_IDENTITY") { throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`); } if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) { throw new Error("peer identity does not match authenticated KK key"); } clearTimeout(handshakeTimer); } catch (err) { clearTimeout(handshakeTimer); session.close(); try { ws.close(); } catch { /* ignore */ } throw err; } return buildEncryptedSyncSession( ws, session, expectedRemotePubkeyHex, receive, ); } // Exported for the PB-26-002 reassembly-bound regression test. export function buildEncryptedSyncSession( ws: WebSocket, session: NoiseNKSession, expectedRemotePubkeyHex: string, receiveCiphertext: () => Promise, beforeClose: () => void = () => undefined, ): SyncSession { return { async send(plaintext: Uint8Array) { // E7: bucket the size before encrypting, so the relay sees frame shapes // that no longer distinguish one operation (or vault size) from another. // E7b: a padded frame can legitimately be up to 24 MiB, but Noise caps a // single transport message at 65535 bytes (see crypto-core/src/lib.rs — // 65519 plaintext, 16-byte tag). We split a too-large padded frame into // numbered Noise messages and tag every chunk after the first with a // 1-byte continuation header. The receiver reassembles by reading the // padded frame's 4-byte length header, computing the same bucket from // the same ladder, and keeping reading until the bucket is full. const padded = padSyncFrame(plaintext); if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { throw new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); } // A single-chunk frame goes out as one Noise message, exactly as before // — no continuation byte, no per-chunk overhead, so the common small // case is unchanged on the wire. if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) { const ct = session.encrypt(padded); ws.send(ct as Uint8Array); return; } for (const plaintext of chunkPaddedSyncFrame(padded)) { if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) { throw new Error( `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`, ); } const ct = session.encrypt(plaintext); if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { throw new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); } ws.send(ct as Uint8Array); } }, async receive() { // The first Noise message of a frame is the first one we read. If it // begins with the continuation marker, the message is one of N chunks // — the padded-frame length header comes inside the first chunk's // payload, so we read it there to learn the total. let buffered: Uint8Array | null = null; while (true) { const ct = await receiveCiphertext(); const pt = session.decrypt(ct); if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) { // Strip the continuation byte and keep the payload. buffered = pt.subarray(1); continue; } if (buffered !== null) { if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) { const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1); merged.set(buffered, 0); merged.set(pt.subarray(1), buffered.length); buffered = merged; // PB-26-002: the frame bound must hold DURING reassembly. The // post-receive check in the coordinator runs only after the full // frame is in memory, so a peer that completes the Noise KK // handshake could otherwise stream continuation chunks without // bound inside the session window. if (buffered.length > SYNC_LIMITS.MAX_FRAME_BYTES) { throw new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); } continue; } // Final chunk. PB-26-602: the continuation loop only bounds what is // buffered, so the last chunk could push the frame up to 64 KiB past // the cap. Bound the concatenation, not just the buffer. if (buffered.length + pt.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) { throw new SyncFrameTooLargeError( `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`, ); } const reassembled = new Uint8Array(buffered.length + pt.byteLength); reassembled.set(buffered, 0); reassembled.set(pt, buffered.length); return unpadSyncFrame(reassembled); } // Single-chunk frame, exactly as before. return unpadSyncFrame(pt); } }, async close() { beforeClose(); try { ws.close(); } catch { /* ignore */ } session.close(); }, remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex), }; } function equalAscii(left: string, right: string): boolean { const a = new TextEncoder().encode(left); const b = new TextEncoder().encode(right); if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i]; return diff === 0; } function connectWebSocketUrl( url: string, targetLabel: string, timeoutMs: number, ): Promise { return new Promise((resolve, reject) => { let ws: WebSocket; try { ws = new WebSocket(url); } catch (err) { reject(err as Error); return; } const t = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } reject(new Error(`${targetLabel} WebSocket connect timed out`)); }, timeoutMs); ws.onopen = () => { clearTimeout(t); resolve(ws); }; ws.onerror = () => { clearTimeout(t); reject(new Error(`${targetLabel} WebSocket connect errored`)); }; }); }