sync-transport.ts

15.0 KB · raw

1// WebSocket reconnect transport — Noise KK with both paired static keys.2// First pairing remains Noise NK; reconnect never negotiates or falls back.3//4// The extension is always the WebSocket client; Android (or future iOS /5// native sync hosts) is always the server. Each binary WebSocket message6// carries exactly one Noise frame — no length prefix, no fragmentation.7//8// On connect:9//   1. Open ws://<ip>:<port>/10//   2. Send PEACH_RECONNECT with an opaque rotating rendezvous capability so11//      the listener can select paired local state without exposing identities.12//   3. Run standard Noise KK using both locally-known static keys.13//   4. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame14//      Android's SyncReconnectListener expects before it will treat the15//      connection as sync traffic rather than an unknown caller). Our16//      identity is bound to the static key authenticated by KK.17//   5. Hand the caller a `SyncSession` for the SyncMessage protocol.18//19// Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`20// when the cached last-known IP fails. We use a short connection timeout21// (~500ms per IP) and parallel batches of 16 to keep the worst-case scan22// under ~10 seconds.2324import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";25import { SYNC_LIMITS } from "./sync-limits";26import { hexToPubkey } from "./sync-identity";27import { buildRendezvousUrl } from "./rendezvous";28import type { DeviceType } from "./sync-types";2930// E6 (session rekeying) is deliberately NOT wired, and this is a reality check31// against the spec rather than an omission. E6 targets "long-lived sync32// sessions"; Peach has none. A session is one-shot — roughly ten frames each33// way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then close — and every34// reconnect runs a fresh Noise KK handshake with fresh ephemerals. That already35// gives stronger post-compromise security than intra-session Rekey() would,36// because no two sessions share key material at all (pinned by the E6 test in37// noise-kk.test.ts).38//39// Wiring Rekey() anyway would mean both ends rekeying at the identical frame40// index with no wire signal to resynchronise on. One frame of drift between41// browser and Android breaks all sync — the exact failure class this protocol42// has already been burned by twice. The primitives are exposed on NoiseSession43// (rekeyOutgoing/rekeyIncoming) if a genuinely long-lived session ever appears;44// until then the cost is real and the benefit is zero.45const DEFAULT_PROBE_TIMEOUT_MS = 500;46const DEFAULT_PROBE_BATCH = 16;47const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;48const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;49/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */50export const SUBNET_PROBE_COOLDOWN_MS = 60_000;5152export interface SyncSession {53  send(plaintext: Uint8Array): void;54  receive(): Promise<Uint8Array>;55  close(): Promise<void>;56  remoteStaticPublicKey: Uint8Array;57}5859export interface SyncEndpoint {60  ip: string;61  port: number;62}6364export interface ProbedSyncEndpoint extends SyncEndpoint {65  /** Exact clock-skew window capability that completed KK at this endpoint. */66  rendezvousId: string;67}6869/** Our identity, repeated in the post-handshake PAIR_IDENTITY frame and bound70 *  to the local static key already authenticated by KK. */71export interface SyncPeerIdentity {72  deviceId: string;73  deviceName: string;74  deviceType: DeviceType;75  longTermPublicKeyHex: string;76  /** Local-only key material for the KK reconnect handshake; never encoded. */77  longTermPrivateKey: Uint8Array;78  signingPublicKeyHex?: string;79}8081export class SyncFrameTooLargeError extends Error {82  constructor(message = "sync frame too large (WebSocket close 1009)") {83    super(message);84    this.name = "SyncFrameTooLargeError";85  }86}8788export interface SyncSessionOptions {89  connectTimeoutMs?: number;90  handshakeTimeoutMs?: number;91}9293function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {94  return new TextEncoder().encode(JSON.stringify({95    type: "PAIR_IDENTITY",96    device_id: id.deviceId,97    device_name: id.deviceName,98    device_type: id.deviceType,99    long_term_public_key_hex: id.longTermPublicKeyHex,100    signing_public_key_hex: id.signingPublicKeyHex,101  }));102}103104/**105 * Open a WebSocket to (ip, port), run Noise KK against both paired static106 * keys, exchange bound PAIR_IDENTITY frames, and return a ready-to-use107 * session.108 *109 * On a successful return, `setLastKnownIp` in the caller can persist110 * (ip, port) for the next session's fast path.111 */112export async function openSyncSession(113  endpoint: SyncEndpoint,114  ourIdentity: SyncPeerIdentity,115  expectedRemotePubkeyHex: string,116  rendezvousId: string,117  options: SyncSessionOptions = {},118): Promise<SyncSession> {119  return openSyncSessionAtUrl(120    `ws://${endpoint.ip}:${endpoint.port}/`,121    "LAN peer",122    ourIdentity,123    expectedRemotePubkeyHex,124    rendezvousId,125    options,126  );127}128129/**130 * Relay reconnect preserves the exact LAN wire protocol; only the WebSocket131 * target changes. The URL builder enforces credential-free `wss://`.132 */133export async function openRelaySyncSession(134  relayUrl: string,135  ourIdentity: SyncPeerIdentity,136  expectedRemotePubkeyHex: string,137  rendezvousId: string,138  options: SyncSessionOptions = {},139): Promise<SyncSession> {140  return openSyncSessionAtUrl(141    buildRendezvousUrl(relayUrl, rendezvousId),142    "relay",143    ourIdentity,144    expectedRemotePubkeyHex,145    rendezvousId,146    options,147  );148}149150async function openSyncSessionAtUrl(151  url: string,152  targetLabel: string,153  ourIdentity: SyncPeerIdentity,154  expectedRemotePubkeyHex: string,155  rendezvousId: string,156  options: SyncSessionOptions,157): Promise<SyncSession> {158  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {159    throw new Error("reconnect requires a valid rotating rendezvous id");160  }161  const ws = await connectWebSocketUrl(162    url,163    targetLabel,164    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,165  );166  const inbox: Uint8Array[] = [];167  const readers: Array<(b: Uint8Array) => void> = [];168  let closed = false;169  let closeError: Error = new Error("WebSocket closed");170171  ws.binaryType = "arraybuffer";172  ws.onmessage = (ev: MessageEvent) => {173    const data = ev.data;174    if (data instanceof ArrayBuffer) {175      // MV-3: refuse oversized frames before they are buffered or decrypted.176      // A peer that sends one is misbehaving — tear the session down rather177      // than letting the inbox grow toward OOM.178      if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {179        // MV-3: oversized frame — tear the session down. close() routes through180        // onclose, which rejects any parked receive(), so no error array is181        // needed (PCH-01-026 #9).182        console.warn(183          `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,184        );185        closeError = new SyncFrameTooLargeError(186          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,187        );188        try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }189        return;190      }191      const bytes = new Uint8Array(data);192      const r = readers.shift();193      if (r) r(bytes);194      else inbox.push(bytes);195    }196  };197  ws.onerror = () => {198    // PCH-01-026 #9: surface the error and unblock any parked receive() by199    // closing. Browsers usually follow error with close, but not always; an200    // explicit close guarantees the handshake fails fast instead of hanging.201    console.warn("[Peach] sync WebSocket error; closing transport");202    try { ws.close(); } catch { /* ignore */ }203  };204  ws.onclose = (event: CloseEvent) => {205    closed = true;206    if (event.code === 1009) {207      closeError = new SyncFrameTooLargeError();208    }209    while (readers.length > 0) {210      const r = readers.shift()!;211      r(new Uint8Array(0));212    }213  };214215  function send(bytes: Uint8Array): Promise<void> {216    return new Promise((resolve, reject) => {217      if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {218        reject(219          new SyncFrameTooLargeError(220            `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,221          ),222        );223        return;224      }225      try {226        ws.send(bytes as Uint8Array<ArrayBuffer>);227        resolve();228      } catch (err) {229        reject(err as Error);230      }231    });232  }233  function receive(): Promise<Uint8Array> {234    if (closed) return Promise.reject(closeError);235    const v = inbox.shift();236    if (v) return Promise.resolve(v);237    return new Promise<Uint8Array>((resolve, reject) => {238      readers.push((b) => {239        if (b.length === 0 && closed) reject(closeError);240        else resolve(b);241      });242    });243  }244245  const handshakeTimer = setTimeout(() => {246    closeError = new Error("sync_handshake_timeout");247    try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }248  }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);249  let session: NoiseNKSession;250  try {251    const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);252    if (ourIdentity.longTermPrivateKey.length !== 32) {253      throw new Error("local reconnect private key must be exactly 32 bytes");254    }255    // This opaque rotating capability lets Android select paired local state256    // without exposing either public key to a relay. It grants no identity257    // trust; KK still proves both private keys immediately afterwards.258    await send(new TextEncoder().encode(JSON.stringify({259      type: "PEACH_RECONNECT",260      rendezvous_id: rendezvousId,261    })));262    session = await noiseKKInitiator(263      ourIdentity.longTermPrivateKey,264      ourPublicKey,265      hexToPubkey(expectedRemotePubkeyHex),266      send,267      receive,268    );269  } catch (err) {270    clearTimeout(handshakeTimer);271    try { ws.close(); } catch { /* ignore */ }272    throw err;273  }274275  try {276    await send(session.encrypt(encodePairIdentity(ourIdentity)));277    const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));278    if (theirFrame?.type !== "PAIR_IDENTITY") {279      throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);280    }281    if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {282      throw new Error("peer identity does not match authenticated KK key");283    }284    clearTimeout(handshakeTimer);285  } catch (err) {286    clearTimeout(handshakeTimer);287    session.close();288    try { ws.close(); } catch { /* ignore */ }289    throw err;290  }291292  return {293    send(plaintext: Uint8Array) {294      const ciphertext = session.encrypt(plaintext);295      if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {296        throw new SyncFrameTooLargeError(297          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,298        );299      }300      ws.send(ciphertext as Uint8Array<ArrayBuffer>);301    },302    async receive() {303      const ct = await receive();304      return session.decrypt(ct);305    },306    async close() {307      try { ws.close(); } catch { /* ignore */ }308      session.close();309    },310    remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),311  };312}313314function connectWebSocketUrl(315  url: string,316  targetLabel: string,317  timeoutMs: number,318): Promise<WebSocket> {319  return new Promise((resolve, reject) => {320    let ws: WebSocket;321    try {322      ws = new WebSocket(url);323    } catch (err) {324      reject(err as Error);325      return;326    }327    const t = setTimeout(() => {328      try { ws.close(); } catch { /* ignore */ }329      reject(new Error(`${targetLabel} WebSocket connect timed out`));330    }, timeoutMs);331    ws.onopen = () => {332      clearTimeout(t);333      resolve(ws);334    };335    ws.onerror = () => {336      clearTimeout(t);337      reject(new Error(`${targetLabel} WebSocket connect errored`));338    };339  });340}341342/**343 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps344 * full /24 scans at once per minute so an extension whose cached IP has345 * gone stale doesn't hammer the LAN with handshake attempts every time346 * the popup opens.347 *348 * Keyed by the expected remote pubkey because each paired peer is probed349 * independently; the rate limit applies to "scan looking for THIS peer",350 * not "scan the subnet at all".351 */352const lastProbeAt = new Map<string, number>();353354export function subnetProbeOnCooldown(355  expectedRemotePubkeyHex: string,356  now: number = Date.now(),357  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,358): boolean {359  const last = lastProbeAt.get(expectedRemotePubkeyHex);360  return last !== undefined && now - last < cooldownMs;361}362363/** Test-only — clear the per-pubkey probe cooldown ledger. */364export function _resetSubnetProbeCooldown(): void {365  lastProbeAt.clear();366}367368/**369 * Subnet probe — scan a /24 looking for a host that completes Noise KK370 * with the expected long-term static key. Returns the first match, or371 * null if the cooldown is still active or no host responded.372 *373 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey374 * (v2 brief §5.2). Callers should still attempt the cached375 * `last_known_ip`/`last_known_port` directly first — the subnet probe is376 * fallback only.377 *378 * For each IP we attempt the full Noise KK handshake, but with a tight379 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser380 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.381 */382export async function probeSubnetForPeer(383  subnet: string, // e.g. "192.168.1"384  port: number,385  ourIdentity: SyncPeerIdentity,386  expectedRemotePubkeyHex: string,387  rendezvousIds: readonly string[],388  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},389): Promise<ProbedSyncEndpoint | null> {390  if (rendezvousIds.length === 0) {391    throw new Error("subnet probe requires at least one rendezvous id");392  }393  const now = options.now ?? Date.now();394  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;395  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {396    return null;397  }398  lastProbeAt.set(expectedRemotePubkeyHex, now);399  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;400  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;401  for (let start = 1; start <= 254; start += batchSize) {402    const end = Math.min(start + batchSize - 1, 254);403    const ips: string[] = [];404    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);405406    const results = await Promise.all(ips.map((ip) => probeOne(407      { ip, port },408      ourIdentity,409      expectedRemotePubkeyHex,410      rendezvousIds,411      timeoutMs,412    )));413    const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);414    if (hit) return hit;415  }416  return null;417}418419async function probeOne(420  endpoint: SyncEndpoint,421  ourIdentity: SyncPeerIdentity,422  expectedRemotePubkeyHex: string,423  rendezvousIds: readonly string[],424  timeoutMs: number,425): Promise<ProbedSyncEndpoint | null> {426  for (const rendezvousId of rendezvousIds) {427    try {428      const session = await openSyncSession(429        endpoint,430        ourIdentity,431        expectedRemotePubkeyHex,432        rendezvousId,433        { connectTimeoutMs: timeoutMs },434      );435      await session.close();436      return { ...endpoint, rendezvousId };437    } catch {438      // Try the next locally-derived clock-skew window at the same host.439    }440  }441  return null;442}