c6c9978bbb7c

feat: add secure relay sync fallback (v4.0.44)

time
2026-07-26 04:02 UTC · 3 months ago
machine
koh2
parent
9c0983a25210
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/sync-runner.test.ts+60 −0src/background/sync-runner.ts+65 −2src/shared/sync-kk-transport.test.ts+29 −3src/shared/sync-transport.ts+67 −6src/shared/sync.disconnect.test.ts+24 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.43",4+  "version": "4.0.44",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.0.43",4+  "version": "4.0.44",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "4.0.43",3+  "version": "4.0.44",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/sync-runner.test.ts+60 −0
@@ -322,6 +322,66 @@322322     await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");323323   });324324 325+  it("falls back to the configured relay after LAN discovery fails", async () => {326+    await bootVault();327+    const { session, peer } = inMemoryPair();328+    const openRelaySession = vi.fn()329+      .mockRejectedValueOnce(new Error("sync_handshake_timeout"))330+      .mockResolvedValueOnce(session);331+    const deps: SyncRunnerDeps = {332+      openSession: vi.fn(),333+      probeSubnet: vi.fn(),334+      openRelaySession,335+      getRelaySettings: vi.fn(async () => ({336+        relayUrl: "wss://relay.example.test",337+        lanOnly: false,338+      })),339+      getSubnet: vi.fn(async () => null),340+      createAdapter: emptyAdapter,341+    };342+343+    const { runWithDeps } = await import("./sync-runner");344+    const runPromise = runWithDeps(345+      makeDevice({ relayUrl: "wss://paired-relay.example.test" }),346+      deps,347+    );348+    await runPeer(peer);349+    await expect(runPromise).resolves.toEqual({350+      ok: true,351+      entriesReceived: 0,352+      entriesSent: 0,353+    });354+    expect(openRelaySession).toHaveBeenCalledTimes(2);355+    expect(openRelaySession.mock.calls[0][0]).toBe(356+      "wss://paired-relay.example.test",357+    );358+    expect(openRelaySession.mock.calls[0][3]).not.toBe(359+      openRelaySession.mock.calls[1][3],360+    );361+  });362+363+  it("never opens a relay session in LAN-only mode", async () => {364+    await bootVault();365+    const openRelaySession = vi.fn();366+    const deps: SyncRunnerDeps = {367+      openSession: vi.fn(),368+      probeSubnet: vi.fn(),369+      openRelaySession,370+      getRelaySettings: vi.fn(async () => ({371+        relayUrl: "wss://relay.example.test",372+        lanOnly: true,373+      })),374+      getSubnet: vi.fn(async () => null),375+      createAdapter: emptyAdapter,376+    };377+378+    const { runWithDeps } = await import("./sync-runner");379+    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow(380+      "subnet_unknown",381+    );382+    expect(openRelaySession).not.toHaveBeenCalled();383+  });384+325385   it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {326386     await bootVault();327387     const { session, peer } = inMemoryPair();

view file

