c6c9978bbb7c
feat: add secure relay sync fallback (v4.0.44)
- time
- 2026-07-26 04:02 UTC · 3 months ago
- machine
- koh2
- parent
- 9c0983a25210
↓ peach-browser-c6c9978b.tar.gzatlas at this save
files changed8 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 −0manifest.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": {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",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",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();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 {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 });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 }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.