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
↓ peach-browser-6c85957b.tar.gzatlas at this save
files changed2 files
src/shared/chirp-sync-transport.test.ts+91 −0src/shared/chirp-sync-transport.ts+42 −2src/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+});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) {