src/background/sync-runner.ts+65 −2
@@ -25,6 +25,7 @@2525   setLastKnownIp,2626 } from "./paired-devices";2727 import {28+  openRelaySyncSession,2829   openSyncSession,2930   probeSubnetForPeer,3031   type ProbedSyncEndpoint,@@ -43,6 +44,10 @@4344   dialingRendezvousIds,4445   rendezvousSecretFromHex,4546 } from "../shared/rendezvous";47+import {48+  getRelaySettings,49+  type RelaySettings,50+} from "./relay-settings";4651 4752 /** Same fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */4853 const DEFAULT_PORT = PAIRING_PORT;@@ -65,6 +70,13 @@6570     expectedRemoteHex: string,6671     rendezvousIds: readonly string[],6772   ) => Promise<ProbedSyncEndpoint | null>;73+  openRelaySession?: (74+    relayUrl: string,75+    ourIdentity: SyncPeerIdentity,76+    expectedRemoteHex: string,77+    rendezvousId: string,78+  ) => Promise<SyncSession>;79+  getRelaySettings?: () => Promise<RelaySettings>;6880   getSubnet: () => Promise<string | null>;6981   createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;7082 }@@ -74,6 +86,9 @@7486     openSyncSession(endpoint, identity, expected, rendezvousId),7587   probeSubnet: (subnet, port, identity, expected, rendezvousIds) =>7688     probeSubnetForPeer(subnet, port, identity, expected, rendezvousIds),89+  openRelaySession: (relayUrl, identity, expected, rendezvousId) =>90+    openRelaySyncSession(relayUrl, identity, expected, rendezvousId),91+  getRelaySettings,7792   getSubnet: getLocalSubnet,7893   createAdapter: (context) => createSyncVaultAdapter(context),7994 };@@ -127,7 +142,31 @@127142   const adapter = deps.createAdapter({ deviceName: device.deviceName });128143   let session: SyncSession;129144   try {130-    session = await connect(expectedRemoteHex, port, ourIdentity, reconnectIds, deps);145+    try {146+      session = await connectLan(147+        expectedRemoteHex,148+        port,149+        ourIdentity,150+        reconnectIds,151+        deps,152+      );153+    } catch (lanError) {154+      const relaySettings = await deps.getRelaySettings?.();155+      if (156+        !relaySettings ||157+        relaySettings.lanOnly ||158+        !deps.openRelaySession159+      ) {160+        throw lanError;161+      }162+      session = await connectRelay(163+        device.relayUrl ?? relaySettings.relayUrl,164+        expectedRemoteHex,165+        ourIdentity,166+        reconnectIds,167+        deps.openRelaySession,168+      );169+    }131170   } finally {132171     masterKey.fill(0);133172     localKeys.secretKey.fill(0);@@ -156,7 +195,7 @@156195   }157196 }158197 159-async function connect(198+async function connectLan(160199   expectedRemoteHex: string,161200   port: number,162201   ourIdentity: SyncPeerIdentity,@@ -201,6 +240,30 @@201240   );202241   await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);203242   return session;243+}244+245+async function connectRelay(246+  relayUrl: string,247+  expectedRemoteHex: string,248+  ourIdentity: SyncPeerIdentity,249+  rendezvousIds: string[],250+  openRelaySession: NonNullable<SyncRunnerDeps["openRelaySession"]>,251+): Promise<SyncSession> {252+  let lastError: unknown;253+  for (const rendezvousId of rendezvousIds) {254+    try {255+      return await openRelaySession(256+        relayUrl,257+        ourIdentity,258+        expectedRemoteHex,259+        rendezvousId,260+      );261+    } catch (error) {262+      lastError = error;263+    }264+  }265+  const detail = lastError instanceof Error ? lastError.message : "unreachable";266+  throw new Error(`relay_sync_failed:${detail}`);204267 }205268 206269 function hexToBytesLocal(hex: string): Uint8Array {

view file

src/shared/sync-kk-transport.test.ts+29 −3
@@ -1,7 +1,11 @@11 import { afterEach, describe, expect, it, vi } from "vitest";22 import { generateKeyPair } from "@stablelib/x25519";33 import { noiseKKResponder } from "./noise-js";4-import { openSyncSession, type SyncPeerIdentity } from "./sync-transport";4+import {5+  openRelaySyncSession,6+  openSyncSession,7+  type SyncPeerIdentity,8+} from "./sync-transport";59 610 const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));711 const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));@@ -23,6 +27,8 @@2327     send(data: Uint8Array | ArrayBuffer): void;2428     close(): void;2529   };30+  let resolveUrl!: (url: string) => void;31+  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });2632   const receiveClient = async () => {2733     const queued = clientFrames.shift();2834     if (queued) return queued;@@ -33,7 +39,8 @@3339   };3440 3541   const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {36-    const WebSocketMock = vi.fn().mockImplementation(function() {42+    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {43+      resolveUrl(url);3744       socket = {3845         binaryType: "arraybuffer",3946         onopen: null,@@ -90,7 +97,7 @@9097     longTermPublicKeyHex: hex(initiator.publicKey),9198     longTermPrivateKey: initiator.secretKey,9299   };93-  return { identity, responder, selectorSeen };100+  return { identity, responder, selectorSeen, urlSeen };94101 }95102 96103 afterEach(() => vi.unstubAllGlobals());@@ -123,5 +130,24 @@123130         RENDEZVOUS_ID,124131       ),125132     ).rejects.toThrow(/identity does not match authenticated KK key/);133+  });134+135+  it("uses the same KK wire over a credential-free wss relay target", async () => {136+    const peer = installPeer();137+    const session = await openRelaySyncSession(138+      "wss://relay.example.test/base/",139+      peer.identity,140+      hex(peer.responder.publicKey),141+      RENDEZVOUS_ID,142+    );143+144+    await expect(peer.urlSeen).resolves.toBe(145+      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,146+    );147+    await expect(peer.selectorSeen).resolves.toEqual({148+      type: "PEACH_RECONNECT",149+      rendezvous_id: RENDEZVOUS_ID,150+    });151+    await session.close();126152   });127153 });

