3f06919b90ed

restructure browser sync around public/private/off relay modes and delete the LAN transport: pairing codes, subnet probing, the address cache and the local-network module all go, leaving one relay path (v4.2.0)

time
2026-07-26 15:41 UTC · 3 months ago
machine
koh2
agent
claude-opus-5
session
LAN retirement
parent
74a005e256a7
30 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/code-pairing-bg.ts+0 −142src/background/code-pairing.test.ts+0 −425src/background/code-pairing.ts+0 −202src/background/device-identity.ts+37 −0src/background/messages.ts+26 −43src/background/relay-adoption-source-bg.test.ts+16 −7src/background/relay-adoption-source-bg.ts+8 −8src/background/relay-settings.test.ts+0 −75src/background/relay-settings.ts+0 −46src/background/sync-runner.test.ts+19 −182src/background/sync-runner.ts+18 −121src/background/sync-settings.ts+105 −0src/background/sync-static.test.ts+4 −2src/popup/App.tsx+11 −41src/popup/styles/popup.css+46 −0src/popup/views/AdoptVaultView.test.tsx+1 −1src/popup/views/AdoptVaultView.tsx+41 −114src/popup/views/PairDevice.test.tsx+5 −255src/popup/views/PairDevice.tsx+44 −311src/popup/views/Settings.tsx+92 −59src/shared/local-network.ts+0 −28src/shared/pairing-code.test.ts+0 −100src/shared/pairing-code.ts+0 −168src/shared/pairing-dial.test.ts+0 −193src/shared/pairing-dial.ts+6 −36src/shared/sync-types.ts+5 −4src/shared/types.ts+4 −8
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.1.13",4+  "version": "4.2.0",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.1.13",4+  "version": "4.2.0",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.1.13",3+  "version": "4.2.0",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/code-pairing-bg.ts+0 −142
@@ -1,142 +0,0 @@1-// Background wiring for code pairing.2-//3-// Keeps `code-pairing.ts` a PURE, dependency-injected client (so its unit tests4-// run without chrome / vault / WebSocket). This module supplies the production5-// `CodePairingDeps` and the single-flight SAS/confirm round-trip that bridges6-// the blocking `confirm(sas)` callback to the popup via runtime messages:7-//8-//   PAIR_WITH_CODE    → startCodePairing()            (popup → bg, messages.ts)9-//   CODE_PAIR_SAS     ← push when onSasReady fires    (bg → popup)10-//   CODE_PAIR_CONFIRM → resolveCodePairingConfirm()   (popup → bg, messages.ts)11-//   CODE_PAIR_RESULT  ← push on complete/error        (bg → popup)12-13-import {14-  pairWithCode,15-  type CodePairingDeps,16-  type CodePairingCallbacks,17-} from "./code-pairing";18-import { openPairingSocket } from "../shared/pairing-dial";19-import {20-  clearDeviceRevocation,21-  upsertPairedDevice,22-  setLastKnownIp,23-} from "./paired-devices";24-import { buildLocalIdentity } from "./device-identity";25-import { computeVaultId, deriveSyncIdentityKeypair } from "../shared/sync-identity";26-import { isUnlocked, getRootKeyHex } from "./vault";27-import type { LocalIdentity } from "../shared/pairing-handshake";28-import { getRelaySettings } from "./relay-settings";29-30-function hexToBytesLocal(hex: string): Uint8Array {31-  const out = new Uint8Array(hex.length / 2);32-  for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);33-  return out;34-}35-36-/** Best-effort push to the popup. The popup may be closed — pushes are advisory. */37-function push(type: string, payload: unknown): void {38-  try {39-    const maybe = chrome.runtime.sendMessage({ type, payload }) as unknown;40-    if (maybe && typeof (maybe as Promise<unknown>).catch === "function") {41-      (maybe as Promise<unknown>).catch(() => undefined);42-    }43-  } catch {44-    // Popup closed / no receiver — fine.45-  }46-}47-48-/** Production dependency set — real WebSocket, identity, and storage. */49-export function productionCodePairingDeps(): CodePairingDeps {50-  return {51-    openSocket: (ip, port) => openPairingSocket(ip, port),52-    async getRelayUrl() {53-      return (await getRelaySettings()).relayUrl;54-    },55-    async getLocalIdentity(): Promise<{56-      identity: LocalIdentity;57-      privateKey: Uint8Array;58-      publicKey: Uint8Array;59-    }> {60-      if (!isUnlocked()) throw new Error("vault_locked");61-      const masterKeyHex = getRootKeyHex();62-      if (!masterKeyHex) throw new Error("vault_locked");63-      // SEV-2-B: derive from the stable ROOT key so pairing survives a64-      // master-password change. Zero the copy when done.65-      const masterKey = hexToBytesLocal(masterKeyHex);66-      try {67-        const base = await buildLocalIdentity(masterKey);68-        const vaultId = await computeVaultId(masterKey);69-        const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);70-        return {71-          identity: { ...base, vaultId },72-          privateKey: keys.secretKey,73-          publicKey: keys.publicKey,74-        };75-      } finally {76-        masterKey.fill(0);77-      }78-    },79-    // A direct code pairing means a physically present user confirmed the SAS80-    // on both screens — the strongest consent signal in the system, and the81-    // only thing allowed to lift a terminal revocation (see revokeDeviceById).82-    persistDevice: async (d) => {83-      await clearDeviceRevocation(d.deviceId);84-      await upsertPairedDevice(d);85-    },86-    cacheLastKnownIp: (pubkeyHex, ip) => setLastKnownIp(pubkeyHex, ip),87-  };88-}89-90-// ── Single in-flight pairing per worker ──────────────────────────────────────91-// One pairing at a time. The blocking confirm() resolver is parked here while92-// the popup shows the SAS; CODE_PAIR_CONFIRM resolves it.93-let pendingConfirm: ((confirmed: boolean) => void) | null = null;94-let inFlight = false;95-96-function clearPairingState(): void {97-  pendingConfirm = null;98-  inFlight = false;99-}100-101-export function isCodePairingInFlight(): boolean {102-  return inFlight;103-}104-105-/**106- * Kick off a code pairing. Returns immediately; the SAS and the terminal107- * result are delivered to the popup as CODE_PAIR_SAS / CODE_PAIR_RESULT108- * pushes. `deps` is injectable for tests.109- */110-export function startCodePairing(111-  code: string,112-  deps: CodePairingDeps = productionCodePairingDeps(),113-): void {114-  inFlight = true;115-  const cb: CodePairingCallbacks = {116-    onSasReady: (sas) => push("CODE_PAIR_SAS", { sas }),117-    onComplete: (device) => {118-      push("CODE_PAIR_RESULT", { status: "complete", device });119-      clearPairingState();120-    },121-    onError: (error) => {122-      push("CODE_PAIR_RESULT", { status: "error", error });123-      clearPairingState();124-    },125-  };126-  // The confirm callback blocks on the popup's CODE_PAIR_CONFIRM reply.127-  const confirm = (_sas: string): Promise<boolean> =>128-    new Promise<boolean>((resolve) => { pendingConfirm = resolve; });129-130-  void pairWithCode(code, cb, deps, confirm).finally(() => {131-    // onComplete/onError already cleared state; this is the backstop for an132-    // unexpected throw that skipped the callbacks.133-    if (inFlight) clearPairingState();134-  });135-}136-137-/** Resolve the parked confirm() with the user's decision from the popup. */138-export function resolveCodePairingConfirm(confirmed: boolean): void {139-  const resolve = pendingConfirm;140-  pendingConfirm = null;141-  if (resolve) resolve(confirmed);142-}

view file at the parent

src/background/code-pairing.test.ts+0 −425
@@ -1,425 +0,0 @@1-// Code pairing — the PAIR_IDENTITY half over an address-bearing pairing code.2-//3-// Driven entirely through the dependency-injection seam (CodePairingDeps and a4-// fake PairingSocket), so no real WebSocket or Noise crypto runs — except the5-// SAS KAT, which calls the REAL `derivePairingSAS` to pin cross-implementation6-// agreement with the peer.7-8-import { beforeEach, describe, expect, it, vi } from "vitest";9-10-import {11-  pairWithCode,12-  type CodePairingDeps,13-  type CodePairingCallbacks,14-} from "./code-pairing";15-import type { PairingSocket } from "../shared/pairing-dial";16-17-// REAL SAS implementation — reused, not re-derived (ground truth §5).18-import { derivePairingSAS } from "../shared/pairing-handshake";19-import { encodePairingCode } from "../shared/pairing-code";20-import type { LocalIdentity } from "../shared/pairing-handshake";21-import type { PairedDevice } from "../shared/sync-types";22-23-// ── helpers ────────────────────────────────────────────────────────────────24-25-const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));26-const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));27-28-const PEER_EPHEMERAL_HEX = "a".repeat(64);29-// The ACK key is the Noise NK responder static, so the encrypted identity must30-// repeat exactly this key after the handshake.31-const PHONE_LONGTERM_HEX = PEER_EPHEMERAL_HEX;32-const PHONE_TOKEN = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);33-const PHONE_CODE = encodePairingCode("192.168.4.77", 51555, PHONE_TOKEN);34-35-const OUR_IDENTITY: LocalIdentity = {36-  deviceId: "ext-local-001",37-  deviceName: "Firefox on MacBook",38-  deviceType: "extension",39-  longTermPublicKeyHex: "c".repeat(64),40-  signingPublicKeyHex: "d".repeat(64),41-  vaultId: "e".repeat(64),42-};43-const OUR_PRIVATE_KEY = new Uint8Array(32).fill(0x0c);44-const OUR_PUBLIC_KEY = new Uint8Array(32).fill(0xcc);45-46-/**47- * Scripted fake socket. Captures everything sent, and replays a queued list48- * of inbound frames. A frame of `null` means "the socket closes here with no49- * data" — the wrong-code behaviour (no error frame).50- */51-function makeFakeSocket(inbound: Array<Uint8Array | null>) {52-  const sent: Uint8Array[] = [];53-  let closed = false;54-  let lastWasClose = false;55-  const queue = [...inbound];56-  const sock: PairingSocket = {57-    send(bytes) { sent.push(bytes); },58-    async receive() {59-      const next = queue.shift();60-      if (next === undefined || next === null) {61-        closed = true;62-        lastWasClose = true;63-        throw new Error("socket closed");64-      }65-      lastWasClose = false;66-      return next;67-    },68-    close() { closed = true; },69-    closedBeforeFrame() { return lastWasClose; },70-  };71-  return { sock, sent, isClosed: () => closed };72-}73-74-/**75- * A fake Noise session that no-ops encrypt/decrypt (identity transform) so we76- * can drive the PAIR_IDENTITY exchange without real crypto. We stub77- * `noiseNKInitiator` via vi.mock below; this is the shape it returns.78- */79-const FAKE_HANDSHAKE_HASH = Uint8Array.from([80-  0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),81-]);82-83-// Mock the NK initiator: skip the two handshake messages, hand back a session84-// whose encrypt/decrypt are identity transforms and whose handshakeHash is the85-// cross-platform KAT prefix (so the SAS test below is end-to-end through the flow).86-vi.mock("../shared/noise-js", async (orig) => {87-  const actual = await orig<typeof import("../shared/noise-js")>();88-  return {89-    ...actual,90-    noiseNKInitiator: vi.fn(async (_responderStatic, _send, _receive) => ({91-      encrypt: (pt: Uint8Array) => pt,92-      decrypt: (ct: Uint8Array) => ct,93-      handshakeHash: FAKE_HANDSHAKE_HASH,94-      close: vi.fn(),95-    })),96-    performInitiatorAuth: vi.fn(async () => undefined),97-  };98-});99-100-function makeDeps(overrides: Partial<CodePairingDeps> = {}): {101-  deps: CodePairingDeps;102-  persisted: PairedDevice[];103-  sockets: ReturnType<typeof makeFakeSocket>[];104-} {105-  const persisted: PairedDevice[] = [];106-  const sockets: ReturnType<typeof makeFakeSocket>[] = [];107-  const deps: CodePairingDeps = {108-    openSocket: vi.fn(async () => {109-      // default: ACK then a PAIR_IDENTITY response110-      const s = makeFakeSocket([111-        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),112-        enc({113-          type: "PAIR_IDENTITY",114-          device_id: "android-001",115-          device_name: "Pixel 8",116-          device_type: "android",117-          long_term_public_key_hex: PHONE_LONGTERM_HEX,118-          ws_port: 49142,119-          relay_url: "wss://relay.peachpasswords.com",120-        }),121-      ]);122-      sockets.push(s);123-      return s.sock;124-    }),125-    getLocalIdentity: vi.fn(async () => ({126-      identity: OUR_IDENTITY,127-      privateKey: OUR_PRIVATE_KEY,128-      publicKey: OUR_PUBLIC_KEY,129-    })),130-    getRelayUrl: vi.fn(async () => "wss://relay.peachpasswords.com"),131-    persistDevice: vi.fn(async (d: PairedDevice) => { persisted.push(d); }),132-    ...overrides,133-  };134-  return { deps, persisted, sockets };135-}136-137-function makeCallbacks(): {138-  cb: CodePairingCallbacks;139-  sas: { value?: string };140-  errors: string[];141-  completed: PairedDevice[];142-} {143-  const sas: { value?: string } = {};144-  const errors: string[] = [];145-  const completed: PairedDevice[] = [];146-  const cb: CodePairingCallbacks = {147-    onSasReady: (s) => { sas.value = s; },148-    onError: (e) => { errors.push(e); },149-    onComplete: (d) => { completed.push(d); },150-  };151-  return { cb, sas, errors, completed };152-}153-154-beforeEach(() => {155-  vi.clearAllMocks();156-});157-158-// ── specs ────────────────────────────────────────────────────────────────159-160-describe("pairWithCode", () => {161-  it("dials the coded endpoint and sends its token in PEACH_HELLO", async () => {162-    const { deps, sockets } = makeDeps();163-    const { cb } = makeCallbacks();164-165-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);166-167-    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);168-    const firstFrame = dec(sockets[0].sent[0]);169-    expect(firstFrame).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });170-  });171-172-  it("treats a socket close without PEACH_HELLO_ACK as invalid_code", async () => {173-    // The listener sends NO error frame on a wrong/expired code — it SILENTLY174-    // CLOSES the socket and rotates the code. So the175-    // absence of a PEACH_HELLO_ACK *is* the wrong-code signal. (This replaces176-    // the original brief's bogus PEACH_HELLO_ERROR test — no such frame exists.)177-    const closeImmediately = makeFakeSocket([null]); // close, no data178-    const { deps } = makeDeps({179-      openSocket: vi.fn(async () => closeImmediately.sock),180-    });181-    const { cb, errors, completed } = makeCallbacks();182-183-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);184-185-    expect(errors).toEqual(["invalid_code"]);186-    expect(completed).toHaveLength(0);187-    expect(deps.persistDevice).not.toHaveBeenCalled();188-  });189-190-  it("derives the SAS via the real derivePairingSAS — KAT 0fbab8cd… → 44907520", () => {191-    // Cross-impl KAT, matched byte-for-byte by peach-android's SASDerivation:192-    // the first eight bytes 0f ba b8 cd 00 00 00 00 read as an unsigned193-    // big-endian integer are 1133421446844907520; mod 1e8 = 44907520.194-    const hash = Uint8Array.from([195-      0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0),196-    ]);197-    expect(derivePairingSAS(hash)).toBe("44907520");198-  });199-200-  it("surfaces that same SAS through the pairing flow", async () => {201-    // FAKE_HANDSHAKE_HASH starts 0fbab8cd, so onSasReady must report 44907520.202-    const { deps } = makeDeps();203-    const { cb, sas } = makeCallbacks();204-205-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);206-207-    expect(sas.value).toBe("44907520");208-  });209-210-  it("persists the phone identity on SAS confirm", async () => {211-    const { deps, persisted } = makeDeps();212-    const { cb, completed, errors } = makeCallbacks();213-214-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);215-216-    expect(errors).toHaveLength(0);217-    expect(persisted).toHaveLength(1);218-    expect(persisted[0].deviceType).toBe("android");219-    expect(persisted[0].deviceId).toBe("android-001");220-    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);221-    expect(persisted[0].trustSource).toBe("direct");222-    expect(persisted[0].wsPort).toBe(49142);223-    expect(persisted[0].relayUrl).toBe("wss://relay.peachpasswords.com");224-    expect(persisted[0].rendezvousSecretHex).toMatch(/^[0-9a-f]{64}$/);225-    expect(completed).toHaveLength(1);226-  });227-228-  it("proves possession before accepting the peer identity", async () => {229-    const { performInitiatorAuth } = await import("../shared/noise-js");230-    const { deps } = makeDeps();231-    const { cb, errors } = makeCallbacks();232-233-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);234-235-    expect(errors).toHaveLength(0);236-    expect(performInitiatorAuth).toHaveBeenCalledTimes(1);237-    expect(performInitiatorAuth).toHaveBeenCalledWith(238-      expect.anything(),239-      OUR_PRIVATE_KEY,240-      OUR_PUBLIC_KEY,241-      expect.any(Uint8Array),242-      expect.any(Function),243-      expect.any(Function),244-    );245-  });246-247-  it("rejects a PAIR_IDENTITY that is not bound to the Noise responder key", async () => {248-    const { deps, persisted } = makeDeps({249-      openSocket: vi.fn(async () => makeFakeSocket([250-        enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),251-        enc({252-          type: "PAIR_IDENTITY",253-          device_id: "attacker",254-          device_name: "Wrong key",255-          device_type: "android",256-          long_term_public_key_hex: "f".repeat(64),257-          ws_port: 49142,258-        }),259-      ]).sock),260-    });261-    const { cb, errors } = makeCallbacks();262-263-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);264-265-    expect(errors).toEqual(["handshake_failed"]);266-    expect(persisted).toHaveLength(0);267-  });268-269-  it("aborts with sas_mismatch and persists nothing when the user rejects", async () => {270-    const { deps, persisted } = makeDeps();271-    const { cb, errors } = makeCallbacks();272-273-    await pairWithCode(PHONE_CODE, cb, deps, async () => false);274-275-    expect(errors).toEqual(["sas_mismatch"]);276-    expect(persisted).toHaveLength(0);277-    expect(deps.persistDevice).not.toHaveBeenCalled();278-  });279-280-  it("includes vault_id in the outbound PAIR_IDENTITY (Change 1)", async () => {281-    const { deps, sockets } = makeDeps();282-    const { cb } = makeCallbacks();283-284-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);285-286-    // sent[0] = PEACH_HELLO (plaintext); sent[1] = our PAIR_IDENTITY287-    // (encrypt is identity in the mock, so it decodes directly).288-    const ourPairIdentity = dec(sockets[0].sent[1]);289-    expect(ourPairIdentity.type).toBe("PAIR_IDENTITY");290-    expect(ourPairIdentity.vault_id).toBe(OUR_IDENTITY.vaultId);291-  });292-293-  it("reports vault_locked when local identity is unavailable", async () => {294-    const { deps } = makeDeps({295-      getLocalIdentity: vi.fn(async () => { throw new Error("vault_locked"); }),296-    });297-    const { cb, errors } = makeCallbacks();298-299-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);300-301-    expect(errors).toEqual(["vault_locked"]);302-    expect(deps.openSocket).not.toHaveBeenCalled();303-  });304-});305-306-// ── pairing-code path (the phone is the listener) ──────────────────────────307-308-/** Deps whose socket replays an Android peer's ACK + PAIR_IDENTITY. */309-function makePhoneDeps(deviceType: string | undefined = "android") {310-  const identity: Record<string, unknown> = {311-    type: "PAIR_IDENTITY",312-    device_id: "android-001",313-    device_name: "Pixel 8",314-    long_term_public_key_hex: PHONE_LONGTERM_HEX,315-    ws_port: 49142,316-    relay_url: "wss://relay.peachpasswords.com",317-  };318-  identity.device_type = deviceType;319-  return makeDeps({320-    openSocket: vi.fn(async () => makeFakeSocket([321-      enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),322-      enc(identity),323-    ]).sock),324-  });325-}326-327-describe("pairWithCode — pairing code", () => {328-  it("dials the address in the code", async () => {329-    const { deps } = makePhoneDeps();330-    const { cb, errors } = makeCallbacks();331-332-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);333-334-    expect(errors).toHaveLength(0);335-    expect(deps.openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);336-  });337-338-  it("presents the token as hex in the plaintext hello, not a 6-digit code", async () => {339-    const sockets: ReturnType<typeof makeFakeSocket>[] = [];340-    const { deps } = makeDeps({341-      openSocket: vi.fn(async () => {342-        const s = makeFakeSocket([343-          enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: PEER_EPHEMERAL_HEX }),344-          enc({345-            type: "PAIR_IDENTITY",346-            device_id: "android-001",347-            device_name: "Pixel 8",348-            device_type: "android",349-            long_term_public_key_hex: PHONE_LONGTERM_HEX,350-            ws_port: 49142,351-            relay_url: "wss://relay.peachpasswords.com",352-          }),353-        ]);354-        sockets.push(s);355-        return s.sock;356-      }),357-    });358-    const { cb } = makeCallbacks();359-360-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);361-362-    const hello = dec(sockets[0].sent[0]);363-    expect(hello.type).toBe("PEACH_HELLO");364-    expect(hello.token).toBe("deadbeef");365-    expect(hello.code).toBeUndefined();366-  });367-368-  it("persists the phone with the device type it announced", async () => {369-    const { deps, persisted } = makePhoneDeps();370-    const { cb, completed } = makeCallbacks();371-372-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);373-374-    expect(persisted).toHaveLength(1);375-    expect(persisted[0].deviceType).toBe("android");376-    expect(persisted[0].deviceId).toBe("android-001");377-    expect(persisted[0].longTermPublicKeyHex).toBe(PHONE_LONGTERM_HEX);378-    expect(persisted[0].trustSource).toBe("direct");379-    expect(persisted[0].wsPort).toBe(49142);380-    expect(completed).toHaveLength(1);381-  });382-383-  it("rejects a peer that omits its device type", async () => {384-    const { deps, persisted } = makePhoneDeps("");385-    const { cb, errors } = makeCallbacks();386-387-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);388-389-    expect(errors).toEqual(["handshake_failed"]);390-    expect(persisted).toHaveLength(0);391-  });392-393-  it("caches the coded IP for the next sync round", async () => {394-    const cacheLastKnownIp = vi.fn(async () => undefined);395-    const { deps } = makePhoneDeps();396-    deps.cacheLastKnownIp = cacheLastKnownIp;397-    const { cb } = makeCallbacks();398-399-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);400-401-    expect(cacheLastKnownIp).toHaveBeenCalledWith(PHONE_LONGTERM_HEX, "192.168.4.77");402-  });403-404-  it("reports no_host_found when the coded endpoint refuses the connection", async () => {405-    const { deps } = makeDeps({406-      openSocket: vi.fn(async () => { throw new Error("ECONNREFUSED"); }),407-    });408-    const { cb, errors } = makeCallbacks();409-410-    await pairWithCode(PHONE_CODE, cb, deps, async () => true);411-412-    expect(errors).toEqual(["no_host_found"]);413-    expect(deps.persistDevice).not.toHaveBeenCalled();414-  });415-416-  it("still aborts on SAS rejection — the token is not the security boundary", async () => {417-    const { deps, persisted } = makePhoneDeps();418-    const { cb, errors } = makeCallbacks();419-420-    await pairWithCode(PHONE_CODE, cb, deps, async () => false);421-422-    expect(errors).toEqual(["sas_mismatch"]);423-    expect(persisted).toHaveLength(0);424-  });425-});

