6c85957b5309

subnet probe rate limit — per-pubkey 60s cooldown ledger, callable-from-outside via subnetProbeOnCooldown helper (5 transport tests, full suite 3198 green)

time
2026-05-18 02:50 UTC · 5 months ago
machine
koh2
parent
1d225ca17f0f
2 files src/shared/chirp-sync-transport.test.ts+91 −0src/shared/chirp-sync-transport.ts+42 −2
src/shared/chirp-sync-transport.test.ts+91 −0
@@ -0,0 +1,91 @@1+// Subnet-probe rate-limit tests. The full Noise XX handshake side of the2+// transport requires a live WebSocket peer, so we exercise the cooldown3+// ledger directly — it is the only Phase 2 invariant we want to pin4+// regardless of network reachability.5+6+import { afterEach, beforeEach, describe, expect, it } from "vitest";7+import {8+  _resetSubnetProbeCooldown,9+  probeSubnetForPeer,10+  subnetProbeOnCooldown,11+  SUBNET_PROBE_COOLDOWN_MS,12+} from "./chirp-sync-transport";13+14+const PEER_PUBKEY = "ab".repeat(32);15+16+describe("subnetProbeOnCooldown", () => {17+  beforeEach(() => _resetSubnetProbeCooldown());18+  afterEach(() => _resetSubnetProbeCooldown());19+20+  it("returns false before any probe has happened", () => {21+    expect(subnetProbeOnCooldown(PEER_PUBKEY)).toBe(false);22+  });23+24+  it("returns true within the cooldown window after a probe", async () => {25+    const now = 1_000_000;26+    // Fire-and-forget; the probe itself fails fast because no peer exists.27+    // We only care about the ledger side-effect of recording `now`.28+    await probeSubnetForPeer(29+      "10.0.0",30+      49142,31+      new Uint8Array(32),32+      new Uint8Array(32),33+      PEER_PUBKEY,34+      { batch: 254, timeoutMs: 1, now },35+    );36+    expect(subnetProbeOnCooldown(PEER_PUBKEY, now + 1)).toBe(true);37+    expect(subnetProbeOnCooldown(PEER_PUBKEY, now + SUBNET_PROBE_COOLDOWN_MS - 1)).toBe(true);38+  });39+40+  it("releases the cooldown after the window expires", async () => {41+    const now = 1_000_000;42+    await probeSubnetForPeer(43+      "10.0.0",44+      49142,45+      new Uint8Array(32),46+      new Uint8Array(32),47+      PEER_PUBKEY,48+      { batch: 254, timeoutMs: 1, now },49+    );50+    expect(subnetProbeOnCooldown(PEER_PUBKEY, now + SUBNET_PROBE_COOLDOWN_MS)).toBe(false);51+  });52+53+  it("returns null and skips the scan when on cooldown", async () => {54+    const now = 1_000_000;55+    await probeSubnetForPeer(56+      "10.0.0",57+      49142,58+      new Uint8Array(32),59+      new Uint8Array(32),60+      PEER_PUBKEY,61+      { batch: 254, timeoutMs: 1, now },62+    );63+    // Second call within the window must short-circuit before any64+    // WebSocket attempt is made. We can't easily observe "no WebSocket"65+    // directly here, but null + cooldown=true is the contract.66+    const result = await probeSubnetForPeer(67+      "10.0.0",68+      49142,69+      new Uint8Array(32),70+      new Uint8Array(32),71+      PEER_PUBKEY,72+      { batch: 254, timeoutMs: 1, now: now + 1_000 },73+    );74+    expect(result).toBeNull();75+  });76+77+  it("tracks cooldown independently per peer pubkey", async () => {78+    const now = 1_000_000;79+    const other = "cd".repeat(32);80+    await probeSubnetForPeer(81+      "10.0.0",82+      49142,83+      new Uint8Array(32),84+      new Uint8Array(32),85+      PEER_PUBKEY,86+      { batch: 254, timeoutMs: 1, now },87+    );88+    expect(subnetProbeOnCooldown(PEER_PUBKEY, now + 1)).toBe(true);89+    expect(subnetProbeOnCooldown(other, now + 1)).toBe(false);90+  });91+});

view file

src/shared/chirp-sync-transport.ts+42 −2
@@ -24,6 +24,8 @@2424 const DEFAULT_PROBE_TIMEOUT_MS = 500;2525 const DEFAULT_PROBE_BATCH = 16;2626 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;27+/** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */28+export const SUBNET_PROBE_COOLDOWN_MS = 60_000;2729 2830 export interface ChirpSyncSession {2931   send(plaintext: Uint8Array): void;@@ -166,9 +168,41 @@166168 }167169 168170 /**171+ * Per-pubkey cooldown tracker for the subnet probe. v2 brief §5.2 caps172+ * full /24 scans at once per minute so an extension whose cached IP has173+ * gone stale doesn't hammer the LAN with handshake attempts every time174+ * the popup opens.175+ *176+ * Keyed by the expected remote pubkey because each paired peer is probed177+ * independently; the rate limit applies to "scan looking for THIS peer",178+ * not "scan the subnet at all".179+ */180+const lastProbeAt = new Map<string, number>();181+182+export function subnetProbeOnCooldown(183+  expectedRemotePubkeyHex: string,184+  now: number = Date.now(),185+  cooldownMs: number = SUBNET_PROBE_COOLDOWN_MS,186+): boolean {187+  const last = lastProbeAt.get(expectedRemotePubkeyHex);188+  return last !== undefined && now - last < cooldownMs;189+}190+191+/** Test-only — clear the per-pubkey probe cooldown ledger. */192+export function _resetSubnetProbeCooldown(): void {193+  lastProbeAt.clear();194+}195+196+/**169197  * Subnet probe — scan a /24 looking for a host that completes Noise XX170- * with the expected long-term static key. Returns the first match.198+ * with the expected long-term static key. Returns the first match, or199+ * null if the cooldown is still active or no host responded.171200  *201+ * Rate-limited to once per `SUBNET_PROBE_COOLDOWN_MS` per peer pubkey202+ * (v2 brief §5.2). Callers should still attempt the cached203+ * `last_known_ip`/`last_known_port` directly first — the subnet probe is204+ * fallback only.205+ *172206  * For each IP we attempt the full Noise XX handshake, but with a tight173207  * timeout: dead hosts fail at the WebSocket connect step in <1ms (browser174208  * surfaces ECONNREFUSED immediately) or by the connectTimeout otherwise.@@ -179,8 +213,14 @@179213   ourLongTermPrivateKey: Uint8Array,180214   ourLongTermPublicKey: Uint8Array,181215   expectedRemotePubkeyHex: string,182-  options: { batch?: number; timeoutMs?: number } = {},216+  options: { batch?: number; timeoutMs?: number; cooldownMs?: number; now?: number } = {},183217 ): Promise<SyncEndpoint | null> {218+  const now = options.now ?? Date.now();219+  const cooldownMs = options.cooldownMs ?? SUBNET_PROBE_COOLDOWN_MS;220+  if (subnetProbeOnCooldown(expectedRemotePubkeyHex, now, cooldownMs)) {221+    return null;222+  }223+  lastProbeAt.set(expectedRemotePubkeyHex, now);184224   const batchSize = options.batch ?? DEFAULT_PROBE_BATCH;185225   const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;186226   for (let start = 1; start <= 254; start += batchSize) {

view file