view file

src/shared/sync-transport.ts+67 −6
@@ -24,11 +24,13 @@2424 import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";2525 import { SYNC_LIMITS } from "./sync-limits";2626 import { hexToPubkey } from "./sync-identity";27+import { buildRendezvousUrl } from "./rendezvous";2728 import type { DeviceType } from "./sync-types";2829 2930 const DEFAULT_PROBE_TIMEOUT_MS = 500;3031 const DEFAULT_PROBE_BATCH = 16;3132 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;33+const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;3234 /** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */3335 export const SUBNET_PROBE_COOLDOWN_MS = 60_000;3436 @@ -68,6 +70,11 @@6870   }6971 }7072 73+export interface SyncSessionOptions {74+  connectTimeoutMs?: number;75+  handshakeTimeoutMs?: number;76+}77+7178 function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {7279   return new TextEncoder().encode(JSON.stringify({7380     type: "PAIR_IDENTITY",@@ -92,12 +99,55 @@9299   ourIdentity: SyncPeerIdentity,93100   expectedRemotePubkeyHex: string,94101   rendezvousId: string,95-  options: { connectTimeoutMs?: number } = {},102+  options: SyncSessionOptions = {},96103 ): Promise<SyncSession> {104+  return openSyncSessionAtUrl(105+    `ws://${endpoint.ip}:${endpoint.port}/`,106+    "LAN peer",107+    ourIdentity,108+    expectedRemotePubkeyHex,109+    rendezvousId,110+    options,111+  );112+}113+114+/**115+ * Relay reconnect preserves the exact LAN wire protocol; only the WebSocket116+ * target changes. The URL builder enforces credential-free `wss://`.117+ */118+export async function openRelaySyncSession(119+  relayUrl: string,120+  ourIdentity: SyncPeerIdentity,121+  expectedRemotePubkeyHex: string,122+  rendezvousId: string,123+  options: SyncSessionOptions = {},124+): Promise<SyncSession> {125+  return openSyncSessionAtUrl(126+    buildRendezvousUrl(relayUrl, rendezvousId),127+    "relay",128+    ourIdentity,129+    expectedRemotePubkeyHex,130+    rendezvousId,131+    options,132+  );133+}134+135+async function openSyncSessionAtUrl(136+  url: string,137+  targetLabel: string,138+  ourIdentity: SyncPeerIdentity,139+  expectedRemotePubkeyHex: string,140+  rendezvousId: string,141+  options: SyncSessionOptions,142+): Promise<SyncSession> {97143   if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {98144     throw new Error("reconnect requires a valid rotating rendezvous id");99145   }100-  const ws = await connectWebSocket(endpoint, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS);146+  const ws = await connectWebSocketUrl(147+    url,148+    targetLabel,149+    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,150+  );101151   const inbox: Uint8Array[] = [];102152   const readers: Array<(b: Uint8Array) => void> = [];103153   let closed = false;@@ -177,6 +227,10 @@177227     });178228   }179229 230+  const handshakeTimer = setTimeout(() => {231+    closeError = new Error("sync_handshake_timeout");232+    try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }233+  }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);180234   let session: NoiseNKSession;181235   try {182236     const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);@@ -198,6 +252,7 @@198252       receive,199253     );200254   } catch (err) {255+    clearTimeout(handshakeTimer);201256     try { ws.close(); } catch { /* ignore */ }202257     throw err;203258   }@@ -211,7 +266,9 @@211266     if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {212267       throw new Error("peer identity does not match authenticated KK key");213268     }269+    clearTimeout(handshakeTimer);214270   } catch (err) {271+    clearTimeout(handshakeTimer);215272     try { ws.close(); } catch { /* ignore */ }216273     throw err;217274   }@@ -237,18 +294,22 @@237294   };238295 }239296 240-function connectWebSocket(endpoint: SyncEndpoint, timeoutMs: number): Promise<WebSocket> {297+function connectWebSocketUrl(298+  url: string,299+  targetLabel: string,300+  timeoutMs: number,301+): Promise<WebSocket> {241302   return new Promise((resolve, reject) => {242303     let ws: WebSocket;243304     try {244-      ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);305+      ws = new WebSocket(url);245306     } catch (err) {246307       reject(err as Error);247308       return;248309     }249310     const t = setTimeout(() => {250311       try { ws.close(); } catch { /* ignore */ }251-      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} timed out`));312+      reject(new Error(`${targetLabel} WebSocket connect timed out`));252313     }, timeoutMs);253314     ws.onopen = () => {254315       clearTimeout(t);@@ -256,7 +317,7 @@256317     };257318     ws.onerror = () => {258319       clearTimeout(t);259-      reject(new Error(`WebSocket connect to ${endpoint.ip}:${endpoint.port} errored`));320+      reject(new Error(`${targetLabel} WebSocket connect errored`));260321     };261322   });262323 }

