sync-transport.ts

24.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 lower long-term public key is the WebSocket/Noise initiator; the higher5// key holds the relay room as responder. Device type does not affect the role,6// so two extensions can sync directly without a native hub. Each binary7// WebSocket message carries one Noise frame (large padded application frames8// are split across multiple Noise messages below).9//10// On connect:11//   1. Open ws://<ip>:<port>/12//   2. Send PEACH_RECONNECT with an opaque rotating rendezvous capability so13//      the listener can select paired local state without exposing identities.14//   3. Run standard Noise KK using both locally-known static keys.15//   4. Exchange PAIR_IDENTITY frames over the encrypted channel (same frame16//      Android's SyncReconnectListener expects before it will treat the17//      connection as sync traffic rather than an unknown caller). Our18//      identity is bound to the static key authenticated by KK.19//   5. Hand the caller a `SyncSession` for the SyncMessage protocol.20//21// Subnet probe falls back to scanning the /24 returned by `getLocalSubnet`22// when the cached last-known IP fails. We use a short connection timeout23// (~500ms per IP) and parallel batches of 16 to keep the worst-case scan24// under ~10 seconds.2526import {27  noiseKKInitiator,28  noiseKKResponder,29  type NoiseNKSession,30} from "./noise-js";31import { SYNC_LIMITS } from "./sync-limits";32import {33  padSyncFrame,34  unpadSyncFrame,35  MAX_NOISE_PLAINTEXT_BYTES,36  FRAME_CONTINUATION_MARKER,37  chunkPaddedSyncFrame,38} from "./sync-padding";39import { hexToPubkey } from "./sync-identity";40import { buildRendezvousUrl } from "./rendezvous";41import type { DeviceType } from "./sync-types";4243// E6 (session rekeying) is deliberately NOT wired, and this is a reality check44// against the spec rather than an omission. E6 targets "long-lived sync45// sessions"; the browser has none. Sessions opened here are one-shot — roughly46// ten frames each way, a 30s coordinator timeout (SESSION_TIMEOUT_MS), then47// sync-runner closes them — and every reconnect runs a fresh Noise KK handshake48// with fresh ephemerals. (Android's listener does park a connection open after49// a round so it can act as a relay hub for another native peer, but the browser50// drives the lifetime of every session it opens, and closes it.) That already51// gives stronger post-compromise security than intra-session Rekey() would,52// because no two sessions share key material at all (pinned by the E6 test in53// noise-kk.test.ts).54//55// Wiring Rekey() anyway would mean both ends rekeying at the identical frame56// index with no wire signal to resynchronise on. One frame of drift between57// browser and Android breaks all sync — the exact failure class this protocol58// has already been burned by twice. The primitives are exposed on NoiseSession59// (rekeyOutgoing/rekeyIncoming) if a genuinely long-lived session ever appears;60// until then the cost is real and the benefit is zero.61const DEFAULT_PROBE_TIMEOUT_MS = 500;62const DEFAULT_PROBE_BATCH = 16;63const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;64const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;65export const RELAY_WAITER_KEEPALIVE_MS = 20_000;66/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */67export const SUBNET_PROBE_COOLDOWN_MS = 60_000;6869export interface SyncSession {70  send(plaintext: Uint8Array): void;71  receive(): Promise<Uint8Array>;72  close(): Promise<void>;73  remoteStaticPublicKey: Uint8Array;74}7576export interface SyncEndpoint {77  ip: string;78  port: number;79}8081export interface ProbedSyncEndpoint extends SyncEndpoint {82  /** Exact clock-skew window capability that completed KK at this endpoint. */83  rendezvousId: string;84}8586/** Our identity, repeated in the post-handshake PAIR_IDENTITY frame and bound87 *  to the local static key already authenticated by KK. */88export interface SyncPeerIdentity {89  deviceId: string;90  deviceName: string;91  deviceType: DeviceType;92  longTermPublicKeyHex: string;93  /** Local-only key material for the KK reconnect handshake; never encoded. */94  longTermPrivateKey: Uint8Array;95  signingPublicKeyHex?: string;96}9798export class SyncFrameTooLargeError extends Error {99  constructor(message = "sync frame too large (WebSocket close 1009)") {100    super(message);101    this.name = "SyncFrameTooLargeError";102  }103}104105export interface SyncSessionOptions {106  connectTimeoutMs?: number;107  handshakeTimeoutMs?: number;108  signal?: AbortSignal;109}110111function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {112  return new TextEncoder().encode(JSON.stringify({113    type: "PAIR_IDENTITY",114    device_id: id.deviceId,115    device_name: id.deviceName,116    device_type: id.deviceType,117    long_term_public_key_hex: id.longTermPublicKeyHex,118    signing_public_key_hex: id.signingPublicKeyHex,119  }));120}121122/**123 * Open a WebSocket to (ip, port), run Noise KK against both paired static124 * keys, exchange bound PAIR_IDENTITY frames, and return a ready-to-use125 * session.126 *127 * On a successful return, `setLastKnownIp` in the caller can persist128 * (ip, port) for the next session's fast path.129 */130export async function openSyncSession(131  endpoint: SyncEndpoint,132  ourIdentity: SyncPeerIdentity,133  expectedRemotePubkeyHex: string,134  rendezvousId: string,135  options: SyncSessionOptions = {},136): Promise<SyncSession> {137  return openSyncSessionAtUrl(138    `ws://${endpoint.ip}:${endpoint.port}/`,139    "LAN peer",140    ourIdentity,141    expectedRemotePubkeyHex,142    rendezvousId,143    options,144  );145}146147/**148 * Relay reconnect preserves the exact LAN wire protocol; only the WebSocket149 * target changes. The URL builder enforces credential-free `wss://`.150 */151export async function openRelaySyncSession(152  relayUrl: string,153  ourIdentity: SyncPeerIdentity,154  expectedRemotePubkeyHex: string,155  rendezvousId: string,156  options: SyncSessionOptions = {},157): Promise<SyncSession> {158  return openSyncSessionAtUrl(159    buildRendezvousUrl(relayUrl, rendezvousId),160    "relay",161    ourIdentity,162    expectedRemotePubkeyHex,163    rendezvousId,164    options,165  );166}167168/**169 * Hold one relay room as the deterministic responder, then run Noise KK and170 * the bound PAIR_IDENTITY exchange. The zero-length liveness frame keeps a171 * Chrome 116+ MV3 service worker alive while the room is empty; the Peach172 * relay consumes that frame locally and never forwards it to the peer.173 */174export async function openRelaySyncResponderSession(175  relayUrl: string,176  ourIdentity: SyncPeerIdentity,177  expectedRemotePubkeyHex: string,178  rendezvousId: string,179  options: SyncSessionOptions & { keepaliveMs?: number } = {},180): Promise<SyncSession> {181  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {182    throw new Error("reconnect requires a valid rotating rendezvous id");183  }184  if (ourIdentity.longTermPrivateKey.length !== 32) {185    throw new Error("local reconnect private key must be exactly 32 bytes");186  }187  const ws = await connectWebSocketUrl(188    buildRendezvousUrl(relayUrl, rendezvousId),189    "relay",190    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,191  );192  const inbox: Uint8Array[] = [];193  const readers: Array<(b: Uint8Array) => void> = [];194  let closed = false;195  let closeError: Error = new Error("WebSocket closed");196  const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;197  const abortWaiter = () => {198    try { ws.close(1000, "relay waiter replaced"); } catch { /* ignore */ }199  };200  const keepalive = setInterval(() => {201    if (ws.readyState !== WebSocket.OPEN) return;202    try { ws.send(new Uint8Array(0)); } catch { /* onclose owns cleanup */ }203  }, keepaliveMs);204205  ws.binaryType = "arraybuffer";206  ws.onmessage = (event: MessageEvent) => {207    if (!(event.data instanceof ArrayBuffer)) return;208    if (event.data.byteLength === 0) return;209    if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {210      closeError = new SyncFrameTooLargeError(211        `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,212      );213      try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }214      return;215    }216    const bytes = new Uint8Array(event.data);217    const reader = readers.shift();218    if (reader) reader(bytes);219    else inbox.push(bytes);220  };221  ws.onerror = () => {222    try { ws.close(); } catch { /* ignore */ }223  };224  ws.onclose = (event: CloseEvent) => {225    clearInterval(keepalive);226    options.signal?.removeEventListener("abort", abortWaiter);227    closed = true;228    if (event.code === 1009) closeError = new SyncFrameTooLargeError();229    while (readers.length > 0) readers.shift()!(new Uint8Array(0));230  };231  // Install close handling before honoring an already-aborted signal so a232  // synchronous/mock close cannot strand the first receive() forever.233  options.signal?.addEventListener("abort", abortWaiter, { once: true });234  if (options.signal?.aborted) abortWaiter();235236  const send = async (bytes: Uint8Array): Promise<void> => {237    if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {238      throw new SyncFrameTooLargeError(239        `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,240      );241    }242    ws.send(bytes as Uint8Array<ArrayBuffer>);243  };244  const receive = (): Promise<Uint8Array> => {245    if (closed) return Promise.reject(closeError);246    const buffered = inbox.shift();247    if (buffered) return Promise.resolve(buffered);248    return new Promise<Uint8Array>((resolve, reject) => {249      readers.push((bytes) => {250        if (bytes.length === 0 && closed) reject(closeError);251        else resolve(bytes);252      });253    });254  };255256  let noiseSession: NoiseNKSession | undefined;257  try {258    const selectorBytes = await receive();259    const selector = JSON.parse(new TextDecoder().decode(selectorBytes));260    if (261      selector?.type !== "PEACH_RECONNECT" ||262      typeof selector.rendezvous_id !== "string" ||263      !equalAscii(selector.rendezvous_id, rendezvousId)264    ) {265      throw new Error("reconnect capability does not match relay room");266    }267    const handshakeTimer = setTimeout(() => {268      closeError = new Error("sync_handshake_timeout");269      try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }270    }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);271    try {272      const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);273      noiseSession = await noiseKKResponder(274        ourIdentity.longTermPrivateKey,275        ourPublicKey,276        hexToPubkey(expectedRemotePubkeyHex),277        send,278        receive,279      );280      const theirFrame = JSON.parse(281        new TextDecoder().decode(282          unpadSyncFrame(noiseSession.decrypt(await receive())),283        ),284      );285      if (theirFrame?.type !== "PAIR_IDENTITY") {286        throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);287      }288      if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {289        throw new Error("peer identity does not match authenticated KK key");290      }291      await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));292    } finally {293      clearTimeout(handshakeTimer);294    }295  } catch (error) {296    clearInterval(keepalive);297    options.signal?.removeEventListener("abort", abortWaiter);298    noiseSession?.close();299    try { ws.close(); } catch { /* ignore */ }300    throw error;301  }302303  return buildEncryptedSyncSession(304    ws,305    noiseSession,306    expectedRemotePubkeyHex,307    receive,308    () => {309      clearInterval(keepalive);310      options.signal?.removeEventListener("abort", abortWaiter);311    },312  );313}314315async function openSyncSessionAtUrl(316  url: string,317  targetLabel: string,318  ourIdentity: SyncPeerIdentity,319  expectedRemotePubkeyHex: string,320  rendezvousId: string,321  options: SyncSessionOptions,322): Promise<SyncSession> {323  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {324    throw new Error("reconnect requires a valid rotating rendezvous id");325  }326  const ws = await connectWebSocketUrl(327    url,328    targetLabel,329    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,330  );331  const inbox: Uint8Array[] = [];332  const readers: Array<(b: Uint8Array) => void> = [];333  let closed = false;334  let closeError: Error = new Error("WebSocket closed");335336  ws.binaryType = "arraybuffer";337  ws.onmessage = (ev: MessageEvent) => {338    const data = ev.data;339    if (data instanceof ArrayBuffer) {340      // MV-3: refuse oversized frames before they are buffered or decrypted.341      // A peer that sends one is misbehaving — tear the session down rather342      // than letting the inbox grow toward OOM.343      if (data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {344        // MV-3: oversized frame — tear the session down. close() routes through345        // onclose, which rejects any parked receive(), so no error array is346        // needed (PCH-01-026 #9).347        console.warn(348          `[Peach] sync frame exceeds max size (${SYNC_LIMITS.MAX_FRAME_BYTES} bytes); closing`,349        );350        closeError = new SyncFrameTooLargeError(351          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,352        );353        try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }354        return;355      }356      const bytes = new Uint8Array(data);357      const r = readers.shift();358      if (r) r(bytes);359      else inbox.push(bytes);360    }361  };362  ws.onerror = () => {363    // PCH-01-026 #9: surface the error and unblock any parked receive() by364    // closing. Browsers usually follow error with close, but not always; an365    // explicit close guarantees the handshake fails fast instead of hanging.366    console.warn("[Peach] sync WebSocket error; closing transport");367    try { ws.close(); } catch { /* ignore */ }368  };369  ws.onclose = (event: CloseEvent) => {370    closed = true;371    if (event.code === 1009) {372      closeError = new SyncFrameTooLargeError();373    }374    while (readers.length > 0) {375      const r = readers.shift()!;376      r(new Uint8Array(0));377    }378  };379380  function send(bytes: Uint8Array): Promise<void> {381    return new Promise((resolve, reject) => {382      if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {383        reject(384          new SyncFrameTooLargeError(385            `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,386          ),387        );388        return;389      }390      try {391        ws.send(bytes as Uint8Array<ArrayBuffer>);392        resolve();393      } catch (err) {394        reject(err as Error);395      }396    });397  }398  function receive(): Promise<Uint8Array> {399    if (closed) return Promise.reject(closeError);400    const v = inbox.shift();401    if (v) return Promise.resolve(v);402    return new Promise<Uint8Array>((resolve, reject) => {403      readers.push((b) => {404        if (b.length === 0 && closed) reject(closeError);405        else resolve(b);406      });407    });408  }409410  const handshakeTimer = setTimeout(() => {411    closeError = new Error("sync_handshake_timeout");412    try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }413  }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);414  let session: NoiseNKSession;415  try {416    const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);417    if (ourIdentity.longTermPrivateKey.length !== 32) {418      throw new Error("local reconnect private key must be exactly 32 bytes");419    }420    // This opaque rotating capability lets Android select paired local state421    // without exposing either public key to a relay. It grants no identity422    // trust; KK still proves both private keys immediately afterwards.423    await send(new TextEncoder().encode(JSON.stringify({424      type: "PEACH_RECONNECT",425      rendezvous_id: rendezvousId,426    })));427    session = await noiseKKInitiator(428      ourIdentity.longTermPrivateKey,429      ourPublicKey,430      hexToPubkey(expectedRemotePubkeyHex),431      send,432      receive,433    );434  } catch (err) {435    clearTimeout(handshakeTimer);436    try { ws.close(); } catch { /* ignore */ }437    throw err;438  }439440  try {441    await send(session.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));442    const theirFrame = JSON.parse(443      new TextDecoder().decode(unpadSyncFrame(session.decrypt(await receive()))),444    );445    if (theirFrame?.type !== "PAIR_IDENTITY") {446      throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);447    }448    if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {449      throw new Error("peer identity does not match authenticated KK key");450    }451    clearTimeout(handshakeTimer);452  } catch (err) {453    clearTimeout(handshakeTimer);454    session.close();455    try { ws.close(); } catch { /* ignore */ }456    throw err;457  }458459  return buildEncryptedSyncSession(460    ws,461    session,462    expectedRemotePubkeyHex,463    receive,464  );465}466467function buildEncryptedSyncSession(468  ws: WebSocket,469  session: NoiseNKSession,470  expectedRemotePubkeyHex: string,471  receiveCiphertext: () => Promise<Uint8Array>,472  beforeClose: () => void = () => undefined,473): SyncSession {474  return {475    async send(plaintext: Uint8Array) {476      // E7: bucket the size before encrypting, so the relay sees frame shapes477      // that no longer distinguish one operation (or vault size) from another.478      // E7b: a padded frame can legitimately be up to 24 MiB, but Noise caps a479      // single transport message at 65535 bytes (see crypto-core/src/lib.rs —480      // 65519 plaintext, 16-byte tag). We split a too-large padded frame into481      // numbered Noise messages and tag every chunk after the first with a482      // 1-byte continuation header. The receiver reassembles by reading the483      // padded frame's 4-byte length header, computing the same bucket from484      // the same ladder, and keeping reading until the bucket is full.485      const padded = padSyncFrame(plaintext);486      if (padded.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {487        throw new SyncFrameTooLargeError(488          `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,489        );490      }491      // A single-chunk frame goes out as one Noise message, exactly as before492      // — no continuation byte, no per-chunk overhead, so the common small493      // case is unchanged on the wire.494      if (padded.byteLength <= MAX_NOISE_PLAINTEXT_BYTES) {495        const ct = session.encrypt(padded);496        ws.send(ct as Uint8Array<ArrayBuffer>);497        return;498      }499      for (const plaintext of chunkPaddedSyncFrame(padded)) {500        if (plaintext.byteLength > MAX_NOISE_PLAINTEXT_BYTES) {501          throw new Error(502            `chunk exceeds Noise limit after continuation byte: ${plaintext.byteLength}`,503          );504        }505        const ct = session.encrypt(plaintext);506        if (ct.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {507          throw new SyncFrameTooLargeError(508            `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,509          );510        }511        ws.send(ct as Uint8Array<ArrayBuffer>);512      }513    },514    async receive() {515      // The first Noise message of a frame is the first one we read. If it516      // begins with the continuation marker, the message is one of N chunks517      // — the padded-frame length header comes inside the first chunk's518      // payload, so we read it there to learn the total.519      let buffered: Uint8Array | null = null;520      while (true) {521        const ct = await receiveCiphertext();522        const pt = session.decrypt(ct);523        if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {524          // Strip the continuation byte and keep the payload.525          buffered = pt.subarray(1);526          continue;527        }528        if (buffered !== null) {529          if (pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {530            const merged: Uint8Array = new Uint8Array(buffered.length + pt.byteLength - 1);531            merged.set(buffered, 0);532            merged.set(pt.subarray(1), buffered.length);533            buffered = merged;534            continue;535          }536          // Final chunk. Concatenate without a continuation byte, then fall537          // through to the unpadder.538          const reassembled = new Uint8Array(buffered.length + pt.byteLength);539          reassembled.set(buffered, 0);540          reassembled.set(pt, buffered.length);541          return unpadSyncFrame(reassembled);542        }543        // Single-chunk frame, exactly as before.544        return unpadSyncFrame(pt);545      }546    },547    async close() {548      beforeClose();549      try { ws.close(); } catch { /* ignore */ }550      session.close();551    },552    remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),553  };554}555556function equalAscii(left: string, right: string): boolean {557  const a = new TextEncoder().encode(left);558  const b = new TextEncoder().encode(right);559  if (a.length !== b.length) return false;560  let diff = 0;561  for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];562  return diff === 0;563}564565function connectWebSocketUrl(566  url: string,567  targetLabel: string,568  timeoutMs: number,569): Promise<WebSocket> {570  return new Promise((resolve, reject) => {571    let ws: WebSocket;572    try {573      ws = new WebSocket(url);574    } catch (err) {575      reject(err as Error);576      return;577    }578    const t = setTimeout(() => {579      try { ws.close(); } catch { /* ignore */ }580      reject(new Error(`${targetLabel} WebSocket connect timed out`));581    }, timeoutMs);582    ws.onopen = () => {583      clearTimeout(t);584      resolve(ws);585    };586    ws.onerror = () => {587      clearTimeout(t);588      reject(new Error(`${targetLabel} WebSocket connect errored`));589    };590  });591}592593/**594 * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps595 * full /24 scans at once per minute so an extension whose cached IP has596 * gone stale doesn't hammer the LAN with handshake attempts every time597 * the popup opens.598 *599 * Keyed by the expected remote pubkey because each paired peer is probed600 * independently; the rate limit applies to "scan looking for THIS peer",601 * not "scan the subnet at all".602 */603const lastProbeAt = new Map<string, number>();604605export function subnetProbeOnCooldown(606  expectedRemotePubkeyHex: string,607  now: number = Date.now(),608  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,609): boolean {610  const last = lastProbeAt.get(expectedRemotePubkeyHex);611  return last !== undefined && now - last < cooldownMs;612}613614/** Test-only — clear the per-pubkey probe cooldown ledger. */615export function _resetSubnetProbeCooldown(): void {616  lastProbeAt.clear();617}618619/**620 * Subnet probe — scan a /24 looking for a host that completes Noise KK621 * with the expected long-term static key. Returns the first match, or622 * null if the cooldown is still active or no host responded.623 *624 * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey625 * (v2 brief §5.2). Callers should still attempt the cached626 * `last_known_ip`/`last_known_port` directly first — the subnet probe is627 * fallback only.628 *629 * For each IP we attempt the full Noise KK handshake, but with a tight630 * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser631 * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.632 */633export async function probeSubnetForPeer(634  subnet: string, // e.g. "192.168.1"635  port: number,636  ourIdentity: SyncPeerIdentity,637  expectedRemotePubkeyHex: string,638  rendezvousIds: readonly string[],639  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},640): Promise<ProbedSyncEndpoint | null> {641  if (rendezvousIds.length === 0) {642    throw new Error("subnet probe requires at least one rendezvous id");643  }644  const now = options.now ?? Date.now();645  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;646  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {647    return null;648  }649  lastProbeAt.set(expectedRemotePubkeyHex, now);650  const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;651  const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;652  for (let start = 1; start <= 254; start += batchSize) {653    const end = Math.min(start + batchSize - 1, 254);654    const ips: string[] = [];655    for (let i = start; i <= end; i++) ips.push(`${subnet}.${i}`);656657    const results = await Promise.all(ips.map((ip) => probeOne(658      { ip, port },659      ourIdentity,660      expectedRemotePubkeyHex,661      rendezvousIds,662      timeoutMs,663    )));664    const hit = results.find((r): r is ProbedSyncEndpoint => r !== null);665    if (hit) return hit;666  }667  return null;668}669670async function probeOne(671  endpoint: SyncEndpoint,672  ourIdentity: SyncPeerIdentity,673  expectedRemotePubkeyHex: string,674  rendezvousIds: readonly string[],675  timeoutMs: number,676): Promise<ProbedSyncEndpoint | null> {677  for (const rendezvousId of rendezvousIds) {678    try {679      const session = await openSyncSession(680        endpoint,681        ourIdentity,682        expectedRemotePubkeyHex,683        rendezvousId,684        { connectTimeoutMs: timeoutMs },685      );686      await session.close();687      return { ...endpoint, rendezvousId };688    } catch {689      // Try the next locally-derived clock-skew window at the same host.690    }691  }692  return null;693}