view file at the parent

src/background/code-pairing.ts+0 −202
@@ -1,202 +0,0 @@1-// Device pairing over a code — the PAIR_IDENTITY half.2-//3-// The dialling half (decode the code, connect, plaintext hello, Noise NK) lives4-// in `shared/pairing-dial.ts` and is shared with vault adoption, which speaks5-// ADOPT_* frames over the same channel instead. This module is only what6-// happens once that channel is open:7-//8-//   1. exchange PAIR_IDENTITY (send ours incl. vault_id, then read theirs)9-//   2. derive the SAS, surface it for user confirmation10-//   3. on confirm -> persist the PairedDevice; on reject -> abort without11-//      persisting12-//13-// A pairing code (`A74H-48J3-KI32-OEP4`) carries the listener's endpoint and14-// one-time token.15-16-import { dialPairingChannel, PAIRING_PORT, type PairingDialDeps } from "../shared/pairing-dial";17-import type { LocalIdentity } from "../shared/pairing-handshake";18-import type { DeviceType, PairedDevice } from "../shared/sync-types";19-import { bytesToHex, normalizeRelayUrl } from "../shared/rendezvous";20-import { constantTimeEqualHex } from "../shared/constant-time";21-22-/** Default sync port a peer announces; used when ws_port is absent. Same23- *  fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */24-const DEFAULT_SYNC_PORT = PAIRING_PORT;25-26-export type CodePairingError =27-  | "invalid_code"      // socket closed before PEACH_HELLO_ACK (wrong/expired)28-  | "no_host_found"     // nothing answered at the endpoint29-  | "handshake_failed"  // Noise NK / PAIR_IDENTITY decode failed30-  | "sas_mismatch"      // user rejected the SAS31-  | "vault_locked";     // no master key available32-33-export interface CodePairingDeps extends PairingDialDeps {34-  /**35-   * Public identity plus the private key used for structural36-   * proof-of-possession. The private half never enters a wire frame.37-   */38-  getLocalIdentity(): Promise<{39-    identity: LocalIdentity;40-    privateKey: Uint8Array;41-    publicKey: Uint8Array;42-  }>;43-  /** Current mesh relay URL; sent in the encrypted pairing identity. */44-  getRelayUrl(): Promise<string>;45-  /** Persist on user confirm. */46-  persistDevice(device: PairedDevice): Promise<void>;47-  /** Optional fast-path cache for the next sync round. */48-  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;49-}50-51-export interface CodePairingCallbacks {52-  /** Show the 8-digit SAS; the popup must compare it against the peer's. */53-  onSasReady(sas: string): void;54-  /** Pairing succeeded and the device was persisted. */55-  onComplete(device: PairedDevice): void;56-  /** Pairing failed at some stage. */57-  onError(error: CodePairingError): void;58-}59-60-interface PairingFrame {61-  type: "PAIR_IDENTITY";62-  device_id: string;63-  device_name: string;64-  device_type: DeviceType;65-  long_term_public_key_hex: string;66-  signing_public_key_hex?: string;67-  ws_port?: number;68-  vault_id?: string; // Change 1 — additive, optional69-  relay_url?: string;70-}71-72-function encodeJson(obj: unknown): Uint8Array {73-  return new TextEncoder().encode(JSON.stringify(obj));74-}75-76-function decodePairIdentity(bytes: Uint8Array): PairingFrame {77-  const p = JSON.parse(new TextDecoder().decode(bytes)) as Record<string, unknown>;78-  if (p?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY");79-  if (typeof p.device_id !== "string" || !p.device_id) throw new Error("device_id missing");80-  if (typeof p.device_name !== "string") throw new Error("device_name missing");81-  if (82-    p.device_type !== "extension" &&83-    p.device_type !== "android" &&84-    p.device_type !== "ios"85-  ) {86-    throw new Error("device_type invalid");87-  }88-  if (typeof p.long_term_public_key_hex !== "string" ||89-      !/^[0-9a-f]{64}$/.test(p.long_term_public_key_hex)) {90-    throw new Error("long_term_public_key_hex is not 32-byte hex");91-  }92-  if (p.relay_url !== undefined) {93-    if (typeof p.relay_url !== "string") throw new Error("relay_url invalid");94-    p.relay_url = normalizeRelayUrl(p.relay_url);95-  }96-  return p as unknown as PairingFrame;97-}98-99-/**100- * Drive a complete code pairing.101- *102- * `confirm` is a function the caller resolves AFTER the user has compared the103- * SAS shown here against the SAS the peer displays. Resolving false (or104- * rejecting) aborts before any device is persisted — this is the out-of-band105- * MITM defence (see the SECURITY note in pairing-handshake.ts), and it, not the106- * pairing token, is what actually authenticates the peer.107- */108-export async function pairWithCode(109-  code: string,110-  cb: CodePairingCallbacks,111-  deps: CodePairingDeps,112-  confirm: (sas: string) => Promise<boolean>,113-): Promise<void> {114-  let local: Awaited<ReturnType<CodePairingDeps["getLocalIdentity"]>>;115-  try {116-    local = await deps.getLocalIdentity();117-  } catch {118-    cb.onError("vault_locked");119-    return;120-  }121-122-  const dial = await dialPairingChannel(code, deps);123-  if (!dial.ok) {124-    cb.onError(dial.error);125-    return;126-  }127-  const {128-    channel,129-    sas,130-    host,131-    rendezvousSecretHex,132-    remoteStaticPublicKey,133-    authenticateInitiator,134-    socket,135-  } = dial.dialled;136-137-  try {138-    const { identity, privateKey, publicKey } = local;139-    if (!constantTimeEqualHex(bytesToHex(publicKey), identity.longTermPublicKeyHex)) {140-      throw new Error("local pairing key does not match advertised identity");141-    }142-    // E1: prove the private half of the advertised initiator identity before143-    // either side trusts a PAIR_IDENTITY frame or asks the user for SAS consent.144-    await authenticateInitiator(privateKey, publicKey);145-146-    const relayUrl = await deps.getRelayUrl();147-    // Initiator (browser) sends its identity FIRST, then reads the peer's.148-    const ourFrame: PairingFrame = {149-      type: "PAIR_IDENTITY",150-      device_id: identity.deviceId,151-      device_name: identity.deviceName,152-      device_type: identity.deviceType,153-      long_term_public_key_hex: identity.longTermPublicKeyHex,154-      signing_public_key_hex: identity.signingPublicKeyHex,155-      ws_port: identity.wsPort,156-      vault_id: identity.vaultId, // Change 1157-      relay_url: relayUrl,158-    };159-    await channel.send(encodeJson(ourFrame));160-161-    const theirFrame = decodePairIdentity(await channel.receive());162-    if (!constantTimeEqualHex(163-      theirFrame.long_term_public_key_hex,164-      bytesToHex(remoteStaticPublicKey),165-    )) {166-      throw new Error("peer identity does not match Noise responder key");167-    }168-169-    cb.onSasReady(sas);170-    const confirmed = await confirm(sas);171-    if (!confirmed) {172-      cb.onError("sas_mismatch");173-      return;174-    }175-176-    // ws_port comes from the authenticated peer frame and falls back to the177-    // native listener's fixed pairing/sync port for older peers.178-    const device: PairedDevice = {179-      deviceId: theirFrame.device_id,180-      deviceName: theirFrame.device_name,181-      deviceType: theirFrame.device_type,182-      longTermPublicKeyHex: theirFrame.long_term_public_key_hex,183-      signingPublicKeyHex: theirFrame.signing_public_key_hex,184-      trustSource: "direct",185-      wsPort: typeof theirFrame.ws_port === "number" ? theirFrame.ws_port : DEFAULT_SYNC_PORT,186-      rendezvousSecretHex,187-      // The listening phone is the authority for the relay this pair will use.188-      relayUrl: theirFrame.relay_url ?? relayUrl,189-      pairedAt: Date.now(),190-    };191-    await deps.persistDevice(device);192-    if (deps.cacheLastKnownIp && device.longTermPublicKeyHex) {193-      await deps.cacheLastKnownIp(device.longTermPublicKeyHex, host).catch(() => undefined);194-    }195-    cb.onComplete(device);196-  } catch (err) {197-    if (typeof console !== "undefined") console.error("[code-pairing] handshake failed:", err);198-    cb.onError("handshake_failed");199-  } finally {200-    socket.close();201-  }202-}

view file at the parent

src/background/device-identity.ts+37 −0
@@ -15,11 +15,14 @@1515 // only the public half to callers that don't need to sign.1616 1717 import {18+  computeVaultId,1819   deriveSyncIdentityKeypair,1920   deriveSyncSigningKeypair,2021   pubkeyToHex,2122 } from "../shared/sync-identity";23+import type { LocalIdentity } from "../shared/pairing-handshake";2224 import type { DeviceType } from "../shared/sync-types";25+import { getRootKeyHex, isUnlocked } from "./vault";2326 2427 const DEVICE_ID_KEY = "peach_device_id";2528 const DEVICE_NAME_KEY = "peach_device_name";@@ -140,4 +143,38 @@140143   bytes[8] = (bytes[8] & 0x3f) | 0x80;141144   const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");142145   return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;146+}147+148+/**149+ * The full local pairing identity plus its sync keypair, derived from the150+ * stable ROOT key so pairing survives a master-password change. Callers own151+ * the returned key material and must zero it.152+ *153+ * Lived in `code-pairing-bg.ts` until LAN code pairing was retired154+ * (docs/sync-lan-retirement.md); it was never LAN-specific.155+ */156+export async function getLocalPairingIdentity(): Promise<{157+  identity: LocalIdentity;158+  privateKey: Uint8Array;159+  publicKey: Uint8Array;160+}> {161+  if (!isUnlocked()) throw new Error("vault_locked");162+  const masterKeyHex = getRootKeyHex();163+  if (!masterKeyHex) throw new Error("vault_locked");164+  const masterKey = new Uint8Array(masterKeyHex.length / 2);165+  for (let i = 0; i < masterKey.length; i++) {166+    masterKey[i] = parseInt(masterKeyHex.slice(i * 2, i * 2 + 2), 16);167+  }168+  try {169+    const base = await buildLocalIdentity(masterKey);170+    const vaultId = await computeVaultId(masterKey);171+    const keys = deriveSyncIdentityKeypair(masterKey, base.deviceId);172+    return {173+      identity: { ...base, vaultId },174+      privateKey: keys.secretKey,175+      publicKey: keys.publicKey,176+    };177+  } finally {178+    masterKey.fill(0);179+  }143180 }

view file

src/background/messages.ts+26 −43
@@ -84,23 +84,18 @@8484 // a worker either — so the real reason was replaced by "window is not defined"8585 // and the pairing UI just stopped. scripts/check-sw-static-imports.mjs fails8686 // the build if a runtime import() reappears anywhere in the worker's graph.87-import { parsePairingCode } from "../shared/pairing-code";8887 import {89-  isCodePairingInFlight,90-  resolveCodePairingConfirm,91-  startCodePairing,92-} from "./code-pairing-bg";93-import {9488   cancelRelayAdoptionSource,9589   confirmRelayAdoptionSource,9690   isRelayAdoptionSourceInFlight,9791   startRelayAdoptionSource,9892 } from "./relay-adoption-source-bg";9993 import {100-  getRelaySettings,101-  setLanOnly,102-  setRelayUrl,103-} from "./relay-settings";94+  getSyncSettings,95+  setPrivateRelayUrl,96+  setSyncMode,97+  type SyncMode,98+} from "./sync-settings";10499 import {105100   getSyncState,106101   hydratePersistedSyncState,@@ -1464,27 +1459,10 @@14641459         return { ok: true };14651460       }14661461 1467-      // ── Device code pairing ───────────────────────────────────────────────1462+      // ── Relay pairing ─────────────────────────────────────────────────────14681463       // Not Pro-gated (a free device may pair to receive Pro/sync). The whole NK1469-      // pairing runs in the background; the SAS and the terminal result reach the1470-      // popup as CODE_PAIR_SAS / CODE_PAIR_RESULT pushes.1471-      case "PAIR_WITH_CODE": {1472-        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1473-        const { code } = (msg.payload ?? {}) as { code?: string };1474-        if (typeof code !== "string" || parsePairingCode(code) === null) {1475-          return { ok: false, error: "invalid_payload" };1476-        }1477-        if (isCodePairingInFlight()) return { ok: false, error: "pairing_in_progress" };1478-        startCodePairing(code);1479-        return { ok: true };1480-      }1481-1482-      case "CODE_PAIR_CONFIRM": {1483-        const { confirmed } = (msg.payload ?? {}) as { confirmed?: boolean };1484-        resolveCodePairingConfirm(confirmed === true);1485-        return { ok: true };1486-      }1487-1464+      // session runs in the background; the SAS and the terminal result reach1465+      // the popup as ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT pushes.14881466       case "START_ADOPTION_SOURCE": {14891467         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };14901468         if (isRelayAdoptionSourceInFlight()) {@@ -1540,31 +1518,36 @@15401518         return { ok: true, data: getSyncState() };15411519       }15421520 1543-      case "GET_RELAY_SETTINGS": {1544-        return { ok: true, data: await getRelaySettings() };1521+      case "GET_SYNC_SETTINGS": {1522+        return { ok: true, data: await getSyncSettings() };15451523       }15461524 1547-      case "SET_RELAY_SETTINGS": {1525+      case "SET_SYNC_SETTINGS": {15481526         const payload = (msg.payload ?? {}) as {1549-          relayUrl?: string;1550-          lanOnly?: boolean;1527+          mode?: unknown;1528+          relayUrl?: unknown;15511529         };15521530         try {1531+          // A relay URL implies private mode — offering a custom relay and not1532+          // switching to it would be a trap.15531533           if (payload.relayUrl !== undefined) {15541534             if (typeof payload.relayUrl !== "string") {15551535               return { ok: false, error: "invalid_relay_url" };15561536             }1557-            await setRelayUrl(payload.relayUrl);1537+            return { ok: true, data: await setPrivateRelayUrl(payload.relayUrl) };15581538           }1559-          if (payload.lanOnly !== undefined) {1560-            if (typeof payload.lanOnly !== "boolean") {1561-              return { ok: false, error: "invalid_lan_only" };1539+          if (payload.mode !== undefined) {1540+            if (payload.mode !== "public" && payload.mode !== "private" && payload.mode !== "off") {1541+              return { ok: false, error: "invalid_sync_mode" };15621542             }1563-            await setLanOnly(payload.lanOnly);1543+            return { ok: true, data: await setSyncMode(payload.mode as SyncMode) };15641544           }1565-          return { ok: true, data: await getRelaySettings() };1566-        } catch {1567-          return { ok: false, error: "invalid_relay_url" };1545+          return { ok: true, data: await getSyncSettings() };1546+        } catch (error) {1547+          return {1548+            ok: false,1549+            error: error instanceof Error ? error.message : "invalid_relay_url",1550+          };15681551         }15691552       }15701553 