view file

src/shared/sync.disconnect.test.ts+24 −0
@@ -206,6 +206,30 @@206206     vi.unstubAllGlobals();207207   }, 15_000);208208 209+  it("T-020c2: an occupied-but-unspliced relay path times out the handshake", async () => {210+    let mockWs: MockWebSocketInstance;211+    const MockWebSocketCtor = vi.fn().mockImplementation(function(_url: string) {212+      mockWs = createMockWebSocket();213+      return mockWs;214+    });215+    vi.stubGlobal("WebSocket", MockWebSocketCtor);216+217+    const peerKp = generateKeyPair();218+    const peerHex = Array.from(peerKp.publicKey).map(b => b.toString(16).padStart(2, "0")).join("");219+    const sessionPromise = openSyncSession(220+      { ip: "127.0.0.1", port: 9999 },221+      TEST_IDENTITY,222+      peerHex,223+      TEST_RENDEZVOUS_ID,224+      { handshakeTimeoutMs: 10 },225+    );226+227+    await new Promise((r) => setTimeout(r, 0));228+    mockWs!.simulateOpen();229+    await expect(sessionPromise).rejects.toThrow(/handshake_timeout/);230+    vi.unstubAllGlobals();231+  });232+209233   // T-020d: oversized frame → ws.close() is called, session rejects.210234   // MV-3: if the peer sends a frame > SYNC_LIMITS.MAX_FRAME_BYTES, the transport211235   // must close and reject rather than buffer or decrypt the oversized data.

view file