sync-transport.ts

10.5 KB · raw

1// WebSocket sync transport — standardized on Noise NK (see2// HANDSHAKE_STANDARDIZE_ON_NK.md). Android's ongoing-sync listener3// (SyncReconnectListener.kt) is a Noise NK responder on the same fixed port4// (49142) used for pairing; there is no separate Noise XX responder.5//6// The extension is always the WebSocket client; Android (or future iOS /7// desktop sync hosts) is always the server. Each binary WebSocket message8// carries exactly one Noise frame — no length prefix, no fragmentation.9//10// On connect:11//   1. Open ws://<ip>:<port>/12//   2. Run Noise NK as the initiator against the paired device's known13//      long-term static public key. Unlike XX, NK authenticates only the14//      responder during the handshake — there is no initiator static15//      keypair in the crypto itself.16//   3. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame17//      Android's SyncReconnectListener expects before it will treat the18//      connection as sync traffic rather than an unknown caller). Our19//      identity is asserted, not proven, at this layer — this mirrors20//      Android's own implementation, not a gap introduced here.21//   4. Hand the caller a `SyncSession` for the SyncMessage protocol.22//23// Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`24// when the cached last-known IP fails. We use a short connection timeout25// (~500ms per IP) and parallel batches of 16 to keep the worst-case scan26// under ~10 seconds.2728import { noiseNKInitiator, type NoiseNKSession } from "./noise-js";29import { SYNC_LIMITS } from "./sync-limits";30import { hexToPubkey } from "./sync-identity";31import type { DeviceType } from "./sync-types";3233const DEFAULT_PROBE_TIMEOUT_MS = 500;34const DEFAULT_PROBE_BATCH = 16;35const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;36/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */37export const SUBNET_PROBE_COOLDOWN_MS = 60_000;3839export interface SyncSession {40  send(plaintext: Uint8Array): void;41  receive(): Promise<Uint8Array>;42  close(): Promise<void>;43  remoteStaticPublicKey: Uint8Array;44}4546export interface SyncEndpoint {47  ip: string;48  port: number;49}5051/** Our identity, asserted in the post-handshake PAIR_IDENTITY frame — the52 *  same shape `code-pairing.ts` sends during pairing (`device_id` etc.). */53export interface SyncPeerIdentity {54  deviceId: string;55  deviceName: string;56  deviceType: DeviceType;57  longTermPublicKeyHex: string;58  signingPublicKeyHex?: string;59}6061function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {62  return new TextEncoder().encode(JSON.stringify({63    type: "PAIR_IDENTITY",64    device_id: id.deviceId,65    device_name: id.deviceName,66    device_type: id.deviceType,67    long_term_public_key_hex: id.longTermPublicKeyHex,68    signing_public_key_hex: id.signingPublicKeyHex,69  }));70}7172/**73 * Open a WebSocket to (ip, port), run Noise NK against the paired device's74 * known static key, exchange PAIR_IDENTITY, and return a ready-to-use75 * session.76 *77 * On a successful return, `setLastKnownIp` in the caller can persist78 * (ip, port) for the next session's fast path.79 */80export async function openSyncSession(81  endpoint: SyncEndpoint,82  ourIdentity: SyncPeerIdentity,83  expectedRemotePubkeyHex: string,84  options: { connectTimeoutMs?: number } = {},85): Promise<SyncSession> {86  const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);87  const inbox: Uint8Array[] = [];88  const readers: Array<(b: Uint8Array) => void> = [];89  let closed = false;9091  ws.binaryType = "arraybuffer";92  ws.onmessage = (ev: MessageEvent) => {93    const data = ev.data;94    if (data instanceof ArrayBuffer) {95      // MV-3: refuse oversized frames before they are buffered or decrypted.96      // A peer that sends one is misbehaving — tear the session down rather97      // than letting the inbox grow toward OOM.98      if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {99        // MV-3: oversized frame — tear the session down. close() routes through100        // onclose, which rejects any parked receive(), so no error array is101        // needed (PCH-01-026 #9).102        console.warn(103          `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,104        );105        try { ws.close(); } catch { /* ignore */ }106        return;107      }108      const bytes = new Uint8Array(data);109      const r = readers.shift();110      if (r) r(bytes);111      else inbox.push(bytes);112    }113  };114  ws.onerror = () => {115    // PCH-01-026 #9: surface the error and unblock any parked receive() by116    // closing. Browsers usually follow error with close, but not always; an117    // explicit close guarantees the handshake fails fast instead of hanging.118    console.warn("[Peach] sync WebSocket error; closing transport");119    try { ws.close(); } catch { /* ignore */ }120  };121  ws.onclose = () => {122    closed = true;123    while (readers.length > 0) {124      const r = readers.shift()!;125      r(new Uint8Array(0));126    }127  };128129  function send(bytes: Uint8Array): Promise<void> {130    return new Promise((resolve, reject) => {131      try {132        ws.send(bytes as Uint8Array<ArrayBuffer>);133        resolve();134      } catch (err) {135        reject(err as Error);136      }137    });138  }139  function receive(): Promise<Uint8Array> {140    if (closed) return Promise.reject(new Error("WebSocket closed before frame"));141    const v = inbox.shift();142    if (v) return Promise.resolve(v);143    return new Promise<Uint8Array>((resolve, reject) => {144      readers.push((b) => {145        if (b.length === 0 && closed) reject(new Error("WebSocket closed"));146        else resolve(b);147      });148    });149  }150151  let session: NoiseNKSession;152  try {153    // NK's responder static key is a handshake INPUT, not something learned154    // mid-handshake — a wrong key simply fails msg2 decryption below, so155    // there is no separate mismatch check to run first (unlike XX).156    session = await noiseNKInitiator(hexToPubkey(expectedRemotePubkeyHex), send, receive);157  } catch (err) {158    try { ws.close(); } catch { /* ignore */ }159    throw err;160  }161162  try {163    await send(session.encrypt(encodePairIdentity(ourIdentity)));164    const theirFrame = JSON.parse(new TextDecoder().decode(session.decrypt(await receive())));165    if (theirFrame?.type !== "PAIR_IDENTITY") {166      throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);167    }168  } catch (err) {169    try { ws.close(); } catch { /* ignore */ }170    throw err;171  }172173  return {174    send(plaintext: Uint8Array) {175      const ciphertext = session.encrypt(plaintext);176      ws.send(ciphertext as Uint8Array<ArrayBuffer>);177    },178    async receive() {179      const ct = await receive();180      return session.decrypt(ct);181    },182    async close() {183      try { ws.close(); } catch { /* ignore */ }184    },185    remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),186  };187}188189function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {190  return new Promise((resolve, reject) => {191    let ws: WebSocket;192    try {193      ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);194    } catch (err) {195      reject(err as Error);196      return;197    }198    const t = setTimeout(() => {199      try { ws.close(); } catch { /* ignore */ }200      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));201    }, timeoutMs);202    ws.onopen = () => {203      clearTimeout(t);204      resolve(ws);205    };206    ws.onerror = () => {207      clearTimeout(t);208      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));209    };210  });211}212213/**214 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps215 * full /24 scans at once per minute so an extension whose cached IP has216 * gone stale doesn't hammer the LAN with handshake attempts every time217 * the popup opens.218 *219 * Keyed by the expected remote pubkey because each paired peer is probed220 * independently; the rate limit applies to "scan looking for THIS peer",221 * not "scan the subnet at all".222 */223const lastProbeAt = new Map<string, number>();224225export function subnetProbeOnCooldown(226  expectedRemotePubkeyHex: string,227  now: number = Date.now(),228  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,229): boolean {230  const last = lastProbeAt.get(expectedRemotePubkeyHex);231  return last !== undefined && now - last < cooldownMs;232}233234/** Test-only — clear the per-pubkey probe cooldown ledger. */235export function _resetSubnetProbeCooldown(): void {236  lastProbeAt.clear();237}238239/**240 * Subnet probe — scan a /24 looking for a host that completes Noise NK241 * with the expected long-term static key. Returns the first match, or242 * null if the cooldown is still active or no host responded.243 *244 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey245 * (v2 brief §5.2). Callers should still attempt the cached246 * `last_known_ip`/`last_known_port` directly first — the subnet probe is247 * fallback only.248 *249 * For each IP we attempt the full Noise NK handshake, but with a tight250 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser251 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.252 */253export async function probeSubnetForPeer(254  subnet: string, // e.g. "192.168.1"255  port: number,256  ourIdentity: SyncPeerIdentity,257  expectedRemotePubkeyHex: string,258  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},259): Promise<SyncEndpoint | null> {260  const now = options.now ?? Date.now();261  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;262  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {263    return null;264  }265  lastProbeAt.set(expectedRemotePubkeyHex, now);266  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;267  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;268  for (let start = 1; start <= 254; start += batchSize) {269    const end = Math.min(start + batchSize - 1, 254);270    const ips: string[] = [];271    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);272273    const results = await Promise.all(ips.map((ip) => probeOne(274      { ip, port },275      ourIdentity,276      expectedRemotePubkeyHex,277      timeoutMs,278    )));279    const hit = results.find((r): r is SyncEndpoint => r !== null);280    if (hit) return hit;281  }282  return null;283}284285async function probeOne(286  endpoint: SyncEndpoint,287  ourIdentity: SyncPeerIdentity,288  expectedRemotePubkeyHex: string,289  timeoutMs: number,290): Promise<SyncEndpoint | null> {291  try {292    const session = await openSyncSession(293      endpoint,294      ourIdentity,295      expectedRemotePubkeyHex,296      { connectTimeoutMs: timeoutMs },297    );298    await session.close();299    return endpoint;300  } catch {301    return null;302  }303}