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
↓ peach-browser-a5b05844.tar.gzatlas at this save
files changed8 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 −8manifest.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": {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",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",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",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);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,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+});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 /**