view file

src/background/relay-adoption-source-bg.test.ts+16 −7
@@ -31,17 +31,26 @@3131   };3232 });3333 34-vi.mock("./code-pairing-bg", () => ({35-  productionCodePairingDeps: () => ({36-    getLocalIdentity: mocks.getLocalIdentity,34+35+vi.mock("./device-identity", () => ({36+  getLocalPairingIdentity: async () => ({37+    identity: {38+      deviceId: "test-device",39+      deviceName: "Test",40+      deviceType: "extension",41+      longTermPublicKeyHex: "aa".repeat(32),42+      vaultId: "bb".repeat(32),43+    },44+    privateKey: new Uint8Array(32).fill(7),45+    publicKey: new Uint8Array(32).fill(8),3746   }),3847 }));3948 40-vi.mock("./relay-settings", () => ({41-  getRelaySettings: vi.fn(async () => ({49+vi.mock("./sync-settings", () => ({50+  getSyncSettings: async () => ({51+    mode: "public" as const,4252     relayUrl: "wss://relay.peachpasswords.com",43-    lanOnly: false,44-  })),53+  }),4554 }));4655 4756 vi.mock("./license", () => ({

view file

src/background/relay-adoption-source-bg.ts+8 −8
@@ -26,8 +26,8 @@2626   deriveRendezvousSecret,2727   generatePairingRendezvousToken,2828 } from "../shared/rendezvous";29-import { productionCodePairingDeps } from "./code-pairing-bg";30-import { getRelaySettings } from "./relay-settings";29+import { getLocalPairingIdentity } from "./device-identity";30+import { getSyncSettings } from "./sync-settings";3131 import { getLicenseKey, isProUser } from "./license";3232 import { getRootKeyHex, isUnlocked, verifyMainPassword } from "./vault";3333 import { computeVaultId } from "../shared/sync-identity";@@ -66,12 +66,12 @@6666   if (!isUnlocked()) throw new Error("vault_locked");6767   inFlight = true;6868 69-  let local: Awaited<70-    ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>71-  > | null = null;69+  let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;7270   try {73-    local = await productionCodePairingDeps().getLocalIdentity();74-    const relayUrl = (await getRelaySettings()).relayUrl;71+    const settings = await getSyncSettings();72+    if (settings.mode === "off") throw new Error("sync_disabled");73+    local = await getLocalPairingIdentity();74+    const relayUrl = settings.relayUrl;7575     const token = generatePairingRendezvousToken();7676     const encoded = createPairingInvitation(relayUrl, token);7777     const invitation = parsePairingInvitation(encoded);@@ -131,7 +131,7 @@131131   socket: PairingSocket,132132   expectedToken: string,133133   relayUrl: string,134-  local: Awaited<ReturnType<ReturnType<typeof productionCodePairingDeps>["getLocalIdentity"]>>,134+  local: Awaited<ReturnType<typeof getLocalPairingIdentity>>,135135 ): Promise<void> {136136   const timeout = setTimeout(() => socket.close(), SOURCE_WINDOW_MS);137137   let noise: NoiseNKSession | undefined;

view file

src/background/relay-settings.test.ts+0 −75
@@ -1,75 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-3-import {4-  getRelaySettings,5-  setLanOnly,6-  setRelayUrl,7-} from "./relay-settings";8-import { DEFAULT_RELAY_URL } from "../shared/rendezvous";9-10-let stored: Record<string, unknown>;11-12-beforeEach(() => {13-  stored = {};14-  (globalThis as { chrome?: unknown }).chrome = {15-    storage: {16-      local: {17-        get: vi.fn(async (keys: string | string[]) => {18-          const requested = Array.isArray(keys) ? keys : [keys];19-          return Object.fromEntries(20-            requested21-              .filter((key) => key in stored)22-              .map((key) => [key, stored[key]]),23-          );24-        }),25-        set: vi.fn(async (values: Record<string, unknown>) => {26-          Object.assign(stored, values);27-        }),28-      },29-    },30-  };31-});32-33-describe("relay settings", () => {34-  it("defaults to June's relay with internet fallback enabled", async () => {35-    await expect(getRelaySettings()).resolves.toEqual({36-      relayUrl: DEFAULT_RELAY_URL,37-      lanOnly: false,38-    });39-  });40-41-  it("persists a normalized self-hosted wss URL and LAN-only opt-out", async () => {42-    stored.peach_paired_devices_v3 = [43-      {44-        deviceId: "phone",45-        deviceName: "Phone",46-        deviceType: "android",47-        longTermPublicKeyHex: "0".repeat(64),48-        pairedAt: 1,49-        relayUrl: "wss://old-relay.example",50-      },51-    ];52-    await expect(setRelayUrl("wss://relay.example.test/base/")).resolves.toBe(53-      "wss://relay.example.test/base",54-    );55-    await setLanOnly(true);56-    await expect(getRelaySettings()).resolves.toEqual({57-      relayUrl: "wss://relay.example.test/base",58-      lanOnly: true,59-    });60-    expect(stored.peach_paired_devices_v3).toEqual([61-      expect.objectContaining({62-        deviceId: "phone",63-        relayUrl: "wss://relay.example.test/base",64-      }),65-    ]);66-  });67-68-  it("rejects ws and ignores an invalid stored URL", async () => {69-    await expect(setRelayUrl("ws://relay.example.test")).rejects.toThrow(/wss/);70-    stored.peach_relay_url_v1 = "https://not-a-websocket.example";71-    await expect(getRelaySettings()).resolves.toMatchObject({72-      relayUrl: DEFAULT_RELAY_URL,73-    });74-  });75-});

view file at the parent

src/background/relay-settings.ts+0 −46
@@ -1,46 +0,0 @@1-import { DEFAULT_RELAY_URL, normalizeRelayUrl } from "../shared/rendezvous";2-import { setRelayUrlForAllPairedDevices } from "./paired-devices";3-4-const RELAY_URL_KEY = "peach_relay_url_v1";5-const LAN_ONLY_KEY = "peach_sync_lan_only_v1";6-7-export interface RelaySettings {8-  relayUrl: string;9-  lanOnly: boolean;10-}11-12-export async function getRelaySettings(): Promise<RelaySettings> {13-  try {14-    const raw = await chrome.storage.local.get([RELAY_URL_KEY, LAN_ONLY_KEY]);15-    const values = raw as Record<string, unknown>;16-    const relayUrl =17-      typeof values[RELAY_URL_KEY] === "string"18-        ? safeNormalize(values[RELAY_URL_KEY] as string)19-        : DEFAULT_RELAY_URL;20-    return {21-      relayUrl,22-      lanOnly: values[LAN_ONLY_KEY] === true,23-    };24-  } catch {25-    return { relayUrl: DEFAULT_RELAY_URL, lanOnly: false };26-  }27-}28-29-export async function setRelayUrl(input: string): Promise<string> {30-  const relayUrl = normalizeRelayUrl(input);31-  await setRelayUrlForAllPairedDevices(relayUrl);32-  await chrome.storage.local.set({ [RELAY_URL_KEY]: relayUrl });33-  return relayUrl;34-}35-36-export async function setLanOnly(lanOnly: boolean): Promise<void> {37-  await chrome.storage.local.set({ [LAN_ONLY_KEY]: lanOnly });38-}39-40-function safeNormalize(input: string): string {41-  try {42-    return normalizeRelayUrl(input);43-  } catch {44-    return DEFAULT_RELAY_URL;45-  }46-}

view file at the parent

src/background/sync-runner.test.ts+19 −182
@@ -2,7 +2,6 @@22 import type { PairedDevice, SyncMessage } from "../shared/sync-types";33 import type {44   SyncSession,5-  SyncEndpoint,65 } from "../shared/sync-transport";76 import type { SyncVaultAdapter } from "./sync-coordinator";87 import type { SyncRunnerDeps } from "./sync-runner";@@ -172,6 +171,11 @@172171   };173172 }174173 174+const relaySettings = async () => ({175+  mode: "public" as const,176+  relayUrl: "wss://relay.peachpasswords.com",177+});178+175179 describe("productionSyncRunner", () => {176180   let local: StorageShape;177181   let sync: StorageShape;@@ -195,23 +199,21 @@195199     vault.lock();196200     const { runWithDeps } = await import("./sync-runner");197201     const deps: SyncRunnerDeps = {198-      openSession: vi.fn(),199-      probeSubnet: vi.fn(),200-      getSubnet: vi.fn(async () => "192.168.1"),202+      openRelaySession: vi.fn(),203+      getSyncSettings: relaySettings,201204       createAdapter: emptyAdapter,202205     };203206     const result = await runWithDeps(makeDevice(), deps);204207     expect(result).toEqual({ ok: false, error: "VAULT_LOCKED" });205-    expect(deps.openSession).not.toHaveBeenCalled();208+    expect(deps.openRelaySession).not.toHaveBeenCalled();206209   });207210 208211   it("throws when the device has no longTermPublicKeyHex", async () => {209212     await bootVault();210213     const { runWithDeps } = await import("./sync-runner");211214     const deps: SyncRunnerDeps = {212-      openSession: vi.fn(),213-      probeSubnet: vi.fn(),214-      getSubnet: vi.fn(),215+      openRelaySession: vi.fn(),216+      getSyncSettings: relaySettings,215217       createAdapter: emptyAdapter,216218     };217219     await expect(@@ -223,9 +225,8 @@223225     await bootVault();224226     const { runWithDeps } = await import("./sync-runner");225227     const deps: SyncRunnerDeps = {226-      openSession: vi.fn(),227-      probeSubnet: vi.fn(),228-      getSubnet: vi.fn(),228+      openRelaySession: vi.fn(),229+      getSyncSettings: relaySettings,229230       createAdapter: emptyAdapter,230231     };231232     await expect(@@ -233,170 +234,16 @@233234     ).rejects.toThrow("paired_device_missing_rendezvous_secret");234235   });235236 236-  it("uses the cached last-known IP first and skips the subnet probe", async () => {237+  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {237238     await bootVault();238-    const { setLastKnownIp } = await import("./paired-devices");239-    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.42");240-241239     const { session, peer } = inMemoryPair();242-    const openSession = vi.fn(async (_e: SyncEndpoint) => session);243-    const probeSubnet = vi.fn();240+    const openRelaySession = vi.fn(async () => session);244241     const deps: SyncRunnerDeps = {245-      openSession,246-      probeSubnet,247-      getSubnet: vi.fn(async () => "192.168.1"),248-      createAdapter: emptyAdapter,249-    };250-251-    const { runWithDeps } = await import("./sync-runner");252-    const runPromise = runWithDeps(makeDevice(), deps);253-    await runPeer(peer);254-    const result = await runPromise;255-    expect(result).toEqual({ ok: true, entriesReceived: 0, entriesSent: 0 });256-    expect(openSession).toHaveBeenCalledTimes(1);257-    expect(openSession.mock.calls[0][0]).toEqual({ ip: "192.168.1.42", port: 49142 });258-    expect(probeSubnet).not.toHaveBeenCalled();259-  });260-261-  it("falls back to subnet probe when cached IP fails to connect", async () => {262-    await bootVault();263-    const { setLastKnownIp, getLastKnownIp } = await import("./paired-devices");264-    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");265-266-    const { session, peer } = inMemoryPair();267-    const openSession = vi.fn()268-      .mockRejectedValueOnce(new Error("ECONNREFUSED"))269-      .mockRejectedValueOnce(new Error("ECONNREFUSED"))270-      .mockRejectedValueOnce(new Error("ECONNREFUSED"))271-      .mockResolvedValueOnce(session);272-    const probeSubnet = vi.fn(async (273-      ...args: Parameters<SyncRunnerDeps["probeSubnet"]>274-    ) => ({275-      ip: "192.168.1.99",276-      port: 49142,277-      rendezvousId: args[4][2],278-    }));279-    const deps: SyncRunnerDeps = {280-      openSession,281-      probeSubnet,282-      getSubnet: vi.fn(async () => "192.168.1"),283-      createAdapter: emptyAdapter,284-    };285-286-    const { runWithDeps } = await import("./sync-runner");287-    const runPromise = runWithDeps(makeDevice(), deps);288-    await runPeer(peer);289-    await runPromise;290-    expect(openSession).toHaveBeenCalledTimes(4);291-    expect(openSession.mock.calls[3][0]).toEqual({ ip: "192.168.1.99", port: 49142 });292-    expect(openSession.mock.calls[3][3]).toBe(probeSubnet.mock.calls[0][4][2]);293-    expect(probeSubnet).toHaveBeenCalledTimes(1);294-    expect(probeSubnet.mock.calls[0][4]).toHaveLength(3);295-    expect(await getLastKnownIp(REMOTE_PUBKEY_HEX)).toBe("192.168.1.99");296-  });297-298-  it("throws host_unreachable when cached IP fails AND subnet probe returns null", async () => {299-    await bootVault();300-    const { setLastKnownIp } = await import("./paired-devices");301-    await setLastKnownIp(REMOTE_PUBKEY_HEX, "192.168.1.13");302-303-    const deps: SyncRunnerDeps = {304-      openSession: vi.fn().mockRejectedValue(new Error("ECONNREFUSED")),305-      probeSubnet: vi.fn(async () => null),306-      getSubnet: vi.fn(async () => "192.168.1"),307-      createAdapter: emptyAdapter,308-    };309-    const { runWithDeps } = await import("./sync-runner");310-    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("host_unreachable");311-  });312-313-  it("throws subnet_unknown when no cached IP and the local subnet can't be determined", async () => {314-    await bootVault();315-    const deps: SyncRunnerDeps = {316-      openSession: vi.fn(),317-      probeSubnet: vi.fn(),318-      getSubnet: vi.fn(async () => null),319-      createAdapter: emptyAdapter,320-    };321-    const { runWithDeps } = await import("./sync-runner");322-    await expect(runWithDeps(makeDevice(), deps)).rejects.toThrow("subnet_unknown");323-  });324-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(),334242       openRelaySession,335-      getRelaySettings: vi.fn(async () => ({336-        relayUrl: "wss://relay.example.test",337-        lanOnly: false,338-      })),339-      getSubnet: vi.fn(async () => null),243+      getSyncSettings: relaySettings,340244       createAdapter: emptyAdapter,341245     };342-343246     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-385-  it("surfaces entriesReceived from the peer's SYNC_ENTRIES payload", async () => {386-    await bootVault();387-    const { session, peer } = inMemoryPair();388-    const openSession = vi.fn(async () => session);389-    const deps: SyncRunnerDeps = {390-      openSession,391-      probeSubnet: vi.fn(async () => ({392-        ip: "192.168.1.1",393-        port: 49142,394-        rendezvousId: "A".repeat(43),395-      })),396-      getSubnet: vi.fn(async () => "192.168.1"),397-      createAdapter: emptyAdapter,398-    };399-    const { runWithDeps } = await import("./sync-runner");400247     const runPromise = runWithDeps(makeDevice(), deps);401248     await runPeer(peer, {402249       peerEntriesB64: { "id-1": "AAAA", "id-2": "BBBB", "id-3": "CCCC" },@@ -421,13 +268,8 @@421268       },422269     };423270     const deps: SyncRunnerDeps = {424-      openSession: vi.fn(async () => session),425-      probeSubnet: vi.fn(async () => ({426-        ip: "192.168.1.1",427-        port: 49142,428-        rendezvousId: "A".repeat(43),429-      })),430-      getSubnet: vi.fn(async () => "192.168.1"),271+      openRelaySession: vi.fn(async () => session),272+      getSyncSettings: relaySettings,431273       createAdapter: () => adapter,432274     };433275     const { runWithDeps } = await import("./sync-runner");@@ -442,13 +284,8 @@442284     await bootVault();443285     const { session, peer } = inMemoryPair();444286     const deps: SyncRunnerDeps = {445-      openSession: vi.fn(async () => session),446-      probeSubnet: vi.fn(async () => ({447-        ip: "192.168.1.1",448-        port: 49142,449-        rendezvousId: "A".repeat(43),450-      })),451-      getSubnet: vi.fn(async () => "192.168.1"),287+      openRelaySession: vi.fn(async () => session),288+      getSyncSettings: relaySettings,452289       createAdapter: emptyAdapter,453290     };454291     const { runWithDeps } = await import("./sync-runner");

view file

src/background/sync-runner.ts+18 −121
@@ -5,13 +5,10 @@55 // surfacing a `SyncRunResult` (or throwing on failure). The dispatcher in66 // sync-trigger.ts iterates over hosts and aggregates results.77 //8-// Connection strategy:9-//   1. Try `getLastKnownIp(deviceLongTermPubkey)` if cached — fast path.10-//   2. Fall back to `probeSubnetForPeer` over the local /24. The subnet11-//      probe is itself rate-limited (60s per-pubkey cooldown) inside the12-//      transport, so we don't add another layer here.13-//   3. On a successful session, persist the IP we connected on so the next14-//      sync round skips the probe.8+// Connection strategy: dial the mesh's relay, trying each clock-skew9+// rendezvous window in turn. There is one transport. LAN peer-to-peer sync is10+// retired (docs/sync-lan-retirement.md), so there is no address cache, no11+// subnet probe and no fallback ladder to reason about.1512 //1613 // We do NOT verify vault_id here — the coordinator's SYNC_HELLO exchange1714 // does that (returns `status: "vault_mismatch"` if the peer holds a@@ -21,22 +18,12 @@2118 import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";2219 import { createSyncVaultAdapter } from "./sync-vault-adapter";2320 import {24-  getLastKnownIp,25-  setLastKnownIp,26-} from "./paired-devices";27-import {2821   openRelaySyncSession,29-  openSyncSession,30-  probeSubnetForPeer,31-  type ProbedSyncEndpoint,3222   type SyncSession,33-  type SyncEndpoint,3423   type SyncPeerIdentity,3524 } from "../shared/sync-transport";3625 import { hexToPubkey } from "../shared/sync-identity";37-import { getLocalSubnet } from "../shared/local-network";3826 import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";39-import { PAIRING_PORT } from "../shared/pairing-dial";4027 import { constantTimeEqualHex } from "../shared/constant-time";4128 import { getRootKeyHex, isUnlocked } from "./vault";4229 import type { PairedDevice } from "../shared/sync-types";@@ -45,52 +32,27 @@4532   dialingRendezvousIds,4633   rendezvousSecretFromHex,4734 } from "../shared/rendezvous";48-import {49-  getRelaySettings,50-  type RelaySettings,51-} from "./relay-settings";35+import { getSyncSettings, type SyncSettings } from "./sync-settings";5236 53-/** Same fixed port pairing dials — SyncReconnectListener.SYNC_PORT on Android. */54-const DEFAULT_PORT = PAIRING_PORT;55-5637 /**5738  * Test/injection seam. Production wires in the real `openSyncSession`;5839  * unit tests inject a fake to avoid real WebSocket + Noise KK.5940  */6041 export interface SyncRunnerDeps {61-  openSession: (62-    endpoint: SyncEndpoint,63-    ourIdentity: SyncPeerIdentity,64-    expectedRemoteHex: string,65-    rendezvousId: string,66-  ) => Promise<SyncSession>;67-  probeSubnet: (68-    subnet: string,69-    port: number,70-    ourIdentity: SyncPeerIdentity,71-    expectedRemoteHex: string,72-    rendezvousIds: readonly string[],73-  ) => Promise<ProbedSyncEndpoint | null>;74-  openRelaySession?: (42+  openRelaySession: (7543     relayUrl: string,7644     ourIdentity: SyncPeerIdentity,7745     expectedRemoteHex: string,7846     rendezvousId: string,7947   ) => Promise<SyncSession>;80-  getRelaySettings?: () => Promise<RelaySettings>;81-  getSubnet: () => Promise<string | null>;48+  getSyncSettings: () => Promise<SyncSettings>;8249   createAdapter: (context?: { deviceName?: string }) => SyncVaultAdapter;8350 }8451 8552 const productionDeps: SyncRunnerDeps = {86-  openSession: (endpoint, identity, expected, rendezvousId) =>87-    openSyncSession(endpoint, identity, expected, rendezvousId),88-  probeSubnet: (subnet, port, identity, expected, rendezvousIds) =>89-    probeSubnetForPeer(subnet, port, identity, expected, rendezvousIds),9053   openRelaySession: (relayUrl, identity, expected, rendezvousId) =>9154     openRelaySyncSession(relayUrl, identity, expected, rendezvousId),92-  getRelaySettings,93-  getSubnet: getLocalSubnet,55+  getSyncSettings,9456   createAdapter: (context) => createSyncVaultAdapter(context),9557 };9658 @@ -117,8 +79,6 @@11779     rendezvousSecretFromHex(device.rendezvousSecretHex),11880     Date.now() / 1000,11981   );120-  const port = device.wsPort ?? DEFAULT_PORT;121-12282   // Sync identity derives from the stable root key, not the password-derived12383   // key. Reconnect uses both halves directly in standard Noise KK.12484   const masterKeyHex = getRootKeyHex();@@ -146,31 +106,15 @@146106   const adapter = deps.createAdapter({ deviceName: device.deviceName });147107   let session: SyncSession;148108   try {149-    try {150-      session = await connectLan(151-        expectedRemoteHex,152-        port,153-        ourIdentity,154-        reconnectIds,155-        deps,156-      );157-    } catch (lanError) {158-      const relaySettings = await deps.getRelaySettings?.();159-      if (160-        !relaySettings ||161-        relaySettings.lanOnly ||162-        !deps.openRelaySession163-      ) {164-        throw lanError;165-      }166-      session = await connectRelay(167-        device.relayUrl ?? relaySettings.relayUrl,168-        expectedRemoteHex,169-        ourIdentity,170-        reconnectIds,171-        deps.openRelaySession,172-      );173-    }109+    const settings = await deps.getSyncSettings();110+    if (settings.mode === "off") throw new Error("sync_disabled");111+    session = await connectRelay(112+      device.relayUrl ?? settings.relayUrl,113+      expectedRemoteHex,114+      ourIdentity,115+      reconnectIds,116+      deps.openRelaySession,117+    );174118   } finally {175119     masterKey.fill(0);176120     localKeys.secretKey.fill(0);@@ -199,59 +143,12 @@199143   }200144 }201145 202-async function connectLan(203-  expectedRemoteHex: string,204-  port: number,205-  ourIdentity: SyncPeerIdentity,206-  rendezvousIds: string[],207-  deps: SyncRunnerDeps,208-): Promise<SyncSession> {209-  const cached = await getLastKnownIp(expectedRemoteHex);210-  if (cached) {211-    for (const rendezvousId of rendezvousIds) {212-      try {213-        const session = await deps.openSession(214-          { ip: cached, port },215-          ourIdentity,216-          expectedRemoteHex,217-          rendezvousId,218-        );219-        return session;220-      } catch {221-        // Try the next clock-skew window before treating the IP as stale.222-      }223-    }224-  }225-226-  const subnet = await deps.getSubnet();227-  if (!subnet) {228-    throw new Error(cached ? "host_unreachable" : "subnet_unknown");229-  }230-231-  const found = await deps.probeSubnet(232-    subnet,233-    port,234-    ourIdentity,235-    expectedRemoteHex,236-    rendezvousIds,237-  );238-  if (!found) throw new Error("host_unreachable");239-  const session = await deps.openSession(240-    { ip: found.ip, port: found.port },241-    ourIdentity,242-    expectedRemoteHex,243-    found.rendezvousId,244-  );245-  await setLastKnownIp(expectedRemoteHex, found.ip).catch(() => undefined);246-  return session;247-}248-249146 async function connectRelay(250147   relayUrl: string,251148   expectedRemoteHex: string,252149   ourIdentity: SyncPeerIdentity,253150   rendezvousIds: string[],254-  openRelaySession: NonNullable<SyncRunnerDeps["openRelaySession"]>,151+  openRelaySession: SyncRunnerDeps["openRelaySession"],255152 ): Promise<SyncSession> {256153   let lastError: unknown;257154   for (const rendezvousId of rendezvousIds) {

view file

src/background/sync-settings.ts+105 −0
@@ -0,0 +1,105 @@1+import { DEFAULT_RELAY_URL, normalizeRelayUrl } from "../shared/rendezvous";2+import { setRelayUrlForAllPairedDevices } from "./paired-devices";3+4+/**5+ * Sync is exactly three named choices (docs/sync-lan-retirement.md):6+ *7+ *  - `public`  — Peach's relay. The default, zero setup.8+ *  - `private` — a self-hosted peach-relay the user points their devices at.9+ *  - `off`     — no sync; the vault moves between devices by Codex/CSV export.10+ *11+ * LAN peer-to-peer sync is retired, not deprecated: there is no fourth mode and12+ * no "local network only" opt-out. `public` and `private` differ only in which13+ * relay URL is in force, so the transport below them is identical.14+ */15+export type SyncMode = "public" | "private" | "off";16+17+export interface SyncSettings {18+  mode: SyncMode;19+  /**20+   * The relay in force. Always `DEFAULT_RELAY_URL` under `public`; the user's21+   * URL under `private`. Retained (not blanked) under `off` so turning sync22+   * back on does not lose a configured private relay.23+   */24+  relayUrl: string;25+}26+27+const RELAY_URL_KEY = "peach_relay_url_v1";28+const SYNC_MODE_KEY = "peach_sync_mode_v1";29+/** Retired `lanOnly` flag, read once for migration and never written again. */30+const LEGACY_LAN_ONLY_KEY = "peach_sync_lan_only_v1";31+32+export async function getSyncSettings(): Promise<SyncSettings> {33+  try {34+    const values = (await chrome.storage.local.get([35+      RELAY_URL_KEY,36+      SYNC_MODE_KEY,37+      LEGACY_LAN_ONLY_KEY,38+    ])) as Record<string, unknown>;39+40+    const storedUrl =41+      typeof values[RELAY_URL_KEY] === "string"42+        ? safeNormalize(values[RELAY_URL_KEY] as string)43+        : DEFAULT_RELAY_URL;44+45+    const stored = values[SYNC_MODE_KEY];46+    if (stored === "public" || stored === "private" || stored === "off") {47+      return { mode: stored, relayUrl: relayFor(stored, storedUrl) };48+    }49+50+    // Migration. Someone who set lanOnly chose "no relay involved", and LAN is51+    // now gone, so the honest landing spot is `off` rather than silently52+    // enrolling them in a relay they opted out of. Everyone else lands on53+    // public/private by whether they had configured a custom URL.54+    const mode: SyncMode =55+      values[LEGACY_LAN_ONLY_KEY] === true56+        ? "off"57+        : storedUrl === DEFAULT_RELAY_URL58+          ? "public"59+          : "private";60+    return { mode, relayUrl: relayFor(mode, storedUrl) };61+  } catch {62+    return { mode: "public", relayUrl: DEFAULT_RELAY_URL };63+  }64+}65+66+/** True when sync may run at all. */67+export async function isSyncEnabled(): Promise<boolean> {68+  return (await getSyncSettings()).mode !== "off";69+}70+71+export async function setSyncMode(mode: SyncMode): Promise<SyncSettings> {72+  const current = await getSyncSettings();73+  const relayUrl = relayFor(mode, current.relayUrl);74+  // 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 };79+}80+81+/**82+ * Point this mesh at a self-hosted relay. Implies `private` — choosing a custom83+ * relay and not switching to it would be a trap.84+ */85+export async function setPrivateRelayUrl(input: string): Promise<SyncSettings> {86+  const relayUrl = normalizeRelayUrl(input);87+  await setRelayUrlForAllPairedDevices(relayUrl);88+  await chrome.storage.local.set({89+    [RELAY_URL_KEY]: relayUrl,90+    [SYNC_MODE_KEY]: "private" satisfies SyncMode,91+  });92+  return { mode: "private", relayUrl };93+}94+95+function relayFor(mode: SyncMode, configured: string): string {96+  return mode === "public" ? DEFAULT_RELAY_URL : configured;97+}98+99+function safeNormalize(input: string): string {100+  try {101+    return normalizeRelayUrl(input);102+  } catch {103+    return DEFAULT_RELAY_URL;104+  }105+}

view file

src/background/sync-static.test.ts+4 −2
@@ -76,8 +76,10 @@7676     const appSrc = src("../popup/App.tsx");7777     expect(appSrc).not.toContain("openPairingServer");7878     expect(appSrc).not.toContain("chirp-pair-capacitor");79-    // Adoption dials out like everything else now.80-    expect(appSrc).toContain("dialPairingChannel");79+    // Adoption dials out like everything else now — over the relay only, since80+    // LAN transport is retired (docs/sync-lan-retirement.md).81+    expect(appSrc).toContain("dialRelayPairingChannel");82+    expect(appSrc).not.toContain("openPairingSocket");8183   });8284 8385   it("vault_id uses the full 64-char BLAKE3 hex digest", () => {

view file

src/popup/App.tsx+11 −41
@@ -199,7 +199,6 @@199199   const [settingsInitialSection, setSettingsInitialSection] = createSignal<string | null>(null);200200   // When the sync card's pairing button opens the pairing view, deep-link201201   // straight into that pairing path instead of the rest screen.202-  const [syncPairIntent, setSyncPairIntent] = createSignal<"code" | null>(null);203202   const [settingsActiveCard, setSettingsActiveCard] = createSignal<string>("protection");204203   const [upgradeHighlight, setUpgradeHighlight] = createSignal<ProFeature | null>(null);205204   const [sortOrder, setSortOrder] = createSignal<SortOrder>("url-first");@@ -1222,40 +1221,21 @@12221221    */12231222   async function startVaultAdoption(args: {12241223     password: string;1225-    connection:1226-      | { mode: "relay"; invitation: string }1227-      | { mode: "lan"; code: string };1224+    connection: { invitation: string };12281225     confirmSAS(sas: string): Promise<boolean>;12291226     createVault(rootKeyHex: string): Promise<{ vaultId: string }>;12301227   }) {1231-    const [{1232-      dialPairingChannel,1233-      dialRelayPairingChannel,1234-      openPairingSocket,1235-      openRelayPairingSocket,1236-    }, { requestAdoption }, { setLastKnownIp }] =1228+    const [{ dialRelayPairingChannel, openRelayPairingSocket }, { requestAdoption }] =12371229       await Promise.all([12381230         import("../shared/pairing-dial"),12391231         import("../shared/vault-adopt"),1240-        import("../background/paired-devices"),12411232       ]);12421233 1243-    const dial = args.connection.mode === "relay"1244-      ? await dialRelayPairingChannel(args.connection.invitation, {1245-        openSocket: openRelayPairingSocket,1246-      })1247-      : await dialPairingChannel(args.connection.code, {1248-        openSocket: openPairingSocket,1249-      }, { flow: "adoption" });1234+    const dial = await dialRelayPairingChannel(args.connection.invitation, {1235+      openSocket: openRelayPairingSocket,1236+    });12501237     if (!dial.ok) throw new Error(dial.error);1251-    const {1252-      channel,1253-      sas,1254-      socket,1255-      host,1256-      relayUrl,1257-      rendezvousSecretHex,1258-    } = dial.dialled;1238+    const { channel, sas, socket, relayUrl, rendezvousSecretHex } = dial.dialled;12591239 12601240     try {12611241       const source = await requestAdoption(channel, {@@ -1276,16 +1256,6 @@12761256           const res = await sendMessage({ type: "COMPLETE_PAIRING", payload: { paired } });12771257           const r = res as { ok: boolean; error?: string };12781258           if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");1279-          // Seed the reconnect cache with the address we just dialled, exactly as1280-          // device pairing does (code-pairing.ts). Without it an adopted browser1281-          // never syncs on Chrome: the runner lives in the MV3 service worker,1282-          // its probe fallback needs RTCPeerConnection to find the /24, and that1283-          // API does not exist in a service worker — so getSubnet() always1284-          // returns null and connect() throws subnet_unknown forever. The cache1285-          // is the only route that ever reaches the peer.1286-          if (!relayUrl && paired.longTermPublicKeyHex) {1287-            await setLastKnownIp(paired.longTermPublicKeyHex, host).catch(() => undefined);1288-          }12891259         },12901260         applyLicense: async (license) => {12911261           const res = (await sendMessage({@@ -1302,9 +1272,10 @@13021272       });13031273       if (!source) throw new Error("adoption_rejected");13041274       if (relayUrl) {1275+        // Adopt the source's relay so this device dials the same rendezvous.13051276         const setting = (await sendMessage({1306-          type: "SET_RELAY_SETTINGS",1307-          payload: { relayUrl, lanOnly: false },1277+          type: "SET_SYNC_SETTINGS",1278+          payload: { relayUrl },13081279         })) as { ok: boolean; error?: string };13091280         if (!setting.ok) throw new Error(setting.error ?? "relay_settings_failed");13101281       }@@ -1715,7 +1686,7 @@17151686                   onExport={() => setView("export")}17161687                   onBreachCheckDone={loadEntries}17171688                   onOpenHealth={openHealth}1718-                  onOpenSyncPairing={(method) => { setSettingsInitialSection(settingsActiveCard()); setSyncPairIntent(method ?? null); setView("pair-device"); }}1689+                  onOpenSyncPairing={() => { setSettingsInitialSection(settingsActiveCard()); setView("pair-device"); }}17191690                   onOpenFileImport={handleOpenFileImport}17201691                   onOpenPanic={() => setView("panic")}17211692                   onOpenCodexGenerate={() => { setSettingsInitialSection(settingsActiveCard()); setView("codex-generate"); }}@@ -1756,9 +1727,8 @@17561727               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>17571728                 <PairDeviceView17581729                   isPro={proStatus()}1759-                  initialPair={syncPairIntent()}17601730                   onProChanged={() => { void refreshProStatus(); }}1761-                  onBack={() => { setSyncPairIntent(null); setView("settings"); }}1731+                  onBack={() => setView("settings")}17621732                   onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}17631733                 />17641734               </Suspense>

view file

src/popup/styles/popup.css+46 −0
@@ -10488,3 +10488,49 @@1048810488   line-height: 1.5;1048910489   color: var(--peach-t2);1049010490 }10491+10492+/* Sync mode selector — three named choices (public / private / off). Replaces10493+   the old binary "local network only" toggle; see docs/sync-lan-retirement.md. */10494+.peach-sync-mode {10495+  display: flex;10496+  align-items: flex-start;10497+  gap: 10px;10498+  width: 100%;10499+  text-align: left;10500+  padding: 10px 12px;10501+  margin-top: 8px;10502+  border-radius: 10px;10503+  border: 1px solid var(--peach-border);10504+  background: color-mix(in srgb, var(--peach-t0) 3%, transparent);10505+  color: var(--peach-t1);10506+  cursor: pointer;10507+  transition: border-color var(--peach-dur-80) var(--peach-curve-arrival),10508+              background-color var(--peach-dur-80) var(--peach-curve-arrival);10509+}10510+.peach-sync-mode:hover:not(:disabled) {10511+  border-color: color-mix(in srgb, var(--peach-accent) 40%, transparent);10512+}10513+.peach-sync-mode.is-active {10514+  border-color: var(--peach-accent);10515+  background: color-mix(in srgb, var(--peach-accent) 8%, transparent);10516+}10517+.peach-sync-mode:disabled { opacity: 0.6; cursor: default; }10518+.peach-sync-mode-main { display: flex; flex-direction: column; gap: 3px; flex: 1; }10519+.peach-sync-mode-title { font-size: 12px; }10520+.peach-sync-mode-sub {10521+  font-size: 11px;10522+  line-height: 1.5;10523+  color: var(--peach-t2);10524+}10525+.peach-sync-mode-pip {10526+  flex: none;10527+  margin-top: 3px;10528+  width: 12px; height: 12px;10529+  border-radius: 50%;10530+  border: 1px solid var(--peach-t3);10531+}10532+.peach-sync-mode.is-active .peach-sync-mode-pip {10533+  border-color: var(--peach-accent);10534+  background:10535+    radial-gradient(circle at 50% 50%, var(--peach-accent) 0 42%, transparent 44%);10536+}

view file

src/popup/views/AdoptVaultView.test.tsx+1 −1
@@ -137,7 +137,7 @@137137 138138     expect(startAdoption).toHaveBeenCalledTimes(1);139139     expect(startAdoption.mock.calls[0][0]).toMatchObject({140-      connection: { mode: "relay", invitation: INVITATION },140+      connection: { invitation: INVITATION },141141     });142142   });143143 

view file

src/popup/views/AdoptVaultView.tsx+41 −114
@@ -5,11 +5,9 @@55   createMemo,66   createSignal,77   onCleanup,8-  onMount,98 } from "solid-js";109 import { sendMessage } from "../App";1110 import { RuneBackground } from "../components/RuneBackground";12-import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";1311 import { parsePairingInvitation } from "../../shared/pairing-invitation";1412 import type { PairingQrScannerHandle } from "../pairing-qr-scanner";1513 @@ -20,9 +18,7 @@2018   onDone(): void | Promise<void>;2119   startAdoption(args: {2220     password: string;23-    connection:24-      | { mode: "relay"; invitation: string }25-      | { mode: "lan"; code: string };21+    connection: { invitation: string };2622     confirmSAS(sas: string): Promise<boolean>;2723     createVault(rootKeyHex: string): Promise<{ vaultId: string }>;2824   }): Promise<void>;@@ -38,7 +34,7 @@3834     case "invalid_code":3935       return "That code wasn't accepted — it may have expired. Get a fresh one from your other device.";4036     case "no_host_found":41-      return "Couldn't reach your other device. Check both are on the same Wi-Fi and its pairing screen is still open.";37+      return "Couldn't reach the relay. Check your connection and that the other device's pairing screen is still open.";4238     case "handshake_failed":4339       return "The secure connection failed. Try again with a fresh code.";4440     case "adoption_rejected":@@ -73,9 +69,7 @@7369   const [sas, setSas] = createSignal("");7470   const [sasResolve, setSasResolve] = createSignal<((ok: boolean) => void) | null>(null);7571   const [error, setError] = createSignal("");76-  const [code, setCode] = createSignal("");7772   const [invitation, setInvitation] = createSignal("");78-  const [lanOnly, setLanOnly] = createSignal(false);7973   const [manualEntry, setManualEntry] = createSignal(false);8074   const [scanning, setScanning] = createSignal(false);8175   const [scanError, setScanError] = createSignal("");@@ -83,26 +77,11 @@8377   let videoEl: HTMLVideoElement | undefined;8478   let qrScanner: PairingQrScannerHandle | null = null;8579 86-  const codeReady = createMemo(() => parsePairingCode(code()) !== null);8780   const invitationReady = createMemo(() =>8881     parsePairingInvitation(invitation()) !== null8982   );9083 9184   onCleanup(() => qrScanner?.stop());92-  onMount(() => {93-    void sendMessage({ type: "GET_RELAY_SETTINGS" })94-      .then((response) => {95-        const result = response as {96-          ok: boolean;97-          data?: { lanOnly?: boolean };98-        };99-        if (result.ok) setLanOnly(result.data?.lanOnly === true);100-      })101-      .catch(() => {102-        // Fresh installs default to relay mode if the background is still103-        // starting; the save path will reconcile before pairing completes.104-      });105-  });10685 10786   createMemo(() => {10887     const value = password();@@ -136,12 +115,8 @@136115   });137116 138117   async function beginPairing() {139-    const connection = lanOnly()140-      ? (codeReady() ? { mode: "lan" as const, code: code() } : null)141-      : (invitationReady()142-        ? { mode: "relay" as const, invitation: invitation() }143-        : null);144-    if (!connection || connecting()) return;118+    if (!invitationReady() || connecting()) return;119+    const connection = { invitation: invitation() };145120     stopScanning();146121     setPhase("pair");147122     setError("");@@ -185,7 +160,6 @@185160       setStrengthScore(null);186161       // The code is single-use on the other device's side too — a rejected SAS187162       // means starting over with a fresh one, not retrying this one.188-      setCode("");189163       setInvitation("");190164     }191165   }@@ -225,17 +199,6 @@225199     setScanning(false);226200   }227201 228-  async function chooseLanOnly(next: boolean) {229-    stopScanning();230-    setLanOnly(next);231-    setManualEntry(false);232-    const response = (await sendMessage({233-      type: "SET_RELAY_SETTINGS",234-      payload: { lanOnly: next },235-    })) as { ok: boolean; error?: string };236-    if (!response.ok) setScanError(response.error ?? "Could not update sync mode.");237-  }238-239202   return (240203     <section class="peach-screen peach-theme peach-adopt-vault">241204       <RuneBackground opacity={0.07} />@@ -279,80 +242,44 @@279242               <span />280243             </div>281244             <h1>Pair with your other device.</h1>282-            <Show283-              when={!lanOnly()}284-              fallback={285-                <>286-                  <p>287-                    Local network only. Both devices must be on the same Wi-Fi;288-                    this will not work over VPN or from different locations.289-                  </p>290-                  <label class="peach-label" for="adopt-pair-code">local pairing code</label>291-                  <input292-                    id="adopt-pair-code"293-                    class="peach-underline-input peach-adopt-input peach-code-input"294-                    type="text"295-                    autocapitalize="characters"296-                    autocomplete="one-time-code"297-                    spellcheck={false}298-                    maxlength={32}299-                    placeholder="A74H-48J3-KI32-OEP4"300-                    aria-label="Pairing code"301-                    disabled={connecting()}302-                    value={code()}303-                    onInput={(event) =>304-                      setCode(formatPairingCode((event.target as HTMLInputElement).value).slice(0, 32))305-                    }306-                    onKeyDown={(event) => { if (event.key === "Enter") void beginPairing(); }}307-                  />308-                  <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(false)}>309-                    Use secure relay instead310-                  </button>311-                </>312-              }313-            >314-              <p>Connecting via secure relay — works anywhere.</p>315-              <Show when={scanning()}>316-                <video317-                  ref={videoEl}318-                  class="peach-adopt-qr-video"319-                  autoplay320-                  muted321-                  playsinline322-                  aria-label="Pairing QR camera preview"323-                />324-              </Show>325-              <Show when={!scanning()}>326-                <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>327-                  Scan pairing QR328-                </button>329-              </Show>330-              <button331-                class="peach-btn peach-btn--ghost peach-btn--sm"332-                onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}333-              >334-                {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}245+            <p>Connecting via secure relay — works anywhere.</p>246+            <Show when={scanning()}>247+              <video248+                ref={videoEl}249+                class="peach-adopt-qr-video"250+                autoplay251+                muted252+                playsinline253+                aria-label="Pairing QR camera preview"254+              />255+            </Show>256+            <Show when={!scanning()}>257+              <button class="peach-btn peach-btn--accent peach-btn--md" onClick={() => void startScanning()}>258+                Scan pairing QR335259               </button>336-              <Show when={manualEntry()}>337-                <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>338-                <textarea339-                  id="adopt-relay-invitation"340-                  class="peach-underline-input peach-adopt-input"341-                  rows={3}342-                  autocomplete="off"343-                  spellcheck={false}344-                  aria-label="Pairing invitation"345-                  disabled={connecting()}346-                  value={invitation()}347-                  onInput={(event) =>348-                    setInvitation((event.target as HTMLTextAreaElement).value.trim())349-                  }350-                />351-              </Show>352-              <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void chooseLanOnly(true)}>353-                Switch to local network only354-              </button>355260             </Show>261+            <button262+              class="peach-btn peach-btn--ghost peach-btn--sm"263+              onClick={() => { stopScanning(); setManualEntry(!manualEntry()); }}264+            >265+              {manualEntry() ? "Hide manual entry" : "Enter invitation manually"}266+            </button>267+            <Show when={manualEntry()}>268+              <label class="peach-label" for="adopt-relay-invitation">pairing invitation</label>269+              <textarea270+                id="adopt-relay-invitation"271+                class="peach-underline-input peach-adopt-input"272+                rows={3}273+                autocomplete="off"274+                spellcheck={false}275+                aria-label="Pairing invitation"276+                disabled={connecting()}277+                value={invitation()}278+                onInput={(event) =>279+                  setInvitation((event.target as HTMLTextAreaElement).value.trim())280+                }281+              />282+            </Show>356283             <Show when={scanError()}>357284               <p class="peach-hint peach-hint-error">{scanError()}</p>358285             </Show>@@ -362,7 +289,7 @@362289             <div class="peach-adopt-actions">363290               <button364291                 class="peach-btn peach-btn--accent peach-btn--md"365-                disabled={(lanOnly() ? !codeReady() : !invitationReady()) || connecting()}292+                disabled={!invitationReady() || connecting()}366293                 onClick={() => void beginPairing()}367294               >368295                 Connect securely

view file

src/popup/views/PairDevice.test.tsx+5 −255
@@ -1,16 +1,13 @@11 // @vitest-environment jsdom22 //3-// Code pairing, exercised through the integrated PairDeviceView state machine.4-// Code pairing is a MODE inside PairDevice (birds always animate), not a separate5-// component — so these specs walk enter → connecting → SAS → paired/error,6-// asserting the popup↔background message contract at each step.3+// PairDeviceView is relay-only: the extension always DISPLAYS an invitation for4+// another device to scan. LAN pairing codes went with LAN sync5+// (docs/sync-lan-retirement.md), so there is no code-entry mode to test.76 //8-// The input accepts the address-bearing pairing code a phone displays.9-//107 // chrome messaging is mocked the way the popup uses it:118 //   - shared/bridge `sendMessage` is mocked (popup → bg)129 //   - chrome.runtime.onMessage.{add,remove}Listener is faked so the test can13-//     fire CODE_PAIR_SAS / CODE_PAIR_RESULT pushes (bg → popup)10+//     fire ADOPTION_SOURCE_* pushes (bg → popup)1411 1512 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";1613 @@ -18,14 +15,10 @@1815 const { mockSendMessage } = vi.hoisted(() => ({ mockSendMessage: vi.fn() }));1916 vi.mock("../../shared/bridge", () => ({ sendMessage: mockSendMessage }));2017 21-// Mutated per-test to script the PAIR_WITH_CODE start response.22-let startResponse: { ok: boolean; error?: string } = { ok: true };23-let lanOnly = true;2418 const SOURCE_INVITATION =2519   "peach-pair-v1:eyJyZWxheSI6IndzczovL3JlbGF5LnBlYWNocGFzc3dvcmRzLmNvbSIsInRva2VuIjoiQUFFQ0F3UUZCZ2NJQ1FvTERBME9EdyJ9";2620 2721 import { PairDeviceView } from "./PairDevice";28-import type { PairedDevice } from "../../shared/sync-types";2922 import { renderView, inputText } from "./view-test-utils";3023 3124 // --- fake chrome.runtime push channel (bg → popup) ------------------------@@ -38,23 +31,8 @@3831 3932 beforeEach(() => {4033   listeners = [];41-  startResponse = { ok: true };42-  lanOnly = true;4334   mockSendMessage.mockReset();4435   mockSendMessage.mockImplementation((msg: { type?: string }) => {45-    if (msg?.type === "GET_RELAY_SETTINGS") {46-      // Resolve synchronously so the existing code-pairing state-machine tests47-      // enter the explicitly configured LAN-only path before their first DOM48-      // assertion.49-      return {50-        then(resolve: (value: unknown) => unknown) {51-          return Promise.resolve(resolve({52-            ok: true,53-            data: { lanOnly },54-          }));55-        },56-      };57-    }5836     if (msg?.type === "START_ADOPTION_SOURCE") {5937       return Promise.resolve({6038         ok: true,@@ -64,7 +42,6 @@6442     if (msg?.type === "ADOPTION_SOURCE_CONFIRM") {6543       return Promise.resolve({ ok: true, data: { valid: true } });6644     }67-    if (msg?.type === "PAIR_WITH_CODE") return Promise.resolve(startResponse);6845     return Promise.resolve({ ok: true });6946   });7047   (globalThis as unknown as { chrome: unknown }).chrome = {@@ -84,11 +61,9 @@8461   });8562 8663   function mount() {87-    lanOnly = false;8864     const rendered = renderView(8965       <PairDeviceView9066         isPro={true}91-        initialPair="code"9267         onBack={() => {}}9368         onLearnPro={() => {}}9469       />,@@ -111,7 +86,7 @@11186     // Copy button duplicated the pairing-code path and were removed.11287     expect(container.querySelector("#pairing-invitation-manual")).toBeNull();11388     expect(container.textContent).not.toContain("or copy the invitation");114-    expect(container.textContent).toContain("single-use random token");89+    expect(container.textContent).toContain("single-use token");11590   });11691 11792   it("requires the matching SAS and master password before sharing", async () => {@@ -144,230 +119,5 @@144119     await vi.waitFor(() => {145120       expect(container.textContent).toContain("Waiting for the new device");146121     });147-  });148-});149-150-describe("PairDevice — code pairing", () => {151-  const VALID_CODE = "A74H-48J3-KI32-OEP4";152-  const cleanups: Array<() => void> = [];153-  afterEach(() => {154-    while (cleanups.length) cleanups.pop()!();155-  });156-157-  // PairDevice is pairing-only; it opens straight into the code-entry flow158-  // (the device list / method choice live on the Settings sync card).159-  function mount(onBack: () => void = () => {}) {160-    const r = renderView(161-      <PairDeviceView isPro={true} initialPair="code" onBack={onBack} onLearnPro={() => {}} />,162-    );163-    cleanups.push(r.dispose);164-    return r;165-  }166-167-  function buttonByText(container: HTMLElement, text: string): HTMLButtonElement | undefined {168-    return Array.from(container.querySelectorAll("button")).find(169-      (b) => (b.textContent ?? "").trim() === text,170-    ) as HTMLButtonElement | undefined;171-  }172-  function codeInput(container: HTMLElement): HTMLInputElement {173-    return container.querySelector("#pair-code") as HTMLInputElement;174-  }175-176-  // initialPair="code" lands directly on the code-entry screen.177-  function gotoCodeEntry(_container: HTMLElement): void {178-    /* no-op: mount() already opens the code-entry flow */179-  }180-181-  it("opens straight into the code-entry flow (no method-select screen)", () => {182-    const { container } = mount();183-    expect(container.textContent).not.toContain("How do you want to pair?");184-    // The code input is present immediately, without any intermediate choice.185-    expect(codeInput(container)).toBeTruthy();186-    expect(buttonByText(container, "Connect")).toBeTruthy();187-  });188-189-  it("renders a code input sized for a full pairing code, with birds present", () => {190-    const { container } = mount();191-    gotoCodeEntry(container);192-    const input = codeInput(container);193-    expect(input).toBeTruthy();194-    // Letters are part of the code now, so the field must not be numeric-only.195-    expect(input.getAttribute("inputmode")).toBeNull();196-    expect(input.getAttribute("maxlength")).toBe("32");197-    // birds always animate on the code screens (design correction)198-    expect(container.querySelector(".peach-pair-bird-desktop")).toBeTruthy();199-  });200-201-  it("uppercases and regroups a pairing code as it is typed", () => {202-    const { container } = mount();203-    gotoCodeEntry(container);204-    const input = codeInput(container);205-    inputText(input, "a74h48j3ki32oep4");206-    expect(input.value).toBe("A74H-48J3-KI32-OEP4");207-    expect(buttonByText(container, "Connect")!.disabled).toBe(false);208-  });209-210-  it("groups partial input and strips unusable characters", () => {211-    const { container } = mount();212-    gotoCodeEntry(container);213-    const input = codeInput(container);214-    inputText(input, "12_34/56");215-    expect(input.value).toBe("1234-56");216-    expect(buttonByText(container, "Connect")!.disabled).toBe(true);217-  });218-219-  it("keeps Connect disabled for input that is neither code form", () => {220-    const { container } = mount();221-    gotoCodeEntry(container);222-    // Long enough to be grouped, but too short to carry a token.223-    inputText(codeInput(container), "A74H48J3");224-    expect(buttonByText(container, "Connect")!.disabled).toBe(true);225-  });226-227-  it("Connect sends PAIR_WITH_CODE with the typed code, then searches", async () => {228-    const { container } = mount();229-    gotoCodeEntry(container);230-    inputText(codeInput(container), VALID_CODE);231-    const connect = buttonByText(container, "Connect")!;232-    expect(connect.disabled).toBe(false);233-    connect.click();234-    await Promise.resolve();235-    expect(mockSendMessage).toHaveBeenCalledWith({236-      type: "PAIR_WITH_CODE",237-      payload: { code: VALID_CODE },238-    });239-    expect(container.textContent).toContain("Connecting…");240-  });241-242-  it("Connect stays disabled until the full pairing code is entered", () => {243-    const { container } = mount();244-    gotoCodeEntry(container);245-    inputText(codeInput(container), "A74H48J3");246-    expect(buttonByText(container, "Connect")!.disabled).toBe(true);247-    inputText(codeInput(container), VALID_CODE);248-    expect(buttonByText(container, "Connect")!.disabled).toBe(false);249-  });250-251-  it("shows the SAS on a CODE_PAIR_SAS push, with Confirm/Cancel", async () => {252-    const { container } = mount();253-    gotoCodeEntry(container);254-    inputText(codeInput(container), VALID_CODE);255-    buttonByText(container, "Connect")!.click();256-    await Promise.resolve();257-258-    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });259-260-    expect(container.textContent).toContain("Confirm this code matches on your phone:");261-    expect(container.textContent).toContain("89524517");262-    expect(buttonByText(container, "Confirm")).toBeTruthy();263-    expect(buttonByText(container, "Cancel")).toBeTruthy();264-  });265-266-  it("Confirm sends CODE_PAIR_CONFIRM { confirmed: true }", async () => {267-    const { container } = mount();268-    gotoCodeEntry(container);269-    inputText(codeInput(container), VALID_CODE);270-    buttonByText(container, "Connect")!.click();271-    await Promise.resolve();272-    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });273-274-    buttonByText(container, "Confirm")!.click();275-    await Promise.resolve();276-    expect(mockSendMessage).toHaveBeenCalledWith({277-      type: "CODE_PAIR_CONFIRM",278-      payload: { confirmed: true },279-    });280-  });281-282-  it("Cancel on the SAS gate sends { confirmed: false } and shows mismatch copy", async () => {283-    const { container } = mount();284-    gotoCodeEntry(container);285-    inputText(codeInput(container), VALID_CODE);286-    buttonByText(container, "Connect")!.click();287-    await Promise.resolve();288-    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });289-290-    buttonByText(container, "Cancel")!.click();291-    await Promise.resolve();292-    expect(mockSendMessage).toHaveBeenCalledWith({293-      type: "CODE_PAIR_CONFIRM",294-      payload: { confirmed: false },295-    });296-    expect(container.textContent).toContain("Codes don't match");297-  });298-299-  it("shows the combined 'not found or expired' copy on invalid_code", async () => {300-    const { container } = mount();301-    gotoCodeEntry(container);302-    inputText(codeInput(container), VALID_CODE);303-    buttonByText(container, "Connect")!.click();304-    await Promise.resolve();305-306-    // Wrong vs expired is indistinguishable on the wire; the background307-    // collapses both to invalid_code via a socket close.308-    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "invalid_code" });309-310-    expect(container.textContent).toContain("Code not found or expired");311-    expect(buttonByText(container, "Try again")).toBeTruthy();312-  });313-314-  it("shows the 'couldn't reach your phone' copy on no_host_found", async () => {315-    const { container } = mount();316-    gotoCodeEntry(container);317-    inputText(codeInput(container), VALID_CODE);318-    buttonByText(container, "Connect")!.click();319-    await Promise.resolve();320-    pushFromBackground("CODE_PAIR_RESULT", { status: "error", error: "no_host_found" });321-    expect(container.textContent).toContain("Couldn't reach your phone");322-  });323-324-  it("renders Paired! and the device name on a complete result", async () => {325-    const device: PairedDevice = { deviceId: "d1", deviceName: "June's Pixel", deviceType: "android", pairedAt: 1700000000000 };326-    const { container } = mount();327-    gotoCodeEntry(container);328-    inputText(codeInput(container), VALID_CODE);329-    buttonByText(container, "Connect")!.click();330-    await Promise.resolve();331-    pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });332-    buttonByText(container, "Confirm")!.click();333-    await Promise.resolve();334-335-    pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });336-    expect(container.textContent).toContain("Paired!");337-    expect(container.textContent).toContain("Connected to June's Pixel");338-  });339-340-  it("auto-dismisses back to settings after the paired state settles", async () => {341-    vi.useFakeTimers();342-    try {343-      const device: PairedDevice = { deviceId: "d1", deviceName: "Pixel", deviceType: "android", pairedAt: 0 };344-      const onBack = vi.fn();345-      const { container } = mount(onBack);346-      gotoCodeEntry(container);347-      inputText(codeInput(container), VALID_CODE);348-      buttonByText(container, "Connect")!.click();349-      await Promise.resolve();350-      pushFromBackground("CODE_PAIR_SAS", { sas: "89524517" });351-      buttonByText(container, "Confirm")!.click();352-      await Promise.resolve();353-      pushFromBackground("CODE_PAIR_RESULT", { status: "complete", device });354-      expect(container.textContent).toContain("Paired!");355-      vi.advanceTimersByTime(2000);356-      // returns to the Settings sync hub, which owns the device list now357-      expect(onBack).toHaveBeenCalled();358-    } finally {359-      vi.useRealTimers();360-    }361-  });362-363-  it("surfaces a synchronous start failure (vault_locked) as an error", async () => {364-    startResponse = { ok: false, error: "vault_locked" };365-    const { container } = mount();366-    gotoCodeEntry(container);367-    inputText(codeInput(container), VALID_CODE);368-    buttonByText(container, "Connect")!.click();369-    await Promise.resolve();370-    await Promise.resolve();371-    expect(container.textContent).toContain("Unlock Peach first");372122   });373123 });

view file

src/popup/views/PairDevice.tsx+44 −311
@@ -2,28 +2,26 @@22 //33 // Settings owns the sync "hub" (paired-device list, revoke, sync-now); this44 // view is purely the pairing flow, returning to Settings (`onBack`) when done5-// or cancelled: the user types the pairing code their phone displays, the6-// browser dials out to the address inside it, and the NK handshake + SAS run7-// from there (enter code → connecting → SAS → paired).5+// or cancelled.86 //9-// There is one path because there can only be one: the browser cannot listen,10-// so it is always the side that dials.7+// There is one path because there can only be one: a browser extension cannot8+// listen and has no camera it can point at anything, so it is always the side9+// that *displays* an invitation for another device to scan. LAN pairing codes10+// are retired along with LAN sync (docs/sync-lan-retirement.md), so there is no11+// code to type here either.1112 //12-// The pairing itself runs in the background (code-pairing.ts) and reaches13-// this view via the PAIR_WITH_CODE / CODE_PAIR_* messages.13+// The pairing itself runs in the background (relay-adoption-source-bg.ts) and14+// reaches this view via the ADOPTION_SOURCE_* messages.1415 1516 import {1617   Component,1718   Show,18-  createEffect,1919   createSignal,2020   onCleanup,2121   onMount,2222 } from "solid-js";2323 import { sendMessage } from "../../shared/bridge";24-import type { PairedDevice } from "../../shared/sync-types";2524 import { DesktopBird, MobileBird } from "../components/PairingBirds";26-import { formatPairingCode, parsePairingCode } from "../../shared/pairing-code";2725 import { AdoptionConsentModal } from "../components/AdoptionConsentModal";2826 import { PairingQrCode } from "../components/PairingQrCode";2927 @@ -31,93 +29,20 @@3129   isPro: boolean;3230   onBack(): void;3331   onLearnPro(): void;34-  /**35-   * Set when the user taps the pairing option on the Settings sync card. With36-   * no method there is nothing to do here, so the view returns to Settings.37-   * Read once on mount.38-   */39-  initialPair?: "code" | null;4032   /** Called after a peer's Pro license is activated, so the app re-reads Pro status. */4133   onProChanged?: () => void;4234 }4335 44-// Code-pairing phases. Mirrors the background CodePairingError union.45-type PairPhase = "enter" | "connecting" | "sas" | "paired" | "error";4636 type SharePhase = "opening" | "invitation" | "sas" | "connecting" | "paired" | "error";47-type CodePairingError =48-  | "invalid_code"49-  | "no_host_found"50-  | "handshake_failed"51-  | "sas_mismatch"52-  | "vault_locked";5337 54-const PAIRED_DISMISS_MS = 2_000;55-56-function errorCopy(error: CodePairingError | "unknown"): { headline: string; sub: string } {57-  switch (error) {58-    case "invalid_code":59-      // Ground truth §0.3: wrong vs expired is indistinguishable on the wire —60-      // the listener closes the socket either way, with no error frame.61-      return {62-        headline: "Code not found or expired",63-        sub: "Check the code on your phone and try again. Codes expire, so grab a fresh one if it's been a while.",64-      };65-    case "no_host_found":66-      return {67-        headline: "Couldn't reach your phone",68-        sub: "Make sure both devices are on the same Wi-Fi and the pairing screen is still open on your phone.",69-      };70-    case "sas_mismatch":71-      return {72-        headline: "Codes don't match",73-        sub: "For your safety, pairing was stopped. Start over and compare the two codes carefully.",74-      };75-    case "vault_locked":76-      return {77-        headline: "Unlock Peach first",78-        sub: "Your vault needs to be unlocked before you can pair a device.",79-      };80-    case "handshake_failed":81-    default:82-      return {83-        headline: "Pairing failed",84-        sub: "Something went wrong while connecting. Please try again.",85-      };86-  }87-}88-8938 export const PairDeviceView: Component<PairDeviceViewProps> = (props) => {90-  // The whole NK pairing runs in the background; this view drives the UI and91-  // the out-of-band SAS confirmation round-trip via the PAIR_WITH_CODE /92-  // CODE_PAIR_* messages. The SAS gate is what authenticates the peer93-  // (audit HIGH — pairing initiator authentication), not the pairing token.94-  const [phase, setPhase] = createSignal<PairPhase>("enter");95-  const [code, setCode] = createSignal("");96-  const [sas, setSas] = createSignal<string | null>(null);97-  const [errorKind, setErrorKind] = createSignal<CodePairingError | "unknown">("unknown");98-  const [pairedDevice, setPairedDevice] = createSignal<PairedDevice | undefined>(undefined);99-  const [busy, setBusy] = createSignal(false);100-  const [mode, setMode] = createSignal<"share" | "connect">("share");10139   const [sharePhase, setSharePhase] = createSignal<SharePhase>("opening");10240   const [invitation, setInvitation] = createSignal("");10341   const [sourceSas, setSourceSas] = createSignal("");10442   const [receiverName, setReceiverName] = createSignal("New device");10543   const [shareError, setShareError] = createSignal("");10644 107-  const codeReady = () => parsePairingCode(code()) !== null;108-109-  function startFlow(): void {110-    setMode("connect");111-    setCode("");112-    setSas(null);113-    setErrorKind("unknown");114-    setPairedDevice(undefined);115-    setBusy(false);116-    setPhase("enter");117-  }118-11945   async function startShareFlow(): Promise<void> {120-    setMode("share");12146     setSharePhase("opening");12247     setShareError("");12348     setInvitation("");@@ -145,11 +70,6 @@14570     await sendMessage({ type: "CANCEL_ADOPTION_SOURCE" }).catch(() => undefined);14671   }14772 148-  async function useCodeInstead(): Promise<void> {149-    await stopShareFlow();150-    startFlow();151-  }152-15373   async function verifySourcePassword(password: string): Promise<boolean> {15474     const response = (await sendMessage({15575       type: "ADOPTION_SOURCE_CONFIRM",@@ -158,90 +78,15 @@15878     return response.ok && response.data?.valid === true;15979   }16080 161-  function onCodeInput(value: string): void {162-    const cleaned = value.toUpperCase().replace(/[^0-9A-Z]/g, "").slice(0, 32);163-    setCode(formatPairingCode(cleaned));164-  }165-166-  async function handleConnect(): Promise<void> {167-    if (!codeReady() || busy()) return;168-    setBusy(true);169-    setPhase("connecting");170-    try {171-      const res = (await sendMessage({172-        type: "PAIR_WITH_CODE",173-        payload: { code: code() },174-      })) as { ok: boolean; error?: string };175-      // A synchronous start failure (e.g. vault_locked) returns here; the SAS176-      // and the terminal result arrive later as pushes.177-      if (!res.ok) {178-        setErrorKind((res.error as CodePairingError) ?? "unknown");179-        setPhase("error");180-      }181-    } catch {182-      setErrorKind("unknown");183-      setPhase("error");184-    } finally {185-      setBusy(false);186-    }187-  }188-189-  async function respondConfirm(confirmed: boolean): Promise<void> {190-    if (busy()) return;191-    setBusy(true);192-    // Optimistically move on; the terminal CODE_PAIR_RESULT push decides the193-    // final phase (paired vs sas_mismatch error).194-    if (confirmed) setPhase("connecting");195-    else { setErrorKind("sas_mismatch"); setPhase("error"); }196-    try {197-      await sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed } });198-    } finally {199-      setBusy(false);200-    }201-  }202-203-  // Auto-dismiss after the paired state settles — back to the Settings hub,204-  // which reloads the paired-device list on mount.205-  createEffect(() => {206-    if (phase() !== "paired") return;207-    const t = setTimeout(() => props.onBack(), PAIRED_DISMISS_MS);208-    onCleanup(() => clearTimeout(t));209-  });210-21181   onMount(() => {212-    // Opened straight into a pairing path from the Settings sync card. With no213-    // method there's nothing to do here — return to Settings (the hub).214-    if (props.initialPair === "code") {215-      void sendMessage({ type: "GET_RELAY_SETTINGS" })216-        .then((response) => {217-          const settings = response as {218-            ok: boolean;219-            data?: { lanOnly?: boolean };220-          };221-          if (settings.ok && settings.data?.lanOnly !== true) {222-            return startShareFlow();223-          }224-          startFlow();225-        })226-        .catch(() => startFlow());227-    } else props.onBack();82+    void startShareFlow();22883 229-    // Pairing pushes from the background (bg → popup). The whole NK230-    // pairing runs in the service worker; the SAS to confirm and the terminal231-    // result arrive here as runtime messages.84+    // Pairing pushes from the background (bg → popup). The whole NK session85+    // runs in the service worker; the SAS to confirm and the terminal result86+    // arrive here as runtime messages.23287     if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {23388       const handler = (msg: { type?: string; payload?: unknown }) => {234-        if (msg?.type === "CODE_PAIR_SAS") {235-          const p = msg.payload as { sas?: string } | undefined;236-          if (typeof p?.sas === "string") { setSas(p.sas); setPhase("sas"); }237-        } else if (msg?.type === "CODE_PAIR_RESULT") {238-          const p = msg.payload as239-            | { status: "complete"; device: PairedDevice }240-            | { status: "error"; error: CodePairingError }241-            | undefined;242-          if (p?.status === "complete") { setPairedDevice(p.device); setPhase("paired"); }243-          else if (p?.status === "error") { setErrorKind(p.error); setPhase("error"); }244-        } else if (msg?.type === "ADOPTION_SOURCE_SAS") {89+        if (msg?.type === "ADOPTION_SOURCE_SAS") {24590           const p = msg.payload as {24691             sas?: string;24792             receiverDeviceName?: string;@@ -270,21 +115,16 @@270115       chrome.runtime.onMessage.addListener(handler);271116       onCleanup(() => chrome.runtime.onMessage.removeListener(handler));272117     }273-    // If the popup closes while the user is on the SAS screen, resolve the274-    // pending background confirm as false so the service worker clears275-    // inFlight and future pairing attempts aren't stuck with "pairing_in_progress".118+119+    // Closing the popup mid-session must release the relay room, or the next120+    // attempt is refused with adoption_source_in_progress.276121     onCleanup(() => {277-      if (phase() === "sas") {278-        void sendMessage({ type: "CODE_PAIR_CONFIRM", payload: { confirmed: false } });279-      }280-      if (mode() === "share" && sharePhase() !== "paired") {122+      if (sharePhase() !== "paired") {281123         void sendMessage({ type: "CANCEL_ADOPTION_SOURCE" });282124       }283125     });284126   });285127 286-  // --- Sub-views --------------------------------------------------287-288128   const backArrow = (289129     <svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">290130       <path d="M10 3 L5 8 L10 13" />@@ -303,127 +143,34 @@303143     </div>304144   );305145 146+  function errorCopy(): string {147+    switch (shareError()) {148+      case "vault_locked":149+        return "Unlock Peach first, then try again.";150+      case "sync_disabled":151+        return "Sync is turned off. Choose a relay in Settings → Sync, then try again.";152+      default:153+        return "The secure pairing session ended. Start again with a fresh code.";154+    }155+  }156+306157   function liveText(): string {307-    switch (phase()) {308-      case "enter": return "Enter the pairing code from your phone";309-      case "connecting": return "Connecting to your phone";310-      case "sas": return "Confirm the code matches on your phone";311-      case "paired": return "Paired";312-      case "error": return errorCopy(errorKind()).headline;158+    switch (sharePhase()) {159+      case "opening": return "Opening a secure pairing room";160+      case "invitation": return "Scan this code on your other device";161+      case "sas": return "Confirm the code matches";162+      case "connecting": return "Waiting for the new device";163+      case "paired": return "Device added";164+      case "error": return "Could not add the device";313165     }314166   }315167 316-  // Code pairing — birds animate on every screen below.317-  const pairingScreen = () => (318-    <div class="peach-pair-pair peach-desktop-pair" data-phase={phase()} role="dialog" aria-label="Pair a device with a code">319-      <header class="peach-pair-head">320-        <button class="peach-pair-back" aria-label="Back" onClick={() => props.onBack()}>{backArrow}</button>321-        <div class="peach-pair-title">Enter a code</div>322-        <div class="peach-pair-head-spacer" />323-      </header>324-325-      {birdStage(phase() !== "enter")}326-327-      <Show when={phase() === "enter"}>328-        <div class="peach-desktop-enter">329-          <div class="peach-pair-headline">Enter the code from your phone</div>330-          <div class="peach-pair-sub">331-            Open Peach on your phone, tap “Pair a device”, and type the code it shows.332-            Both devices need to be on the same Wi-Fi.333-          </div>334-          <label class="peach-label" for="pair-code">pairing code</label>335-          <input336-            id="pair-code"337-            class="peach-underline-input peach-unlock-input peach-code-input"338-            type="text"339-            autocapitalize="characters"340-            autocomplete="one-time-code"341-            spellcheck={false}342-            maxlength={32}343-            placeholder="A74H-48J3-KI32-OEP4"344-            aria-label="Pairing code"345-            value={code()}346-            ref={(el) => setTimeout(() => el.focus(), 80)}347-            onInput={(e) => onCodeInput((e.target as HTMLInputElement).value)}348-            onKeyDown={(e) => { if (e.key === "Enter") void handleConnect(); }}349-          />350-          <div class="peach-pair-action">351-            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>352-            <button353-              class="peach-btn peach-btn-primary peach-desktop-connect"354-              disabled={!codeReady() || busy()}355-              onClick={() => void handleConnect()}356-            >357-              Connect358-            </button>359-          </div>360-        </div>361-      </Show>362-363-      <Show when={phase() === "connecting"}>364-        <div class="peach-desktop-searching">365-          <div class="peach-pair-headline">Connecting…</div>366-          <div class="peach-pair-sub">Reaching your phone over the local network.</div>367-        </div>368-      </Show>369-370-      <Show when={phase() === "sas"}>371-        <div class="peach-desktop-confirm" style="display:flex;flex-direction:column;align-items:center;gap:12px;padding:8px 18px 16px;text-align:center;">372-          <div class="peach-pair-headline">Confirm this code matches on your phone:</div>373-          <div374-            class="peach-desktop-sas"375-            style="font-size:30px;font-weight:700;letter-spacing:0.32em;font-family:'IBM Plex Mono',monospace;color:var(--peach-accent);padding:6px 0 2px;"376-          >377-            {sas()}378-          </div>379-          <div class="peach-pair-sub" style="max-width:240px;">380-            Only continue if this 8-digit code is identical on both screens. This is381-            what proves you're talking to your own device.382-          </div>383-          <div style="display:flex;flex-direction:column;gap:8px;width:100%;max-width:240px;">384-            <button class="peach-btn peach-btn-primary" disabled={busy()} onClick={() => void respondConfirm(true)}>385-              Confirm386-            </button>387-            <button class="peach-btn peach-btn-ghost" disabled={busy()} onClick={() => void respondConfirm(false)}>388-              Cancel389-            </button>390-          </div>391-        </div>392-      </Show>393-394-      <Show when={phase() === "paired"}>395-        <div class="peach-desktop-paired">396-          <div class="peach-pair-headline">Paired!</div>397-          <div class="peach-pair-sub">398-            {pairedDevice()?.deviceName ? `Connected to ${pairedDevice()!.deviceName}` : "Connected to your phone"}399-          </div>400-          <div class="peach-pair-action">401-            <button class="peach-btn peach-btn-primary" onClick={() => props.onBack()}>Done</button>402-          </div>403-        </div>404-      </Show>405-406-      <Show when={phase() === "error"}>407-        <div class="peach-desktop-error">408-          <div class="peach-pair-headline">{errorCopy(errorKind()).headline}</div>409-          <div class="peach-pair-sub">{errorCopy(errorKind()).sub}</div>410-          <div class="peach-pair-action">411-            <button class="peach-btn peach-btn-ghost" onClick={() => props.onBack()}>Cancel</button>412-            <button class="peach-btn peach-btn-primary" onClick={() => startFlow()}>Try again</button>413-          </div>414-        </div>415-      </Show>416-417-      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>418-    </div>419-  );420-421-  const shareScreen = () => (168+  return (422169     <div423170       class="peach-pair-pair peach-desktop-pair peach-adoption-source"424171       data-phase={sharePhase()}425172       role="dialog"426-      aria-label="Share this vault with a new device"173+      aria-label="Add a device to this vault"427174     >428175       <header class="peach-pair-head">429176         <button@@ -442,7 +189,7 @@442189       <Show when={sharePhase() === "opening"}>443190         <div class="peach-desktop-searching">444191           <div class="peach-pair-headline">Opening a secure pairing room…</div>445-          <div class="peach-pair-sub">The QR appears only after this browser is waiting in the relay.</div>192+          <div class="peach-pair-sub">The code appears once this browser is waiting in the relay.</div>446193         </div>447194       </Show>448195 @@ -450,18 +197,10 @@450197         <div class="peach-adoption-source-invitation">451198           <div class="peach-pair-headline">Scan this in Peach</div>452199           <div class="peach-pair-sub">453-            Connecting via secure relay — works anywhere. The QR contains only454-            the relay address and a single-use random token.200+            Open Peach on your phone, choose “Scan a code”, and point it here.201+            The code carries only the relay address and a single-use token.455202           </div>456203           <PairingQrCode value={invitation()} size={188} />457-          <div class="peach-pair-action">458-            <button459-              class="peach-btn peach-btn-ghost"460-              onClick={() => void useCodeInstead()}461-            >462-              Enter a code instead463-            </button>464-          </div>465204         </div>466205       </Show>467206 @@ -475,7 +214,7 @@475214       <Show when={sharePhase() === "paired"}>476215         <div class="peach-desktop-paired">477216           <div class="peach-pair-headline">Device added</div>478-          <div class="peach-pair-sub">The new device can now sync this vault through the secure relay.</div>217+          <div class="peach-pair-sub">The new device can now sync this vault through the relay.</div>479218           <div class="peach-pair-action">480219             <button class="peach-btn peach-btn-primary" onClick={props.onBack}>Done</button>481220           </div>@@ -485,11 +224,7 @@485224       <Show when={sharePhase() === "error"}>486225         <div class="peach-desktop-error">487226           <div class="peach-pair-headline">Could not add the device</div>488-          <div class="peach-pair-sub">489-            {shareError() === "vault_locked"490-              ? "Unlock Peach first, then try again."491-              : "The secure pairing session ended. Start again with a fresh QR."}492-          </div>227+          <div class="peach-pair-sub">{errorCopy()}</div>493228           <div class="peach-pair-action">494229             <button class="peach-btn peach-btn-ghost" onClick={props.onBack}>Cancel</button>495230             <button class="peach-btn peach-btn-primary" onClick={() => void startShareFlow()}>Try again</button>@@ -515,10 +250,8 @@515250           }}516251         />517252       </Show>253+254+      <div class="peach-sr-only" aria-live="polite">{liveText()}</div>518255     </div>519256   );520-521-  // Sync is no longer Pro-gated: anyone may attempt to pair so a free device can522-  // receive Pro from an already-Pro peer. The upsell lives on the Settings card.523-  return mode() === "share" ? shareScreen() : pairingScreen();524257 };

view file

src/popup/views/Settings.tsx+92 −59
@@ -270,7 +270,7 @@270270   const [syncStatus, setSyncStatus] = createSignal<"idle" | "syncing" | "error">("idle");271271   const [syncFlash, setSyncFlash] = createSignal<string | null>(null);272272   const [relayUrl, setRelayUrl] = createSignal("");273-  const [lanOnly, setLanOnly] = createSignal(false);273+  const [syncMode, setSyncMode] = createSignal<"public" | "private" | "off">("public");274274   const [relaySaving, setRelaySaving] = createSignal(false);275275   const [relayStatus, setRelayStatus] = createSignal<string | null>(null);276276 @@ -342,30 +342,31 @@342342     }343343   }344344   async function loadRelaySettings() {345-    const result = (await sendMessage({ type: "GET_RELAY_SETTINGS" })) as {345+    const result = (await sendMessage({ type: "GET_SYNC_SETTINGS" })) as {346346       ok: boolean;347-      data?: { relayUrl: string; lanOnly: boolean };347+      data?: { relayUrl: string; mode: "public" | "private" | "off" };348348     };349349     if (result.ok && result.data) {350350       setRelayUrl(result.data.relayUrl);351-      setLanOnly(result.data.lanOnly);351+      setSyncMode(result.data.mode);352352     }353353   }354-  async function saveRelaySettings(nextLanOnly = lanOnly()) {354+  /** Pass a mode to switch; pass nothing to save the typed private relay URL. */355+  async function saveSyncSettings(mode?: "public" | "private" | "off") {355356     setRelaySaving(true);356357     setRelayStatus(null);357358     const result = (await sendMessage({358-      type: "SET_RELAY_SETTINGS",359-      payload: { relayUrl: relayUrl(), lanOnly: nextLanOnly },359+      type: "SET_SYNC_SETTINGS",360+      payload: mode ? { mode } : { relayUrl: relayUrl() },360361     })) as {361362       ok: boolean;362363       error?: string;363-      data?: { relayUrl: string; lanOnly: boolean };364+      data?: { relayUrl: string; mode: "public" | "private" | "off" };364365     };365366     if (result.ok && result.data) {366367       setRelayUrl(result.data.relayUrl);367-      setLanOnly(result.data.lanOnly);368-      setRelayStatus("Saved");368+      setSyncMode(result.data.mode);369+      setRelayStatus(mode ? null : "Saved");369370     } else {370371       setRelayStatus("Use a valid wss:// relay URL");371372     }@@ -1574,9 +1575,9 @@15741575 15751576           {/* Pick a pairing method and begin — no separate hub screen. */}15761577           <p class="peach-settings-sub-label" style="margin-top:10px">PAIR A DEVICE</p>1577-          {/* One path in: the phone listens and shows a code, the browser dials1578-              it. A browser extension can never accept a connection, so there is1579-              no flow where this device is the one being joined. */}1578+          {/* One path in: this browser displays an invitation and the other1579+              device scans it. An extension can neither listen nor use a camera,1580+              so displaying is the only role it can take. */}15801581           <button15811582             class="peach-sync-pair-option"15821583             onClick={() => props.onOpenSyncPairing?.("code")}@@ -1588,8 +1589,8 @@15881589               </svg>15891590             </span>15901591             <span class="peach-sync-pair-option-main">1591-              <span class="peach-sync-pair-option-title">Phone or tablet</span>1592-              <span class="peach-sync-pair-option-sub">Enter the pairing code your phone shows — both on the same Wi-Fi</span>1592+              <span class="peach-sync-pair-option-title">Show a code</span>1593+              <span class="peach-sync-pair-option-sub">This browser shows a code; scan it with Peach on your phone</span>15931594             </span>15941595             <span class="peach-sync-pair-option-chevron" aria-hidden="true">›</span>15951596           </button>@@ -1599,54 +1600,86 @@15991600             </p>16001601           </Show>16011602 1602-          <p class="peach-settings-sub-label" style="margin-top:12px">CONNECTION</p>1603-          <div class="peach-settings-row peach-settings-row--top">1604-            <div class="peach-settings-row-main">1605-              <span>Use local network only (no relay)</span>1606-              <span class="peach-settings-value" style="font-size:11px;line-height:1.5;color:var(--peach-t2)">1607-                Sync will only work when your devices share the same local network.1608-                It won't work over VPN or when your devices are in different locations.1603+          {/* Three named choices, not a toggle with an implied default.1604+              LAN sync is retired (docs/sync-lan-retirement.md), so "no relay"1605+              now means no sync rather than a peer-to-peer mode that never1606+              reliably worked. */}1607+          <p class="peach-settings-sub-label" style="margin-top:12px">HOW THIS VAULT SYNCS</p>1608+1609+          <button1610+            class="peach-sync-mode"1611+            classList={{ "is-active": syncMode() === "public" }}1612+            disabled={relaySaving()}1613+            onClick={() => void saveSyncSettings("public")}1614+          >1615+            <span class="peach-sync-mode-main">1616+              <span class="peach-sync-mode-title">Public relay</span>1617+              <span class="peach-sync-mode-sub">1618+                Peach's own relay. Works anywhere, nothing to set up. It never sees1619+                your vault — everything is encrypted before it leaves the device.16091620               </span>1610-            </div>1611-            <label class="peach-switch">1612-              <input1613-                type="checkbox"1614-                checked={lanOnly()}1615-                disabled={relaySaving()}1616-                onChange={(event) => {1617-                  const enabled = (event.target as HTMLInputElement).checked;1618-                  setLanOnly(enabled);1619-                  void saveRelaySettings(enabled);1620-                }}1621-              />1622-              <span class="peach-switch-track" />1623-            </label>1624-          </div>1625-          <label class="peach-label" for="sync-relay-url">mesh relay URL</label>1626-          <input1627-            id="sync-relay-url"1628-            class="peach-underline-input"1629-            type="url"1630-            inputmode="url"1631-            autocomplete="off"1632-            spellcheck={false}1633-            value={relayUrl()}1621+            </span>1622+            <span class="peach-sync-mode-pip" aria-hidden="true" />1623+          </button>1624+1625+          <button1626+            class="peach-sync-mode"1627+            classList={{ "is-active": syncMode() === "private" }}16341628             disabled={relaySaving()}1635-            onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1636-            onKeyDown={(event) => {1637-              if (event.key === "Enter") void saveRelaySettings();1638-            }}1639-          />1640-          <div class="peach-settings-row peach-settings-row--meta">1641-            <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1642-              {relayStatus() ?? "Only secure wss:// relays are accepted."}1629+            onClick={() => void saveSyncSettings("private")}1630+          >1631+            <span class="peach-sync-mode-main">1632+              <span class="peach-sync-mode-title">Private relay</span>1633+              <span class="peach-sync-mode-sub">1634+                Your own peach-relay, on your own domain. Same open-source code.1635+              </span>16431636             </span>1644-            <button1645-              class="peach-btn peach-btn--subtle peach-btn--sm"1637+            <span class="peach-sync-mode-pip" aria-hidden="true" />1638+          </button>1639+1640+          <Show when={syncMode() === "private"}>1641+            <label class="peach-label" for="sync-relay-url">relay URL</label>1642+            <input1643+              id="sync-relay-url"1644+              class="peach-underline-input"1645+              type="url"1646+              inputmode="url"1647+              autocomplete="off"1648+              spellcheck={false}1649+              value={relayUrl()}16461650               disabled={relaySaving()}1647-              onClick={() => void saveRelaySettings()}1648-            >{relaySaving() ? "saving…" : "save"}</button>1649-          </div>1651+              onInput={(event) => setRelayUrl((event.target as HTMLInputElement).value)}1652+              onKeyDown={(event) => {1653+                if (event.key === "Enter") void saveSyncSettings();1654+              }}1655+            />1656+            <div class="peach-settings-row peach-settings-row--meta">1657+              <span class="peach-settings-value" style={`color:${relayStatus()?.startsWith("Use") ? "var(--peach-breach)" : "var(--peach-t2)"}`}>1658+                {relayStatus() ?? "Only secure wss:// relays are accepted."}1659+              </span>1660+              <button1661+                class="peach-btn peach-btn--subtle peach-btn--sm"1662+                disabled={relaySaving()}1663+                onClick={() => void saveSyncSettings()}1664+              >{relaySaving() ? "saving…" : "save"}</button>1665+            </div>1666+          </Show>1667+1668+          <button1669+            class="peach-sync-mode"1670+            classList={{ "is-active": syncMode() === "off" }}1671+            disabled={relaySaving()}1672+            onClick={() => void saveSyncSettings("off")}1673+          >1674+            <span class="peach-sync-mode-main">1675+              <span class="peach-sync-mode-title">No sync</span>1676+              <span class="peach-sync-mode-sub">1677+                This device keeps to itself. Move your vault by hand with Codex or1678+                CSV export and import.1679+              </span>1680+            </span>1681+            <span class="peach-sync-mode-pip" aria-hidden="true" />1682+          </button>16501683 16511684           {/* Paired devices + manual sync — Settings is the hub. */}16521685           <Show when={pairedDevices().length > 0}>

view file

src/shared/local-network.ts+0 −28
@@ -1,28 +0,0 @@1-// Local network discovery — minimal helper used by the legacy code-pairing probe.2-//3-// `getLocalSubnet` returns the /24 prefix (e.g. "192.168.1") that the4-// browser is on, derived from a host-relative WebRTC ICE candidate. The5-// extension uses this to scan the LAN for the peer's pairing-port6-// WebSocket when a legacy addressless code has to be resolved. Legacy v2.147-// discovery/pair probes have been removed — see code-pairing.ts for the8-// v3 pairing-port scan and sync-transport.ts for the rate-limited9-// Phase-2 subnet probe.10-11-// Returns e.g. "192.168.1" or null if unavailable.12-export async function getLocalSubnet(): Promise<string | null> {13-  return new Promise((resolve) => {14-    try {15-      const pc = new RTCPeerConnection({ iceServers: [] });16-      pc.createDataChannel('');17-      pc.onicecandidate = (e) => {18-        if (!e.candidate) { pc.close(); resolve(null); return; }19-        const m = e.candidate.candidate.match(/(\d+\.\d+\.\d+)\.\d+/);20-        if (m) { pc.close(); resolve(m[1]); }21-      };22-      pc.createOffer().then(o => pc.setLocalDescription(o)).catch(() => { pc.close(); resolve(null); });23-      setTimeout(() => { try { pc.close(); } catch { /* ignore */ } resolve(null); }, 2000);24-    } catch {25-      resolve(null);26-    }27-  });28-}

view file at the parent

src/shared/pairing-code.test.ts+0 −100
@@ -1,100 +0,0 @@1-import { describe, it, expect } from "vitest";2-import {3-  PAIRING_TOKEN_BYTES,4-  encodePairingCode,5-  formatPairingCode,6-  parsePairingCode,7-} from "./pairing-code";8-9-const token4 = Uint8Array.of(0xde, 0xad, 0xbe, 0xef);10-11-describe("pairing-code", () => {12-  // GOLDEN VECTOR — Android's PairingCode.kt is pinned against this exact13-  // output. Changing it is a coordinated cross-repo change, never a local edit.14-  it("golden vector: 192.168.1.42:49142 + deadbeef -> R2M0-2ANZ-YVFA-VFQF", () => {15-    const code = encodePairingCode("192.168.1.42", 49142, token4);16-    expect(code).toBe("R2M0-2ANZ-YVFA-VFQF");17-    expect(parsePairingCode(code)).toEqual({18-      ip: "192.168.1.42",19-      port: 49142,20-      tokenHex: "deadbeef",21-    });22-  });23-24-  it("is exactly 16 characters in 4 groups — the whole point of a 4-byte token", () => {25-    const code = encodePairingCode("192.168.1.42", 49142, token4);26-    expect(code.replace(/-/g, "")).toHaveLength(16);27-    expect(code).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);28-    expect(PAIRING_TOKEN_BYTES).toBe(4);29-  });30-31-  it("refuses to encode a token of any other size", () => {32-    // An 8-byte token is what produced the 23-character, 6-group codes; Base3233-    // gives 5 bits per character, so there is no encoding fix — only this gate.34-    for (const n of [3, 5, 6, 8, 10]) {35-      expect(() => encodePairingCode("192.168.1.42", 49142, new Uint8Array(n))).toThrow(/exactly 4 bytes/);36-    }37-  });38-39-  it("still decodes an older 8-byte-token code so a stale peer can pair", () => {40-    // Real code captured from an Android emulator build before the fix.41-    expect(parsePairingCode("1800-43XF-HPH0-2EP9-A3PR-7P0")).toEqual({42-      ip: "10.0.2.15",43-      port: 44941,44-      tokenHex: "a2013ac950ed83d8",45-    });46-  });47-48-  it("preserves the full port range", () => {49-    for (const port of [1, 49142, 51555, 65535]) {50-      const code = encodePairingCode("192.168.4.230", port, token4);51-      expect(parsePairingCode(code)?.port).toBe(port);52-    }53-  });54-55-  it("folds the ambiguous glyphs I, L and O on the way in", () => {56-    const code = encodePairingCode("192.168.1.42", 49142, token4);57-    const ambiguous = code.replace(/0/g, "O").replace(/1/g, "I");58-    expect(parsePairingCode(ambiguous)).toEqual(parsePairingCode(code));59-  });60-61-  it("accepts a code typed without dashes, in lowercase, with stray spaces", () => {62-    const code = encodePairingCode("192.168.1.42", 49142, token4);63-    const sloppy = ` ${code.replace(/-/g, " ").toLowerCase()} `;64-    expect(parsePairingCode(sloppy)).toEqual(parsePairingCode(code));65-  });66-67-  it("rejects codes that are too short to carry a token", () => {68-    // 4 IP bytes + 2 port bytes and nothing else — 10 base32 characters.69-    expect(parsePairingCode("ABCDEFGHJK")).toBeNull();70-    expect(parsePairingCode("")).toBeNull();71-    expect(parsePairingCode("---")).toBeNull();72-  });73-74-  it("rejects characters outside Crockford's alphabet", () => {75-    // U is excluded by Crockford and is NOT folded, unlike I/L/O.76-    expect(parsePairingCode("UUUU-UUUU-UUUU-UUUU")).toBeNull();77-    expect(parsePairingCode("A74H-48J3-KI32-OEP!")).toBeNull();78-  });79-80-  it("rejects a zero port", () => {81-    // An all-zero payload is long enough but decodes to 0.0.0.0:0 — a code82-    // that parsed would send the dialler at an unroutable endpoint.83-    expect(parsePairingCode("0000-0000-0000-0000")).toBeNull();84-  });85-86-  it("refuses to encode an out-of-range address, port, or short token", () => {87-    expect(() => encodePairingCode("192.168.1.256", 49142, token4)).toThrow();88-    expect(() => encodePairingCode("192.168.1", 49142, token4)).toThrow();89-    expect(() => encodePairingCode("192.168.1.42", 0, token4)).toThrow();90-    expect(() => encodePairingCode("192.168.1.42", 70000, token4)).toThrow();91-    expect(() => encodePairingCode("192.168.1.42", 49142, Uint8Array.of(1, 2, 3))).toThrow();92-  });93-94-  it("regroups partial input as the user types", () => {95-    expect(formatPairingCode("a74h48")).toBe("A74H-48");96-    expect(formatPairingCode("A74H-48J3-KI32-OEP4")).toBe("A74H-48J3-KI32-OEP4");97-    expect(formatPairingCode("a74h 48j3")).toBe("A74H-48J3");98-    expect(formatPairingCode("")).toBe("");99-  });100-});

view file at the parent

src/shared/pairing-code.ts+0 −168
@@ -1,168 +0,0 @@1-// Pairing code — the compact join code the phone displays and the user types2-// into the browser.3-//4-// Payload: IPv4 (4 bytes) | port (2 bytes, big-endian) | token (>= 4 bytes),5-// encoded in Crockford's Base32 and displayed in groups of four:6-//7-//     A74H-48J3-KI32-OEP48-//9-// The browser only ever DECODES. The phone is the listener and the only side10-// that generates codes; `encodePairingCode` exists so the wire format has one11-// authoritative definition and so the round trip is testable.12-//13-// Crockford's alphabet omits I, L, O and U. Decoding still folds I/L -> 1 and14-// O -> 0, so a code misread off a phone screen (or a doc example that used the15-// ambiguous glyphs) still resolves to the right bytes.16-//17-// LENGTH — this is the whole reason the token is 4 bytes and not 8.18-//19-// Base32 carries 5 bits per character, so the code length is fixed arithmetic,20-// not something an encoder can optimise:21-//22-//     token 4B -> 10B payload ->  80 bits -> 16 chars -> 4 groups  <- target23-//     token 6B -> 12B payload ->  96 bits -> 20 chars -> 5 groups24-//     token 8B -> 14B payload -> 112 bits -> 23 chars -> 6 groups25-//26-// The original spec asked for BOTH an 8–10 byte token AND ~16 characters. Those27-// are not simultaneously satisfiable: 16 characters is exactly 80 bits, and28-// IP (32) + port (16) leaves exactly 32 bits. A 4-byte token is not a29-// compromise, it is the only token size that fits four groups. If you widen the30-// token, the code gets longer — there is no encoding trick that avoids it.31-//32-// SECURITY: 32 bits is sufficient here and the entropy is not what protects33-// pairing. The token travels in the clear to the phone (see the hello frame in34-// pairing-dial.ts) and is NOT the security boundary — the user-compared SAS35-// after the Noise NK handshake is. The token's job is to stop an unrelated host36-// on the same LAN from consuming the phone's pairing window during a 5–1037-// minute, single-use window. That does assume the LISTENER closes on a wrong38-// token rather than allowing unbounded guessing — which it does today (a wrong39-// token gets a silent close and the code rotates).40-41-const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";42-const IP_BYTES = 4;43-const PORT_BYTES = 2;44-45-/**46- * The canonical token size. Encoding anything else is refused so both sides47- * keep deriving from one format — this is the value Android's golden vector is48- * pinned against.49- */50-export const PAIRING_TOKEN_BYTES = 4;51-52-// Decoding stays deliberately lenient about token length so a peer on an older53-// build (8-byte tokens, 23 characters) still pairs instead of hard-failing.54-const MIN_TOKEN_BYTES = 4;55-const MAX_TOKEN_BYTES = 16;56-57-export interface PairingCode {58-  ip: string;59-  port: number;60-  /** The one-time token, hex-encoded — presented verbatim in the hello frame. */61-  tokenHex: string;62-}63-64-/**65- * Decode a typed pairing code. Returns null for anything that is not a66- * well-formed code — the caller treats null as "not a pairing code" rather67- * than as an error, so other code formats can share the same input field.68- */69-export function parsePairingCode(input: string): PairingCode | null {70-  const bytes = decodeCrockford(input);71-  if (!bytes) return null;72-  const tokenBytes = bytes.length - IP_BYTES - PORT_BYTES;73-  if (tokenBytes < MIN_TOKEN_BYTES || tokenBytes > MAX_TOKEN_BYTES) return null;74-  const port = (bytes[4] << 8) | bytes[5];75-  if (port === 0) return null;76-  return {77-    ip: `${bytes[0]}.${bytes[1]}.${bytes[2]}.${bytes[3]}`,78-    port,79-    tokenHex: toHex(bytes.subarray(IP_BYTES + PORT_BYTES)),80-  };81-}82-83-/**84- * Build a pairing code. Throws on an unencodable address or port, or on a token85- * that is not exactly `PAIRING_TOKEN_BYTES` — a longer token silently produces86- * a longer code, which is the bug this strictness exists to prevent.87- */88-export function encodePairingCode(ip: string, port: number, token: Uint8Array): string {89-  const octets = ip.split(".");90-  if (octets.length !== IP_BYTES) throw new Error("ip must be dotted-quad IPv4");91-  const bytes = new Uint8Array(IP_BYTES + PORT_BYTES + token.length);92-  for (let i = 0; i < IP_BYTES; i++) {93-    const n = Number(octets[i]);94-    if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`invalid IPv4 octet: ${octets[i]}`);95-    bytes[i] = n;96-  }97-  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`invalid port: ${port}`);98-  bytes[4] = (port >> 8) & 0xff;99-  bytes[5] = port & 0xff;100-  if (token.length !== PAIRING_TOKEN_BYTES) {101-    throw new Error(102-      `token must be exactly ${PAIRING_TOKEN_BYTES} bytes (got ${token.length}); ` +103-      "every extra byte adds 1.6 characters to the code",104-    );105-  }106-  bytes.set(token, IP_BYTES + PORT_BYTES);107-  return group(encodeCrockford(bytes));108-}109-110-/**111- * Normalise partial user input for display in the code field: drop the112- * characters Crockford has no place for, uppercase, and re-group in fours.113- * Applied on every keystroke, so it must tolerate an incomplete code.114- */115-export function formatPairingCode(input: string): string {116-  return group(input.toUpperCase().replace(/[^0-9A-Z]/g, ""));117-}118-119-function group(chars: string): string {120-  return chars.replace(/(.{4})(?=.)/g, "$1-");121-}122-123-function decodeCrockford(input: string): Uint8Array | null {124-  const clean = input125-    .toUpperCase()126-    .replace(/[\s-]/g, "")127-    .replace(/[ILO]/g, (c) => (c === "O" ? "0" : "1"));128-  if (clean.length === 0) return null;129-130-  // Trailing bits that don't complete a byte are padding and are dropped.131-  const out = new Uint8Array(Math.floor((clean.length * 5) / 8));132-  let acc = 0;133-  let bits = 0;134-  let o = 0;135-  for (const ch of clean) {136-    const v = ALPHABET.indexOf(ch);137-    if (v < 0) return null;138-    acc = (acc << 5) | v;139-    bits += 5;140-    if (bits >= 8) {141-      bits -= 8;142-      out[o++] = (acc >> bits) & 0xff;143-    }144-  }145-  return out;146-}147-148-function encodeCrockford(bytes: Uint8Array): string {149-  let s = "";150-  let acc = 0;151-  let bits = 0;152-  for (const b of bytes) {153-    acc = (acc << 8) | b;154-    bits += 8;155-    while (bits >= 5) {156-      bits -= 5;157-      s += ALPHABET[(acc >> bits) & 0x1f];158-    }159-  }160-  if (bits > 0) s += ALPHABET[(acc << (5 - bits)) & 0x1f];161-  return s;162-}163-164-function toHex(bytes: Uint8Array): string {165-  let s = "";166-  for (const b of bytes) s += b.toString(16).padStart(2, "0");167-  return s;168-}

view file at the parent

src/shared/pairing-dial.test.ts+0 −193
@@ -1,193 +0,0 @@1-import { beforeEach, describe, expect, it, vi } from "vitest";2-3-import {4-  dialPairingChannel,5-  dialRelayPairingChannel,6-  type PairingSocket,7-} from "./pairing-dial";8-import { encodePairingCode } from "./pairing-code";9-import { createPairingInvitation } from "./pairing-invitation";10-11-const enc = (o: unknown) => new TextEncoder().encode(JSON.stringify(o));12-const dec = (b: Uint8Array) => JSON.parse(new TextDecoder().decode(b));13-14-const LISTENER_EPHEMERAL_HEX = "a".repeat(64);15-const CODE = encodePairingCode("192.168.4.77", 51555, Uint8Array.of(0xde, 0xad, 0xbe, 0xef));16-const RELAY_TOKEN = "AAECAwQFBgcICQoLDA0ODw";17-const RELAY_INVITATION = createPairingInvitation(18-  "wss://relay.example.test/base",19-  RELAY_TOKEN,20-);21-22-const FAKE_HANDSHAKE_HASH = Uint8Array.from([0x0f, 0xba, 0xb8, 0xcd, ...new Array(28).fill(0)]);23-24-// Skip the real handshake messages; encrypt/decrypt are identity transforms so25-// the channel's framing can be asserted directly.26-vi.mock("./noise-js", async (orig) => {27-  const actual = await orig<typeof import("./noise-js")>();28-  return {29-    ...actual,30-    noiseNKInitiator: vi.fn(async () => ({31-      encrypt: (pt: Uint8Array) => pt,32-      decrypt: (ct: Uint8Array) => ct,33-      handshakeHash: FAKE_HANDSHAKE_HASH,34-      close: vi.fn(),35-    })),36-  };37-});38-39-/** `null` in the queue means "the socket closes here with no data". */40-function fakeSocket(inbound: Array<Uint8Array | null>) {41-  const sent: Uint8Array[] = [];42-  let closed = false;43-  let lastWasClose = false;44-  const queue = [...inbound];45-  const sock: PairingSocket = {46-    send(bytes) { sent.push(bytes); },47-    async receive() {48-      const next = queue.shift();49-      if (next === undefined || next === null) {50-        closed = true;51-        lastWasClose = true;52-        throw new Error("socket closed");53-      }54-      lastWasClose = false;55-      return next;56-    },57-    close() { closed = true; },58-    closedBeforeFrame() { return lastWasClose; },59-  };60-  return { sock, sent, isClosed: () => closed };61-}62-63-function ackThen(...rest: Array<Uint8Array | null>) {64-  return fakeSocket([65-    enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: LISTENER_EPHEMERAL_HEX }),66-    ...rest,67-  ]);68-}69-70-beforeEach(() => { vi.clearAllMocks(); });71-72-describe("dialPairingChannel", () => {73-  it("dials the coded endpoint", async () => {74-    const openSocket = vi.fn(async () => ackThen().sock);75-76-    const result = await dialPairingChannel(CODE, { openSocket });77-78-    expect(result.ok).toBe(true);79-    expect(openSocket).toHaveBeenCalledWith("192.168.4.77", 51555);80-  });81-82-  it("presents the token as hex and returns the shared SAS", async () => {83-    const s = ackThen();84-    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });85-86-    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: "deadbeef" });87-    expect(result.ok && result.dialled.sas).toBe("44907520");88-    expect(result.ok && result.dialled.host).toBe("192.168.4.77");89-    expect(result.ok && result.dialled.rendezvousSecretHex).toBe(90-      "a8977cd86e944e72e9cb569dbba00a7b0abbf7f456c745299aa8f66157859143",91-    );92-  });93-94-  it("marks LAN adoption before Noise so the source can skip device-pair auth", async () => {95-    const s = ackThen();96-    const result = await dialPairingChannel(97-      CODE,98-      { openSocket: async () => s.sock },99-      { flow: "adoption" },100-    );101-102-    expect(result.ok).toBe(true);103-    expect(dec(s.sent[0])).toEqual({104-      type: "PEACH_HELLO",105-      token: "deadbeef",106-      flow: "adoption",107-    });108-  });109-110-  it("rejects an addressless legacy code before opening a socket", async () => {111-    const openSocket = vi.fn(async () => ackThen().sock);112-113-    const result = await dialPairingChannel("123456", { openSocket });114-115-    expect(result).toEqual({ ok: false, error: "invalid_code" });116-    expect(openSocket).not.toHaveBeenCalled();117-  });118-119-  it("reads a silent close as invalid_code, not a transport failure", async () => {120-    // A wrong or expired token gets no error frame — the listener just closes.121-    const s = fakeSocket([null]);122-    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });123-124-    expect(result).toEqual({ ok: false, error: "invalid_code" });125-    expect(s.isClosed()).toBe(true);126-  });127-128-  it("treats a non-ACK first frame exactly like a wrong token", async () => {129-    const s = fakeSocket([enc({ type: "SOMETHING_ELSE" })]);130-    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });131-132-    expect(result).toEqual({ ok: false, error: "invalid_code" });133-  });134-135-  it("rejects an ACK whose ephemeral key is not 32-byte hex", async () => {136-    const s = fakeSocket([enc({ type: "PEACH_HELLO_ACK", ephemeralPublicKeyHex: "nope" })]);137-    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });138-139-    expect(result).toEqual({ ok: false, error: "handshake_failed" });140-    expect(s.isClosed()).toBe(true);141-  });142-143-  it("reports no_host_found when the endpoint refuses the connection", async () => {144-    const result = await dialPairingChannel(CODE, {145-      openSocket: async () => { throw new Error("ECONNREFUSED"); },146-    });147-148-    expect(result).toEqual({ ok: false, error: "no_host_found" });149-  });150-151-  it("leaves the socket open on success so the caller owns its lifetime", async () => {152-    const s = ackThen(enc({ hello: "world" }));153-    const result = await dialPairingChannel(CODE, { openSocket: async () => s.sock });154-    if (!result.ok) throw new Error("expected a dialled channel");155-156-    expect(s.isClosed()).toBe(false);157-    await result.dialled.channel.send(enc({ type: "ANYTHING" }));158-    // sent[0] is the plaintext hello; sent[1] went through the Noise channel.159-    expect(dec(s.sent[1])).toEqual({ type: "ANYTHING" });160-    expect(dec(await result.dialled.channel.receive())).toEqual({ hello: "world" });161-162-    result.dialled.socket.close();163-    expect(s.isClosed()).toBe(true);164-  });165-});166-167-describe("dialRelayPairingChannel", () => {168-  it("occupies the invited relay room and runs the identical hello/NK path", async () => {169-    const s = ackThen();170-    const openSocket = vi.fn(async () => s.sock);171-172-    const result = await dialRelayPairingChannel(RELAY_INVITATION, { openSocket });173-174-    expect(result.ok).toBe(true);175-    expect(openSocket).toHaveBeenCalledWith(176-      `wss://relay.example.test/base/r/${RELAY_TOKEN}`,177-    );178-    expect(dec(s.sent[0])).toEqual({ type: "PEACH_HELLO", token: RELAY_TOKEN });179-    expect(result.ok && result.dialled.relayUrl).toBe(180-      "wss://relay.example.test/base",181-    );182-    expect(result.ok && result.dialled.host).toBe("relay.example.test");183-  });184-185-  it("rejects a malformed invitation before opening a socket", async () => {186-    const openSocket = vi.fn(async () => ackThen().sock);187-    const result = await dialRelayPairingChannel("peach-pair-v1:not-valid", {188-      openSocket,189-    });190-    expect(result).toEqual({ ok: false, error: "invalid_code" });191-    expect(openSocket).not.toHaveBeenCalled();192-  });193-});

view file at the parent

src/shared/pairing-dial.ts+6 −36
@@ -19,7 +19,6 @@1919   type NoiseNKSession,2020 } from "./noise-js";2121 import { derivePairingSAS, type PairingTransport } from "./pairing-handshake";22-import { parsePairingCode } from "./pairing-code";2322 import {2423   pairingInvitationUrl,2524   parsePairingInvitation,@@ -27,7 +26,12 @@2726 import { bytesToHex, deriveRendezvousSecret } from "./rendezvous";2827 2928 /** Default pairing port used by native listeners. */30-export const PAIRING_PORT = 49142;29+/**30+ * The port Android's reconnect listener used to accept on. Retained only31+ * because paired records may still carry a `wsPort`; nothing dials it. LAN32+ * transport is retired — see docs/sync-lan-retirement.md.33+ */34+export const LEGACY_LAN_PORT = 49142;3135 3236 export type PairingDialError =3337   | "invalid_code" // socket closed before PEACH_HELLO_ACK (wrong/expired)@@ -88,35 +92,6 @@8892   | { ok: true; dialled: DialledPairingChannel }8993   | { ok: false; error: PairingDialError };9094 91-/**92- * Dial a pairing code and bring up the encrypted channel behind it.93- *94- * On success the socket is left OPEN and owned by the caller, which must close95- * it. On failure it is closed here.96- */97-export async function dialPairingChannel(98-  code: string,99-  deps: PairingDialDeps,100-  options: PairingDialOptions = {},101-): Promise<DialResult> {102-  const pairing = parsePairingCode(code);103-  if (!pairing) return { ok: false, error: "invalid_code" };104-  const { ip: host, port } = pairing;105-106-  let sock: PairingSocket;107-  try {108-    sock = await deps.openSocket(host, port);109-  } catch {110-    return { ok: false, error: "no_host_found" };111-  }112-113-  return finishPairingHandshake(sock, pairing.tokenHex, host, options.flow);114-}115-116-/**117- * Dial a relay-first invitation. The source must already occupy the room118- * because the relay deliberately buffers nothing.119- */12095 export async function dialRelayPairingChannel(12196   encodedInvitation: string,12297   deps: RelayPairingDialDeps,@@ -231,11 +206,6 @@231206     sock.close();232207     return { ok: false, error: "handshake_failed" };233208   }234-}235-236-/** Adapt a real `WebSocket` to the `PairingSocket` seam (binary frames). */237-export function openPairingSocket(ip: string, port: number): Promise<PairingSocket> {238-  return openPairingSocketUrl(`ws://${ip}:${port}/`);239209 }240210 241211 /** Relay-first pairing uses the same binary socket seam over `wss://`. */

view file

src/shared/sync-types.ts+5 −4
@@ -1,8 +1,9 @@1-// Vault sync — type definitions (Master Brief v2).1+// Vault sync — type definitions.22 //3-// v3 — code pairing once per device pair (Phase 1). All ongoing vault sync4-// after that runs over a direct WebSocket on the local network (Phase 2).5-// The two phases never overlap.3+// Pairing happens once per device pair over a relay rendezvous; all ongoing4+// sync afterwards runs over that same relay with Noise KK. There is one5+// transport — LAN peer-to-peer sync is retired6+// (docs/sync-lan-retirement.md).67 78 // ---------------------------------------------------------------------------89 // Device types

view file

src/shared/types.ts+4 −8
@@ -303,12 +303,8 @@303303   | "LIST_PAIRED_DEVICES"304304   | "UNPAIR_DEVICE"305305   | "RENAME_LOCAL_DEVICE"306-  // Code pairing. PAIR_WITH_CODE / CODE_PAIR_CONFIRM are popup→bg requests307-  // handled in the switch; CODE_PAIR_SAS / CODE_PAIR_RESULT are bg→popup pushes.308-  | "PAIR_WITH_CODE"309-  | "CODE_PAIR_CONFIRM"310-  | "CODE_PAIR_SAS"311-  | "CODE_PAIR_RESULT"306+  // Relay pairing. START/CONFIRM/CANCEL are popup→bg requests handled in the307+  // switch; ADOPTION_SOURCE_SAS / ADOPTION_SOURCE_RESULT are bg→popup pushes.312308   | "START_ADOPTION_SOURCE"313309   | "ADOPTION_SOURCE_CONFIRM"314310   | "CANCEL_ADOPTION_SOURCE"@@ -316,8 +312,8 @@316312   | "ADOPTION_SOURCE_RESULT"317313   | "TRIGGER_SYNC"318314   | "GET_SYNC_STATE"319-  | "GET_RELAY_SETTINGS"320-  | "SET_RELAY_SETTINGS"315+  | "GET_SYNC_SETTINGS"316+  | "SET_SYNC_SETTINGS"321317   | "GET_PENDING_PASSKEY"322318   | "APPROVE_PASSKEY"323319   | "DENY_PASSKEY"

view file