sync-transport.ts

15.5 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 { padSyncFrame, unpadSyncFrame } from "./sync-padding";27import { hexToPubkey } from "./sync-identity";28import { buildRendezvousUrl } from "./rendezvous";29import type { DeviceType } from "./sync-types";3031// E6 (session rekeying) is deliberately NOT wired, and this is a reality check32// against the spec rather than an omission. E6 targets "long-lived sync33// sessions"; the browser has none. Sessions opened here are one-shot — roughly34// ten frames each way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then35// sync-runner closes them — and every reconnect runs a fresh Noise KK handshake36// with fresh ephemerals. (Android's listener does park a connection open after37// a round so it can act as a relay hub for another native peer, but the browser38// drives the lifetime of every session it opens, and closes it.) That already39// gives stronger post-compromise security than intra-session Rekey() would,40// because no two sessions share key material at all (pinned by the E6 test in41// noise-kk.test.ts).42//43// Wiring Rekey() anyway would mean both ends rekeying at the identical frame44// index with no wire signal to resynchronise on. One frame of drift between45// browser and Android breaks all sync — the exact failure class this protocol46// has already been burned by twice. The primitives are exposed on NoiseSession47// (rekeyOutgoing/rekeyIncoming) if a genuinely long-lived session ever appears;48// until then the cost is real and the benefit is zero.49const DEFAULT_PROBE_TIMEOUT_MS = 500;50const DEFAULT_PROBE_BATCH = 16;51const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;52const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;53/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */54export const SUBNET_PROBE_COOLDOWN_MS = 60_000;5556export interface SyncSession {57  send(plaintext: Uint8Array): void;58  receive(): Promise<Uint8Array>;59  close(): Promise<void>;60  remoteStaticPublicKey: Uint8Array;61}6263export interface SyncEndpoint {64  ip: string;65  port: number;66}6768export interface ProbedSyncEndpoint extends SyncEndpoint {69  /** Exact clock-skew window capability that completed KK at this endpoint. */70  rendezvousId: string;71}7273/** Our identity, repeated in the post-handshake PAIR_IDENTITY frame and bound74 *  to the local static key already authenticated by KK. */75export interface SyncPeerIdentity {76  deviceId: string;77  deviceName: string;78  deviceType: DeviceType;79  longTermPublicKeyHex: string;80  /** Local-only key material for the KK reconnect handshake; never encoded. */81  longTermPrivateKey: Uint8Array;82  signingPublicKeyHex?: string;83}8485export class SyncFrameTooLargeError extends Error {86  constructor(message = "sync frame too large (WebSocket close 1009)") {87    super(message);88    this.name = "SyncFrameTooLargeError";89  }90}9192export interface SyncSessionOptions {93  connectTimeoutMs?: number;94  handshakeTimeoutMs?: number;95}9697function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {98  return new TextEncoder().encode(JSON.stringify({99    type: "PAIR_IDENTITY",100    device_id: id.deviceId,101    device_name: id.deviceName,102    device_type: id.deviceType,103    long_term_public_key_hex: id.longTermPublicKeyHex,104    signing_public_key_hex: id.signingPublicKeyHex,105  }));106}107108/**109 * Open a WebSocket to (ip, port), run Noise KK against both paired static110 * keys, exchange bound PAIR_IDENTITY frames, and return a ready-to-use111 * session.112 *113 * On a successful return, `setLastKnownIp` in the caller can persist114 * (ip, port) for the next session's fast path.115 */116export async function openSyncSession(117  endpoint: SyncEndpoint,118  ourIdentity: SyncPeerIdentity,119  expectedRemotePubkeyHex: string,120  rendezvousId: string,121  options: SyncSessionOptions = {},122): Promise<SyncSession> {123  return openSyncSessionAtUrl(124    `ws://${endpoint.ip}:${endpoint.port}/`,125    "LAN peer",126    ourIdentity,127    expectedRemotePubkeyHex,128    rendezvousId,129    options,130  );131}132133/**134 * Relay reconnect preserves the exact LAN wire protocol; only the WebSocket135 * target changes. The URL builder enforces credential-free `wss://`.136 */137export async function openRelaySyncSession(138  relayUrl: string,139  ourIdentity: SyncPeerIdentity,140  expectedRemotePubkeyHex: string,141  rendezvousId: string,142  options: SyncSessionOptions = {},143): Promise<SyncSession> {144  return openSyncSessionAtUrl(145    buildRendezvousUrl(relayUrl, rendezvousId),146    "relay",147    ourIdentity,148    expectedRemotePubkeyHex,149    rendezvousId,150    options,151  );152}153154async function openSyncSessionAtUrl(155  url: string,156  targetLabel: string,157  ourIdentity: SyncPeerIdentity,158  expectedRemotePubkeyHex: string,159  rendezvousId: string,160  options: SyncSessionOptions,161): Promise<SyncSession> {162  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {163    throw new Error("reconnect requires a valid rotating rendezvous id");164  }165  const ws = await connectWebSocketUrl(166    url,167    targetLabel,168    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,169  );170  const inbox: Uint8Array[] = [];171  const readers: Array<(b: Uint8Array) => void> = [];172  let closed = false;173  let closeError: Error = new Error("WebSocket closed");174175  ws.binaryType = "arraybuffer";176  ws.onmessage = (ev: MessageEvent) => {177    const data = ev.data;178    if (data instanceof ArrayBuffer) {179      // MV-3: refuse oversized frames before they are buffered or decrypted.180      // A peer that sends one is misbehaving — tear the session down rather181      // than letting the inbox grow toward OOM.182      if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {183        // MV-3: oversized frame — tear the session down. close() routes through184        // onclose, which rejects any parked receive(), so no error array is185        // needed (PCH-01-026 #9).186        console.warn(187          `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,188        );189        closeError = new SyncFrameTooLargeError(190          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,191        );192        try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }193        return;194      }195      const bytes = new Uint8Array(data);196      const r = readers.shift();197      if (r) r(bytes);198      else inbox.push(bytes);199    }200  };201  ws.onerror = () => {202    // PCH-01-026 #9: surface the error and unblock any parked receive() by203    // closing. Browsers usually follow error with close, but not always; an204    // explicit close guarantees the handshake fails fast instead of hanging.205    console.warn("[Peach] sync WebSocket error; closing transport");206    try { ws.close(); } catch { /* ignore */ }207  };208  ws.onclose = (event: CloseEvent) => {209    closed = true;210    if (event.code === 1009) {211      closeError = new SyncFrameTooLargeError();212    }213    while (readers.length > 0) {214      const r = readers.shift()!;215      r(new Uint8Array(0));216    }217  };218219  function send(bytes: Uint8Array): Promise<void> {220    return new Promise((resolve, reject) => {221      if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {222        reject(223          new SyncFrameTooLargeError(224            `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,225          ),226        );227        return;228      }229      try {230        ws.send(bytes as Uint8Array<ArrayBuffer>);231        resolve();232      } catch (err) {233        reject(err as Error);234      }235    });236  }237  function receive(): Promise<Uint8Array> {238    if (closed) return Promise.reject(closeError);239    const v = inbox.shift();240    if (v) return Promise.resolve(v);241    return new Promise<Uint8Array>((resolve, reject) => {242      readers.push((b) => {243        if (b.length === 0 && closed) reject(closeError);244        else resolve(b);245      });246    });247  }248249  const handshakeTimer = setTimeout(() => {250    closeError = new Error("sync_handshake_timeout");251    try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }252  }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);253  let session: NoiseNKSession;254  try {255    const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);256    if (ourIdentity.longTermPrivateKey.length !== 32) {257      throw new Error("local reconnect private key must be exactly 32 bytes");258    }259    // This opaque rotating capability lets Android select paired local state260    // without exposing either public key to a relay. It grants no identity261    // trust; KK still proves both private keys immediately afterwards.262    await send(new TextEncoder().encode(JSON.stringify({263      type: "PEACH_RECONNECT",264      rendezvous_id: rendezvousId,265    })));266    session = await noiseKKInitiator(267      ourIdentity.longTermPrivateKey,268      ourPublicKey,269      hexToPubkey(expectedRemotePubkeyHex),270      send,271      receive,272    );273  } catch (err) {274    clearTimeout(handshakeTimer);275    try { ws.close(); } catch { /* ignore */ }276    throw err;277  }278279  try {280    await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));281    const theirFrame = JSON.parse(282      new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),283    );284    if (theirFrame?.type !== "PAIR_IDENTITY") {285      throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);286    }287    if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {288      throw new Error("peer identity does not match authenticated KK key");289    }290    clearTimeout(handshakeTimer);291  } catch (err) {292    clearTimeout(handshakeTimer);293    session.close();294    try { ws.close(); } catch { /* ignore */ }295    throw err;296  }297298  return {299    send(plaintext: Uint8Array) {300      // E7: bucket the size before encrypting, so the relay sees frame shapes301      // that no longer distinguish one operation (or vault size) from another.302      const ciphertext = session.encrypt(padSyncFrame(plaintext));303      if (ciphertext.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {304        throw new SyncFrameTooLargeError(305          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,306        );307      }308      ws.send(ciphertext as Uint8Array<ArrayBuffer>);309    },310    async receive() {311      const ct = await receive();312      return unpadSyncFrame(session.decrypt(ct));313    },314    async close() {315      try { ws.close(); } catch { /* ignore */ }316      session.close();317    },318    remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),319  };320}321322function connectWebSocketUrl(323  url: string,324  targetLabel: string,325  timeoutMs: number,326): Promise<WebSocket> {327  return new Promise((resolve, reject) => {328    let ws: WebSocket;329    try {330      ws = new WebSocket(url);331    } catch (err) {332      reject(err as Error);333      return;334    }335    const t = setTimeout(() => {336      try { ws.close(); } catch { /* ignore */ }337      reject(new Error(`${targetLabel} WebSocket connect timed out`));338    }, timeoutMs);339    ws.onopen = () => {340      clearTimeout(t);341      resolve(ws);342    };343    ws.onerror = () => {344      clearTimeout(t);345      reject(new Error(`${targetLabel} WebSocket connect errored`));346    };347  });348}349350/**351 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps352 * full /24 scans at once per minute so an extension whose cached IP has353 * gone stale doesn't hammer the LAN with handshake attempts every time354 * the popup opens.355 *356 * Keyed by the expected remote pubkey because each paired peer is probed357 * independently; the rate limit applies to "scan looking for THIS peer",358 * not "scan the subnet at all".359 */360const lastProbeAt = new Map<string, number>();361362export function subnetProbeOnCooldown(363  expectedRemotePubkeyHex: string,364  now: number = Date.now(),365  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,366): boolean {367  const last = lastProbeAt.get(expectedRemotePubkeyHex);368  return last !== undefined && now - last < cooldownMs;369}370371/** Test-only — clear the per-pubkey probe cooldown ledger. */372export function _resetSubnetProbeCooldown(): void {373  lastProbeAt.clear();374}375376/**377 * Subnet probe — scan a /24 looking for a host that completes Noise KK378 * with the expected long-term static key. Returns the first match, or379 * null if the cooldown is still active or no host responded.380 *381 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey382 * (v2 brief §5.2). Callers should still attempt the cached383 * `last_known_ip`/`last_known_port` directly first — the subnet probe is384 * fallback only.385 *386 * For each IP we attempt the full Noise KK handshake, but with a tight387 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser388 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.389 */390export async function probeSubnetForPeer(391  subnet: string, // e.g. "192.168.1"392  port: number,393  ourIdentity: SyncPeerIdentity,394  expectedRemotePubkeyHex: string,395  rendezvousIds: readonly string[],396  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},397): Promise<ProbedSyncEndpoint | null> {398  if (rendezvousIds.length === 0) {399    throw new Error("subnet probe requires at least one rendezvous id");400  }401  const now = options.now ?? Date.now();402  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;403  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {404    return null;405  }406  lastProbeAt.set(expectedRemotePubkeyHex, now);407  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;408  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;409  for (let start = 1; start <= 254; start += batchSize) {410    const end = Math.min(start + batchSize - 1, 254);411    const ips: string[] = [];412    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);413414    const results = await Promise.all(ips.map((ip) => probeOne(415      { ip, port },416      ourIdentity,417      expectedRemotePubkeyHex,418      rendezvousIds,419      timeoutMs,420    )));421    const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);422    if (hit) return hit;423  }424  return null;425}426427async function probeOne(428  endpoint: SyncEndpoint,429  ourIdentity: SyncPeerIdentity,430  expectedRemotePubkeyHex: string,431  rendezvousIds: readonly string[],432  timeoutMs: number,433): Promise<ProbedSyncEndpoint | null> {434  for (const rendezvousId of rendezvousIds) {435    try {436      const session = await openSyncSession(437        endpoint,438        ourIdentity,439        expectedRemotePubkeyHex,440        rendezvousId,441        { connectTimeoutMs: timeoutMs },442      );443      await session.close();444      return { ...endpoint, rendezvousId };445    } catch {446      // Try the next locally-derived clock-skew window at the same host.447    }448  }449  return null;450}