a5b058442cd7

fail closed when Private relay is chosen with no URL: one relayInForce gate for sync and pairing, and stop pushing the public relay to peers under a private label (v4.2.2)

time
2026-07-26 16:42 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
sync settings ux and relay surfacing
parent
517bf896d7f1
8 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+2 −1src/background/relay-adoption-source-bg.ts+2 −3src/background/sync-runner.ts+3 −3src/background/sync-settings.test.ts+90 −0src/background/sync-settings.ts+28 −8
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.2.1",4+  "version": "4.2.2",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.2.1",4+  "version": "4.2.2",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.2.1",3+  "version": "4.2.2",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/relay-adoption-source-bg.test.ts+2 −1
@@ -46,7 +46,8 @@4646   }),4747 }));4848 49-vi.mock("./sync-settings", () => ({49+vi.mock("./sync-settings", async (importOriginal) => ({50+  ...(await importOriginal<typeof import("./sync-settings")>()),5051   getSyncSettings: async () => ({5152     mode: "public" as const,5253     relayUrl: "wss://relay.peachpasswords.com",

view file

src/background/relay-adoption-source-bg.ts+2 −3
@@ -27,7 +27,7 @@2727   generatePairingRendezvousToken,2828 } from "../shared/rendezvous";2929 import { getLocalPairingIdentity } from "./device-identity";30-import { getSyncSettings } from "./sync-settings";30+import { getSyncSettings, relayInForce } from "./sync-settings";3131 import { getLicenseKey, isProUser } from "./license";3232 import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";3333 import { computeVaultId } from "../shared/sync-identity";@@ -69,9 +69,8 @@6969   let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;7070   try {7171     const settings = await getSyncSettings();72-    if (settings.mode === "off") throw new Error("sync_disabled");72+    const relayUrl = relayInForce(settings);7373     local = await getLocalPairingIdentity();74-    const relayUrl = settings.relayUrl;7574     const token = generatePairingRendezvousToken();7675     const encoded = createPairingInvitation(relayUrl, token);7776     const invitation = parsePairingInvitation(encoded);

view file

src/background/sync-runner.ts+3 −3
@@ -32,7 +32,7 @@3232   dialingRendezvousIds,3333   rendezvousSecretFromHex,3434 } from "../shared/rendezvous";35-import { getSyncSettings, type SyncSettings } from "./sync-settings";35+import { getSyncSettings, relayInForce, type SyncSettings } from "./sync-settings";3636 3737 /**3838  * Test/injection seam. Production wires in the real `openSyncSession`;@@ -107,9 +107,9 @@107107   let session: SyncSession;108108   try {109109     const settings = await deps.getSyncSettings();110-    if (settings.mode === "off") throw new Error("sync_disabled");110+    const configuredRelay = relayInForce(settings);111111     session = await connectRelay(112-      device.relayUrl ?? settings.relayUrl,112+      device.relayUrl ?? configuredRelay,113113       expectedRemoteHex,114114       ourIdentity,115115       reconnectIds,

view file

src/background/sync-settings.test.ts+90 −0
@@ -0,0 +1,90 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import { DEFAULT_RELAY_URL } from "../shared/rendezvous";3+4+const setRelayUrlForAllPairedDevices = vi.fn(async (_relayUrl: string) => undefined);5+vi.mock("./paired-devices", () => ({6+  setRelayUrlForAllPairedDevices: (url: string) => setRelayUrlForAllPairedDevices(url),7+}));8+9+import {10+  getSyncSettings,11+  isPrivateRelayUnconfigured,12+  relayInForce,13+  setPrivateRelayUrl,14+  setSyncMode,15+} from "./sync-settings";16+17+const PRIVATE_RELAY = "wss://relay.example.com";18+19+let store: Record<string, unknown> = {};20+21+beforeEach(() => {22+  store = {};23+  setRelayUrlForAllPairedDevices.mockClear();24+  (globalThis as unknown as { chrome: unknown }).chrome = {25+    storage: {26+      local: {27+        get: async (keys: string[]) =>28+          Object.fromEntries(keys.filter((k) => k in store).map((k) => [k, store[k]])),29+        set: async (values: Record<string, unknown>) => {30+          Object.assign(store, values);31+        },32+      },33+    },34+  };35+});36+37+describe("sync settings migration", () => {38+  it("defaults to public relay with nothing stored", async () => {39+    expect(await getSyncSettings()).toEqual({ mode: "public", relayUrl: DEFAULT_RELAY_URL });40+  });41+42+  it("maps the retired lanOnly flag to off, never to public", async () => {43+    store.peach_sync_lan_only_v1 = true;44+    expect((await getSyncSettings()).mode).toBe("off");45+  });46+47+  it("maps a configured custom relay with no stored mode to private", async () => {48+    store.peach_relay_url_v1 = PRIVATE_RELAY;49+    expect(await getSyncSettings()).toEqual({ mode: "private", relayUrl: PRIVATE_RELAY });50+  });51+52+  it("keeps a configured private relay through off and back", async () => {53+    await setPrivateRelayUrl(PRIVATE_RELAY);54+    await setSyncMode("off");55+    expect((await getSyncSettings()).relayUrl).toBe(PRIVATE_RELAY);56+    expect(await setSyncMode("private")).toEqual({ mode: "private", relayUrl: PRIVATE_RELAY });57+  });58+});59+60+describe("relayInForce", () => {61+  it("returns the relay under public and private", async () => {62+    expect(relayInForce(await getSyncSettings())).toBe(DEFAULT_RELAY_URL);63+    expect(relayInForce(await setPrivateRelayUrl(PRIVATE_RELAY))).toBe(PRIVATE_RELAY);64+  });65+66+  it("refuses when sync is off", async () => {67+    expect(() => relayInForce({ mode: "off", relayUrl: DEFAULT_RELAY_URL })).toThrow("sync_disabled");68+  });69+70+  it("refuses private with no URL rather than falling back to the public relay", async () => {71+    const settings = await setSyncMode("private");72+    expect(isPrivateRelayUnconfigured(settings)).toBe(true);73+    expect(() => relayInForce(settings)).toThrow("private_relay_unset");74+  });75+});76+77+describe("mesh relay propagation", () => {78+  it("does not push the public relay to peers when private has no URL yet", async () => {79+    await setSyncMode("private");80+    expect(setRelayUrlForAllPairedDevices).not.toHaveBeenCalled();81+  });82+83+  it("pushes the relay once it is a real choice", async () => {84+    await setPrivateRelayUrl(PRIVATE_RELAY);85+    expect(setRelayUrlForAllPairedDevices).toHaveBeenCalledWith(PRIVATE_RELAY);86+    setRelayUrlForAllPairedDevices.mockClear();87+    await setSyncMode("public");88+    expect(setRelayUrlForAllPairedDevices).toHaveBeenCalledWith(DEFAULT_RELAY_URL);89+  });90+});

view file

src/background/sync-settings.ts+28 −8
@@ -63,19 +63,39 @@6363   }6464 }6565 66-/** True when sync may run at all. */67-export async function isSyncEnabled(): Promise<boolean> {68-  return (await getSyncSettings()).mode !== "off";66+/**67+ * `private` chosen but no relay entered yet. The stored URL still reads as the68+ * public default, so running sync here would quietly put the user on Peach's69+ * relay under a "Private relay" label — the exact trap the retired `lanOnly`70+ * flag set. Every sync entry point fails closed on this instead.71+ */72+export function isPrivateRelayUnconfigured(settings: SyncSettings): boolean {73+  return settings.mode === "private" && settings.relayUrl === DEFAULT_RELAY_URL;6974 }7075 76+/**77+ * The relay to dial, or a reason not to. Single gate for every sync and pairing78+ * entry point, so a new caller cannot forget one of the two ways sync can be79+ * unusable.80+ */81+export function relayInForce(settings: SyncSettings): string {82+  if (settings.mode === "off") throw new Error("sync_disabled");83+  if (isPrivateRelayUnconfigured(settings)) throw new Error("private_relay_unset");84+  return settings.relayUrl;85+}86+7187 export async function setSyncMode(mode: SyncMode): Promise<SyncSettings> {7288   const current = await getSyncSettings();73-  const relayUrl = relayFor(mode, current.relayUrl);89+  const next: SyncSettings = { mode, relayUrl: relayFor(mode, current.relayUrl) };7490   // The relay is mesh state: every paired device must agree on it, or a peer75-  // keeps dialling a relay we no longer use.76-  if (mode !== "off") await setRelayUrlForAllPairedDevices(relayUrl);77-  await chrome.storage.local.set({ [SYNC_MODE_KEY]: mode, [RELAY_URL_KEY]: relayUrl });78-  return { mode, relayUrl };91+  // keeps dialling a relay we no longer use. Only propagate one we will92+  // actually use — switching to `private` before a URL is typed must not push93+  // the public relay back out to the mesh.94+  if (mode !== "off" && !isPrivateRelayUnconfigured(next)) {95+    await setRelayUrlForAllPairedDevices(next.relayUrl);96+  }97+  await chrome.storage.local.set({ [SYNC_MODE_KEY]: mode, [RELAY_URL_KEY]: next.relayUrl });98+  return next;7999 }80100 81101 /**

view file