781b92629025

feat: Add deterministic full-mesh sync (v4.4.0)

time
2026-07-28 22:20 UTC · 3 months ago
machine
koh2
agent
gpt-5
session
deterministic full mesh
parent
359f57c490f8
22 files manifest.firefox.json+1 −1manifest.json+2 −1package.json+1 −1src/background/entry.ts+14 −1src/background/mesh-relay-listener.test.ts+62 −0src/background/mesh-relay-listener.ts+219 −0src/background/sync-coordinator.test.ts+55 −1src/background/sync-coordinator.ts+164 −4src/background/sync-runner.ts+3 −2src/background/sync-static.test.ts+1 −1src/background/sync-trigger.test.ts+21 −1src/background/sync-trigger.ts+26 −10src/background/sync-vault-adapter.test.ts+34 −15src/background/sync-vault-adapter.ts+155 −112src/shared/device-manifest.test.ts+6 −3src/shared/device-manifest.ts+13 −3src/shared/mesh-peer-role.ts+59 −0src/shared/rendezvous.test.ts+14 −0src/shared/rendezvous.ts+43 −0src/shared/sync-kk-transport.test.ts+94 −1src/shared/sync-transport.ts+185 −5src/shared/sync-types.ts+9 −6
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.3.20",4+  "version": "4.4.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+2 −1
@@ -1,7 +1,8 @@11 {22   "manifest_version": 3,3+  "minimum_chrome_version": "116",34   "name": "Peach Password Manager",4-  "version": "4.3.20",5+  "version": "4.4.0",56   "description": "Zero-knowledge password manager",67   "action": {78     "default_popup": "src/popup/index.html",

view file

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

view file

src/background/entry.ts+14 −1
@@ -8,9 +8,19 @@88 import { ensureSettingsLoaded } from "./vault";99 import { isRecoveryConfirmPending } from "./recovery-pending";1010 import { denyAllPendingPasskeyRequests } from "./passkey";11-import { hydratePersistedSyncState, setSyncRunner, triggerSyncIfReachable } from "./sync-trigger";11+import {12+  hydratePersistedSyncState,13+  setSyncDialTargetSelector,14+  setSyncRunner,15+  triggerSyncIfReachable,16+} from "./sync-trigger";1217 import { productionSyncRunner } from "./sync-runner";1318 import { repairPairedRevocationOverlaps } from "./paired-devices";19+import {20+  requestMeshRelayReconcile,21+  selectProductionDialTargets,22+  setupMeshRelayListener,23+} from "./mesh-relay-listener";1424 1525 // Keep alive for MV3 service worker1626 chrome.runtime.onInstalled.addListener(() => {});@@ -69,6 +79,8 @@6979   // `triggerSyncIfReachable` is a no-op (the runner slot starts null) — the7080   // pairing/transport/coordinator stack ships dormant until this assignment.7181   setSyncRunner(productionSyncRunner);82+  setSyncDialTargetSelector(selectProductionDialTargets);83+  setupMeshRelayListener();7284   // 10-minute background sync. Must be registered during initial synchronous7385   // evaluation for the same reason as the clipboard alarm: a cold worker7486   // woken BY the sync alarm needs the listener already attached. Setup is@@ -79,6 +91,7 @@7991   // guard inside triggerSyncIfReachable handles the case where another path8092   // (e.g. a fresh pairing completing on the same wake) is already running.8193   void restored94+    .then(() => requestMeshRelayReconcile())8295     .then(() => repairPairedRevocationOverlaps())8396     .then(() => hydratePersistedSyncState())8497     .then(() => triggerSyncIfReachable({ reason: "startup" }))

view file

src/background/mesh-relay-listener.test.ts+62 −0
@@ -0,0 +1,62 @@1+import { describe, expect, it } from "vitest";2+import type { PairedDevice } from "../shared/sync-types";3+import {4+  isRouteCapableMeshPeer,5+  nextMeshWaiterRetryDelay,6+  selectDialTargets,7+  weDialMeshPeer,8+} from "../shared/mesh-peer-role";9+10+function peer(11+  id: string,12+  key: string,13+  overrides: Partial<PairedDevice> = {},14+): PairedDevice {15+  return {16+    deviceId: id,17+    deviceName: id,18+    deviceType: "extension",19+    longTermPublicKeyHex: key,20+    signingPublicKeyHex: "cc".repeat(32),21+    rendezvousSecretHex: "dd".repeat(32),22+    relayUrl: "wss://relay.peachpasswords.com",23+    trustSource: "vouched",24+    pairedAt: 1,25+    ...overrides,26+  };27+}28+29+describe("mesh relay roles", () => {30+  it("assigns exactly one dialer for every device-type combination", () => {31+    const low = "11".repeat(32);32+    const high = "ee".repeat(32);33+    for (const deviceType of ["extension", "android", "ios"] as const) {34+      expect(weDialMeshPeer(low, high)).toBe(true);35+      expect(weDialMeshPeer(high, low)).toBe(false);36+      expect(37+        selectDialTargets(38+          [peer(deviceType, high, { deviceType })],39+          low,40+        ).map((device) => device.deviceId),41+      ).toEqual([deviceType]);42+    }43+  });44+45+  it("admits direct and vouched routes but rejects incomplete or unknown rows", () => {46+    const key = "ee".repeat(32);47+    expect(isRouteCapableMeshPeer(peer("direct", key, { trustSource: "direct" }))).toBe(true);48+    expect(isRouteCapableMeshPeer(peer("vouched", key))).toBe(true);49+    expect(isRouteCapableMeshPeer(peer("missing-secret", key, {50+      rendezvousSecretHex: undefined,51+    }))).toBe(false);52+    expect(isRouteCapableMeshPeer(peer("printer", key, {53+      deviceType: "printer" as PairedDevice["deviceType"],54+    }))).toBe(false);55+  });56+57+  it("backs off failed waiter reconnects and resets after a completed round", () => {58+    expect(nextMeshWaiterRetryDelay(2_000, false)).toBe(4_000);59+    expect(nextMeshWaiterRetryDelay(20_000, false)).toBe(30_000);60+    expect(nextMeshWaiterRetryDelay(30_000, true)).toBe(2_000);61+  });62+});

view file

src/background/mesh-relay-listener.ts+219 −0
@@ -0,0 +1,219 @@1+import type { PairedDevice } from "../shared/sync-types";2+import {3+  isRouteCapableMeshPeer,4+  MESH_WAITER_RETRY_DELAY_MS,5+  nextMeshWaiterRetryDelay,6+  selectDialTargets,7+  weDialMeshPeer,8+} from "../shared/mesh-peer-role";9+import {10+  openRelaySyncResponderSession,11+  type SyncPeerIdentity,12+} from "../shared/sync-transport";13+import {14+  rendezvousSecretFromHex,15+  waitingRendezvousIds,16+} from "../shared/rendezvous";17+import { runSyncResponder } from "./sync-coordinator";18+import { createSyncVaultAdapter } from "./sync-vault-adapter";19+import { getLocalPairingIdentity } from "./device-identity";20+import {21+  findPairedDeviceById,22+  listPairedDevices,23+  markLastSyncedAt,24+} from "./paired-devices";25+import { getSyncSettings, relayInForce } from "./sync-settings";26+import { isUnlocked } from "./vault";27+28+const RECONCILE_INTERVAL_MS = 60_000;29+30+interface WaiterHandle {31+  abort: AbortController;32+}33+34+interface MeshListenerState {35+  installed: boolean;36+  reconcileQueued: boolean;37+  timer: ReturnType<typeof setTimeout> | null;38+  waiters: Map<string, WaiterHandle>;39+}40+41+function state(): MeshListenerState {42+  const global = globalThis as typeof globalThis & {43+    __peachMeshListenerState?: MeshListenerState;44+  };45+  if (!global.__peachMeshListenerState) {46+    global.__peachMeshListenerState = {47+      installed: false,48+      reconcileQueued: false,49+      timer: null,50+      waiters: new Map(),51+    };52+  }53+  return global.__peachMeshListenerState;54+}55+56+export async function selectProductionDialTargets(57+  devices: PairedDevice[],58+): Promise<PairedDevice[]> {59+  // Preserve the established VAULT_LOCKED outcome. The production runner60+  // refuses before opening a socket, so returning the candidates here is safe61+  // and avoids misreporting a locked vault as "no eligible host".62+  if (!isUnlocked()) return devices;63+  const local = await getLocalPairingIdentity();64+  try {65+    return selectDialTargets(66+      devices,67+      local.identity.longTermPublicKeyHex,68+    );69+  } finally {70+    local.privateKey.fill(0);71+    local.publicKey.fill(0);72+  }73+}74+75+export function setupMeshRelayListener(): void {76+  const listener = state();77+  if (listener.installed) return;78+  listener.installed = true;79+  chrome.storage?.onChanged?.addListener(() => requestMeshRelayReconcile());80+  requestMeshRelayReconcile();81+}82+83+export function requestMeshRelayReconcile(): void {84+  const listener = state();85+  if (listener.reconcileQueued) return;86+  listener.reconcileQueued = true;87+  queueMicrotask(() => {88+    listener.reconcileQueued = false;89+    void reconcile().catch(() => undefined);90+  });91+}92+93+async function reconcile(): Promise<void> {94+  const listener = state();95+  if (listener.timer !== null) clearTimeout(listener.timer);96+  listener.timer = setTimeout(requestMeshRelayReconcile, RECONCILE_INTERVAL_MS);97+98+  if (!isUnlocked()) {99+    abortAll(listener);100+    return;101+  }102+103+  const local = await getLocalPairingIdentity();104+  const desired = new Map<string, {105+    peer: PairedDevice;106+    relayUrl: string;107+    rendezvousId: string;108+  }>();109+  try {110+    const settings = await getSyncSettings();111+    const fallbackRelay = relayInForce(settings);112+    const nowSeconds = Date.now() / 1000;113+    for (const peer of await listPairedDevices()) {114+      if (115+        !isRouteCapableMeshPeer(peer) ||116+        weDialMeshPeer(117+          local.identity.longTermPublicKeyHex,118+          peer.longTermPublicKeyHex!,119+        )120+      ) {121+        continue;122+      }123+      const relayUrl = peer.relayUrl ?? fallbackRelay;124+      const secret = rendezvousSecretFromHex(peer.rendezvousSecretHex!);125+      for (const rendezvousId of waitingRendezvousIds(secret, nowSeconds)) {126+        desired.set(`${peer.deviceId}|${relayUrl}|${rendezvousId}`, {127+          peer,128+          relayUrl,129+          rendezvousId,130+        });131+      }132+    }133+  } finally {134+    local.privateKey.fill(0);135+    local.publicKey.fill(0);136+  }137+138+  for (const [key, handle] of listener.waiters) {139+    if (desired.has(key)) continue;140+    handle.abort.abort();141+    listener.waiters.delete(key);142+  }143+  for (const [key, target] of desired) {144+    if (listener.waiters.has(key)) continue;145+    const abort = new AbortController();146+    listener.waiters.set(key, { abort });147+    void holdWaiter(target, abort.signal).finally(() => {148+      if (listener.waiters.get(key)?.abort === abort) {149+        listener.waiters.delete(key);150+      }151+    });152+  }153+}154+155+async function holdWaiter(156+  target: { peer: PairedDevice; relayUrl: string; rendezvousId: string },157+  signal: AbortSignal,158+): Promise<void> {159+  let retryDelayMs = MESH_WAITER_RETRY_DELAY_MS;160+  while (!signal.aborted && isUnlocked()) {161+    const current = await findPairedDeviceById(target.peer.deviceId);162+    if (!current || !isRouteCapableMeshPeer(current)) return;163+164+    let session: Awaited<ReturnType<typeof openRelaySyncResponderSession>> | null = null;165+    let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;166+    let succeeded = false;167+    try {168+      local = await getLocalPairingIdentity();169+      const identity: SyncPeerIdentity = {170+        ...local.identity,171+        longTermPrivateKey: local.privateKey,172+      };173+      session = await openRelaySyncResponderSession(174+        target.relayUrl,175+        identity,176+        current.longTermPublicKeyHex!,177+        target.rendezvousId,178+        { signal },179+      );180+      const outcome = await runSyncResponder(181+        session,182+        createSyncVaultAdapter({ deviceName: current.deviceName }),183+      );184+      if (outcome.status === "completed") {185+        await markLastSyncedAt(current.deviceId, Date.now());186+        succeeded = true;187+      }188+      requestMeshRelayReconcile();189+    } catch {190+      if (signal.aborted) return;191+    } finally {192+      local?.privateKey.fill(0);193+      local?.publicKey.fill(0);194+      await session?.close().catch(() => undefined);195+    }196+    await waitForRetry(signal, retryDelayMs);197+    retryDelayMs = nextMeshWaiterRetryDelay(retryDelayMs, succeeded);198+  }199+}200+201+function waitForRetry(signal: AbortSignal, delayMs: number): Promise<void> {202+  if (signal.aborted) return Promise.resolve();203+  return new Promise((resolve) => {204+    const onAbort = () => {205+      clearTimeout(timer);206+      resolve();207+    };208+    const timer = setTimeout(() => {209+      signal.removeEventListener("abort", onAbort);210+      resolve();211+    }, delayMs);212+    signal.addEventListener("abort", onAbort, { once: true });213+  });214+}215+216+function abortAll(listener: MeshListenerState): void {217+  for (const handle of listener.waiters.values()) handle.abort.abort();218+  listener.waiters.clear();219+}

view file

src/background/sync-coordinator.test.ts+55 −1
@@ -1,5 +1,9 @@11 import { describe, expect, it, vi } from "vitest";2-import { runSyncInitiator, type SyncVaultAdapter } from "./sync-coordinator";2+import {3+  runSyncInitiator,4+  runSyncResponder,5+  type SyncVaultAdapter,6+} from "./sync-coordinator";37 import type { SyncMessage, SignedDeviceManifest } from "../shared/sync-types";48 import type { SyncSession } from "../shared/sync-transport";59 import { buildSignedDeviceManifest } from "../shared/device-manifest";@@ -53,6 +57,30 @@5357   return [ourSession, peer];5458 }5559 60+function symmetricSessionPair(): [SyncSession, SyncSession] {61+  const queues: [Uint8Array[], Uint8Array[]] = [[], []];62+  const readers: [63+    Array<(value: Uint8Array) => void>,64+    Array<(value: Uint8Array) => void>,65+  ] = [[], []];66+  const make = (side: 0 | 1): SyncSession => ({67+    send(bytes) {68+      const other = side === 0 ? 1 : 0;69+      const reader = readers[other].shift();70+      if (reader) reader(bytes);71+      else queues[other].push(bytes);72+    },73+    receive() {74+      const queued = queues[side].shift();75+      if (queued) return Promise.resolve(queued);76+      return new Promise<Uint8Array>((resolve) => readers[side].push(resolve));77+    },78+    async close() {},79+    remoteStaticPublicKey: new Uint8Array(32).fill(side === 0 ? 0x22 : 0x11),80+  });81+  return [make(0), make(1)];82+}83+5684 interface FakePeer {5785   send(msg: SyncMessage): Promise<void>;5886   receive(): Promise<SyncMessage>;@@ -174,6 +202,32 @@174202 });175203 176204 describe("runSyncInitiator", () => {205+  it("syncs two mesh peers directly with their introducer absent", async () => {206+    const [dialerSession, waiterSession] = symmetricSessionPair();207+    const dialer = makeVaultAdapter({208+      deviceId: "laptop-a",209+      localVersion: 10,210+      entries: { a: 10 },211+      ciphertext: { a: "cipher-a" },212+    });213+    const waiter = makeVaultAdapter({214+      deviceId: "laptop-c",215+      localVersion: 11,216+      entries: { c: 11 },217+      ciphertext: { c: "cipher-c" },218+    });219+220+    const [dialerOutcome, waiterOutcome] = await Promise.all([221+      runSyncInitiator(dialerSession, dialer),222+      runSyncResponder(waiterSession, waiter),223+    ]);224+225+    expect(dialerOutcome.status).toBe("completed");226+    expect(waiterOutcome.status).toBe("completed");227+    expect(dialer.appliedEntries).toEqual([{ c: "cipher-c" }]);228+    expect(waiter.appliedEntries).toEqual([{ a: "cipher-a" }]);229+  });230+177231   it("reports the privacy-safe protocol stage when a device stops answering", async () => {178232     vi.useFakeTimers();179233     try {

view file

src/background/sync-coordinator.ts+164 −4
@@ -90,10 +90,8 @@9090 }9191 9292 /**93- * Run one sync round as the initiator. The extension is always the94- * initiator (it is the WebSocket client). The peer's role is symmetric95- * after SYNC_HELLO, so a corresponding responder helper exists for the96- * mobile-side coordinator that calls this same protocol the other way.93+ * Run one sync round as the lower-key initiator. Device type does not decide94+ * the role; the higher-key peer runs [runSyncResponder].9795  */9896 export async function runSyncInitiator(9997   session: SyncSession,@@ -253,6 +251,168 @@253251     // Session lifetime is owned by the runner (sync-runner.ts), which254252     // closes it in its own finally block. The coordinator must not close it255253     // here — the runner still needs to record the outcome after we return.254+    timer.cancel();255+  }256+}257+258+/**259+ * Exact complement of [runSyncInitiator] for the higher-key peer holding a260+ * relay waiter room. Keeping this in the browser lets two extensions form a261+ * direct mesh edge; the device that introduced them is not in the data path.262+ */263+export async function runSyncResponder(264+  session: SyncSession,265+  vault: SyncVaultAdapter,266+): Promise<SyncOutcome> {267+  const result: SyncOutcome = {268+    status: "errored",269+    entriesReceived: 0,270+    entriesSent: 0,271+    tombstonesReceived: 0,272+    tombstonesSent: 0,273+  };274+  const timer = scheduleTimeout(SESSION_TIMEOUT_MS);275+  let stage: SyncStage = "waiting_for_device_hello";276+  try {277+    const ourVaultId = await vault.computeVaultId();278+    const ourDeviceId = await vault.getOurDeviceId();279+    const localVersion = await vault.getLocalVersion();280+    const localTime = Date.now();281+282+    const helloIn = await receiveMessage(session, timer);283+    if (helloIn.type !== "SYNC_HELLO") {284+      return errorOutcome(result, `expected SYNC_HELLO, got ${helloIn.type}`);285+    }286+    await sendMessage(session, {287+      type: "SYNC_HELLO",288+      vaultId: ourVaultId,289+      deviceId: ourDeviceId,290+      version: localVersion,291+      deviceTime: localTime,292+    });293+    if (helloIn.vaultId !== ourVaultId) {294+      result.status = "vault_mismatch";295+      return result;296+    }297+    if (Math.abs(localTime - (helloIn.deviceTime ?? 0)) > 120_000) {298+      console.warn(299+        `[sync] clock skew detected: local=${localTime} remote=${helloIn.deviceTime ?? "unknown"}`,300+      );301+    }302+303+    if (helloIn.version === localVersion) {304+      stage = "waiting_for_device_done";305+      const done = await receiveMessage(session, timer);306+      if (done.type !== "SYNC_DONE") {307+        return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);308+      }309+      await sendMessage(session, {310+        type: "SYNC_DONE",311+        entriesReceived: 0,312+        entriesSent: 0,313+      });314+      result.status = "completed";315+      return result;316+    }317+318+    const ourEntries = await vault.buildEntryManifest();319+    const ourTombstones = await vault.buildTombstoneManifest();320+    stage = "waiting_for_device_manifest";321+    const manifestIn = await receiveMessage(session, timer);322+    if (manifestIn.type !== "SYNC_MANIFEST") {323+      return errorOutcome(result, `expected SYNC_MANIFEST, got ${manifestIn.type}`);324+    }325+    await sendMessage(session, {326+      type: "SYNC_MANIFEST",327+      entries: ourEntries,328+      tombstones: ourTombstones,329+    });330+331+    stage = "waiting_for_device_request";332+    const requestIn = await receiveMessage(session, timer);333+    if (requestIn.type !== "SYNC_REQUEST") {334+      return errorOutcome(result, `expected SYNC_REQUEST, got ${requestIn.type}`);335+    }336+    const wantFromPeer = diffNewer(manifestIn.entries, ourEntries);337+    await sendMessage(session, { type: "SYNC_REQUEST", entryIds: wantFromPeer });338+339+    stage = "waiting_for_device_entries";340+    const entriesIn = await receiveMessage(session, timer);341+    if (entriesIn.type !== "SYNC_ENTRIES") {342+      return errorOutcome(result, `expected SYNC_ENTRIES, got ${entriesIn.type}`);343+    }344+    if (wantFromPeer.length > 0) {345+      await vault.applyEntries(entriesIn.entries);346+      result.entriesReceived = Object.keys(entriesIn.entries).length;347+    }348+    if (requestIn.entryIds.length > 0) {349+      const ourPayload = await vault.getEncryptedEntries(requestIn.entryIds);350+      await sendMessage(session, { type: "SYNC_ENTRIES", entries: ourPayload });351+      result.entriesSent = Object.keys(ourPayload).length;352+    }353+354+    stage = "waiting_for_device_tombstones";355+    const tombstonesIn = await receiveMessage(session, timer);356+    if (tombstonesIn.type !== "SYNC_TOMBSTONES") {357+      return errorOutcome(result, `expected SYNC_TOMBSTONES, got ${tombstonesIn.type}`);358+    }359+    await vault.applyTombstones(tombstonesIn.tombstones);360+    result.tombstonesReceived = Object.keys(tombstonesIn.tombstones).length;361+362+    const newerTombstonesForPeer: Record<string, number> = {};363+    for (const [id, timestamp] of Object.entries(ourTombstones)) {364+      if (timestamp > (manifestIn.tombstones[id] ?? 0)) {365+        newerTombstonesForPeer[id] = timestamp;366+      }367+    }368+    await sendMessage(session, {369+      type: "SYNC_TOMBSTONES",370+      tombstones: newerTombstonesForPeer,371+    });372+    result.tombstonesSent = Object.keys(newerTombstonesForPeer).length;373+374+    if (vault.buildOutgoingDeviceManifest && vault.applyIncomingDeviceManifest) {375+      stage = "waiting_for_device_graph";376+      const graphIn = await receiveMessage(session, timer);377+      if (graphIn.type !== "SYNC_DEVICE_MANIFEST") {378+        return errorOutcome(379+          result,380+          `expected SYNC_DEVICE_MANIFEST, got ${graphIn.type}`,381+        );382+      }383+      const ours = await vault.buildOutgoingDeviceManifest();384+      await sendMessage(session, {385+        type: "SYNC_DEVICE_MANIFEST",386+        manifest: ours,387+      });388+      if (graphIn.manifest) {389+        await vault.applyIncomingDeviceManifest(390+          graphIn.manifest,391+          session.remoteStaticPublicKey,392+        );393+      }394+    }395+396+    stage = "waiting_for_device_done";397+    const done = await receiveMessage(session, timer);398+    if (done.type !== "SYNC_DONE") {399+      return errorOutcome(result, `expected SYNC_DONE, got ${done.type}`);400+    }401+    await sendMessage(session, {402+      type: "SYNC_DONE",403+      entriesReceived: result.entriesReceived,404+      entriesSent: result.entriesSent,405+    });406+    result.status = "completed";407+    return result;408+  } catch (error) {409+    if ((error as Error).message === "sync_timeout") {410+      result.status = "timeout";411+      result.error = `sync_timeout:${stage}`;412+      return result;413+    }414+    return errorOutcome(result, (error as Error).message);415+  } finally {256416     timer.cancel();257417   }258418 }

view file

src/background/sync-runner.ts+3 −2
@@ -170,9 +170,10 @@170170       lastError = error;171171       // Each clock-skew window fails separately and only the last one survives172172       // into the thrown message; on a real device the earlier ones are often173-      // the informative ones.173+      // the informative ones. The URL and rendezvous capability are174+      // deliberately omitted because either may reveal private routing state.174175       console.info(175-        `[Peach sync] relay dial failed — relay=${relayUrl} rendezvous=${rendezvousId}: ${176+        `[Peach sync] relay dial failed — reason=${176177           error instanceof Error ? error.message : String(error)177178         }`,178179       );

view file

src/background/sync-static.test.ts+1 −1
@@ -72,7 +72,7 @@7272     expect(triggerSrc).toMatch(/trustSource === "vouched"/);7373   });7474 75-  it("the browser never listens — no pairing server or audio-chirp module survives", () => {75+  it("pairing stays relay-only with no local server or audio-chirp module", () => {7676     const appSrc = src("../popup/App.tsx");7777     expect(appSrc).not.toContain("openPairingServer");7878     expect(appSrc).not.toContain("chirp-pair-capacitor");

view file

src/background/sync-trigger.test.ts+21 −1
@@ -3,6 +3,7 @@33 import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices";44 import {55   triggerSyncIfReachable,6+  setSyncDialTargetSelector,67   setSyncRunner,78   _resetSyncTriggerForTest,89   getSyncState,@@ -181,7 +182,7 @@181182     expect(seen).toEqual(["VVV-vouched"]);182183   });183184 184-  it("never treats another extension as a sync host — it cannot listen", async () => {185+  it("keeps the legacy no-selector test fallback limited to native hosts", async () => {185186     let ran = false;186187     setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });187188     await upsertPairedDevice({@@ -192,6 +193,25 @@192193     });193194     await triggerSyncIfReachable({ reason: "foreground" });194195     expect(ran).toBe(false);196+  });197+198+  it("lets the production role selector choose an extension dial target", async () => {199+    const seen: string[] = [];200+    setSyncRunner(async (device) => {201+      seen.push(device.deviceId);202+      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };203+    });204+    setSyncDialTargetSelector(async (devices) =>205+      devices.filter((device) => device.deviceType === "extension")206+    );207+    await upsertPairedDevice({208+      ...EXT,209+      trustSource: "vouched",210+    });211+212+    await triggerSyncIfReachable({ reason: "foreground" });213+214+    expect(seen).toEqual(["CCC-ext"]);195215   });196216 197217   it("persists peach_last_sync_result to storage after a successful sync", async () => {

view file

src/background/sync-trigger.ts+26 −10
@@ -63,11 +63,15 @@6363   | { ok: false; error: string };6464 6565 export type RunSyncWithDevice = (device: PairedDevice) => Promise<SyncRunResult>;66+export type SelectSyncDialTargets = (67+  devices: PairedDevice[],68+) => Promise<PairedDevice[]>;6669 6770 interface PeachSyncTriggerState {6871   syncInFlight: boolean;6972   lastState: SyncState;7073   runner: RunSyncWithDevice | null;74+  selectDialTargets: SelectSyncDialTargets | null;7175 }7276 7377 function getSyncTriggerState(): PeachSyncTriggerState {@@ -79,6 +83,7 @@7983       syncInFlight: false,8084       lastState: { status: "idle" },8185       runner: null,86+      selectDialTargets: null,8287     };8388   }8489   return g.__peachSyncTriggerState;@@ -88,11 +93,18 @@8893   getSyncTriggerState().runner = fn;8994 }9095 96+export function setSyncDialTargetSelector(97+  fn: SelectSyncDialTargets | null,98+): void {99+  getSyncTriggerState().selectDialTargets = fn;100+}101+91102 export function _resetSyncTriggerForTest(): void {92103   const state = getSyncTriggerState();93104   state.syncInFlight = false;94105   state.lastState = { status: "idle" };95106   state.runner = null;107+  state.selectDialTargets = null;96108 }97109 98110 export function getSyncState(): SyncState {@@ -148,19 +160,23 @@148160 149161   try {150162     const devices = await listPairedDevices();151-    // Only native peers can accept a connection, so only they are sync hosts;152-    // another extension is reachable solely via a relay, which is the relaying153-    // device's job, not ours.154-    //155-    // "vouched" belongs here alongside "direct": a vouched device is one the156-    // user has already confirmed out of the pending queue (MV-7), and the whole157-    // point of mesh introduction is that it becomes reachable without its own158-    // pairing code. Excluding it left confirmed devices permanently unsynced.159-    const hosts = devices.filter(163+    const routeCapable = devices.filter(160164       (d) =>161-        (d.deviceType === "android" || d.deviceType === "ios") &&165+        (166+          d.deviceType === "extension" ||167+          d.deviceType === "android" ||168+          d.deviceType === "ios"169+        ) &&162170         (d.trustSource === "direct" || d.trustSource === "vouched"),163171     );172+    // Production resolves the symmetric key-order role here: lower static key173+    // dials, higher static key is kept online by mesh-relay-listener. Tests174+    // without the production selector retain the pre-mesh native-host default.175+    const hosts = state.selectDialTargets176+      ? await state.selectDialTargets(routeCapable)177+      : routeCapable.filter(178+          (d) => d.deviceType === "android" || d.deviceType === "ios",179+        );164180     if (devices.length === 0) {165181       finish({ outcome: "no_paired_devices" });166182       return;

view file

src/background/sync-vault-adapter.test.ts+34 −15
@@ -1,6 +1,7 @@11 import { beforeEach, describe, expect, it, vi } from "vitest";22 33 type StorageShape = Record<string, unknown>;4+const MESH_RELAY = "wss://relay.peachpasswords.com";45 56 function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {67   const area = (store: StorageShape) => ({@@ -305,6 +306,7 @@305306       longTermPublicKeyHex: "a".repeat(64),306307       signingPublicKeyHex: await signingPubkeyHex("android-1"),307308       wsPort: 49152,309+      relayUrl: MESH_RELAY,308310       pairedAt: 1_700_000_000_000,309311       trustSource: "direct",310312     });@@ -327,10 +329,11 @@327329       deviceType: "android",328330       longTermPublicKeyHex: "a".repeat(64),329331       wsPort: 49152,332+      relayUrl: MESH_RELAY,330333     });331334   });332335 333-  it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {336+  it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {334337     const { adapter } = await setupVault();335338     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");336339     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");@@ -374,6 +377,7 @@374377           longTermPublicKeyHex: "e".repeat(64),375378           signingPublicKeyHex: await signingPubkeyHex("android-new"),376379           wsPort: 51234,380+          relayUrl: MESH_RELAY,377381           pairedAt: 1_700_000_000_020,378382         },379383         {@@ -383,6 +387,7 @@383387           longTermPublicKeyHex: "f".repeat(64),384388           signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),385389           wsPort: 52345,390+          relayUrl: MESH_RELAY,386391           pairedAt: 1_700_000_000_030,387392         },388393       ],@@ -394,16 +399,14 @@394399       new Uint8Array(32).fill(0xcc),395400     );396401 397-    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending398-    // queue for explicit user confirmation.399-    expect(await findPairedDeviceById("android-new")).toBeNull();400-    const pending = await listPendingVouchedDevices();401-    expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);402-    expect(pending[0]).toMatchObject({402+    expect(await findPairedDeviceById("android-new")).toMatchObject({403403       deviceName: "New Pixel",404-      longTermPublicKeyHex: "e".repeat(64),405-      vouchedByDeviceId: "extension-issuer",404+      trustSource: "vouched",405+      relayUrl: MESH_RELAY,406+      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),406407     });408+    const pending = await listPendingVouchedDevices();409+    expect(pending).toEqual([]);407410 408411     // The already-paired device is still updated in place — but S5: the409412     // third-party issuer does NOT get to rename it. Keys and ports propagate;@@ -417,6 +420,7 @@417420     });418421     expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([419422       "android-existing",423+      "android-new",420424       "extension-issuer",421425     ]);422426   });@@ -720,6 +724,7 @@720724         deviceType: "android",721725         longTermPublicKeyHex: "2".repeat(64),722726         signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),727+        relayUrl: MESH_RELAY,723728         pairedAt: 1_700_000_000_010,724729       }],725730       mobileIssuerKp.privateKey,@@ -729,8 +734,12 @@729734       new Uint8Array(32).fill(0x11),730735     );731736 732-    expect(await findPairedDeviceById("mesh-peer")).toBeNull();733-    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);737+    expect(await findPairedDeviceById("mesh-peer")).toMatchObject({738+      trustSource: "vouched",739+      relayUrl: MESH_RELAY,740+      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),741+    });742+    expect(await listPendingVouchedDevices()).toEqual([]);734743   });735744 736745   it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {@@ -854,6 +863,7 @@854863         deviceType: "android",855864         longTermPublicKeyHex: "5".repeat(64),856865         signingPublicKeyHex: await signingPubkeyHex("smuggled"),866+        relayUrl: MESH_RELAY,857867         pairedAt: 3,858868       }],859869       betaKp.privateKey,@@ -866,7 +876,12 @@866876     // Same manifest, same devices, delivered over BETA's session: it must land.867877     // Without this control the assertion above could pass for any reason.868878     await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));869-    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["smuggled"]);879+    const { findPairedDeviceById } = await import("./paired-devices");880+    expect(await findPairedDeviceById("smuggled")).toMatchObject({881+      trustSource: "vouched",882+      relayUrl: MESH_RELAY,883+    });884+    expect(await listPendingVouchedDevices()).toEqual([]);870885   });871886 872887   it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {@@ -919,6 +934,7 @@919934         deviceType: "extension",920935         longTermPublicKeyHex: stolenKey,921936         signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),937+        relayUrl: MESH_RELAY,922938         pairedAt: Date.now(),923939       }],924940       issuerKp.privateKey,@@ -937,14 +953,17 @@937953         deviceType: "extension",938954         longTermPublicKeyHex: "9".repeat(64),939955         signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),956+        relayUrl: MESH_RELAY,940957         pairedAt: Date.now(),941958       }],942959       issuerKp.privateKey,943960     );944961     await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));945-    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual([946-      "stolen-laptop-mk2",947-    ]);962+    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({963+      trustSource: "vouched",964+      relayUrl: MESH_RELAY,965+    });966+    expect(await listPendingVouchedDevices()).toEqual([]);948967   });949968 950969   it("S1: a revoked key cannot be written onto a device we still trust", async () => {

view file

src/background/sync-vault-adapter.ts+155 −112
@@ -28,6 +28,7 @@2828 import { getOrCreateDeviceId } from "./device-identity";2929 import {3030   computeVaultId,31+  deriveSyncIdentityKeypair,3132   deriveSyncSigningKeypair,3233   hexToPubkey,3334   pubkeyToHex,@@ -57,9 +58,13 @@5758   upsertPairedDevice,5859 } from "./paired-devices";5960 import {60-  addPendingVouchedDevice,6161   removePendingVouchedDevice,6262 } from "./pending-vouched-devices";63+import {64+  bytesToHex as rendezvousBytesToHex,65+  deriveMeshRendezvousSecret,66+  normalizeRelayUrl,67+} from "../shared/rendezvous";6368 import { recordSyncAlerts } from "./sync-alerts";6469 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";6570 import type { VaultEntry } from "../shared/types";@@ -243,6 +248,7 @@243248     longTermPublicKeyHex: device.longTermPublicKeyHex,244249     signingPublicKeyHex: device.signingPublicKeyHex,245250     wsPort: device.wsPort,251+    relayUrl: device.relayUrl,246252     pairedAt: device.pairedAt,247253   };248254 }@@ -251,16 +257,13 @@251257  * Apply a device manifest issued by a peer we pair with directly.252258  *253259  * The issuer used to be required to be an "extension", on the assumption that254- * the extension was the mesh's trust anchor. It is not: the browser can never255- * listen for a connection, so the device it joins the mesh THROUGH is a native256- * peer (a phone), and that peer is the one holding the peer list to hand over.257- * Rejecting its manifest made mesh introduction impossible in the direction it258- * actually happens.260+ * the extension was the mesh's trust anchor. It is not: the device a fresh261+ * install joins through is the one holding the peer list to hand over.259262  *260263  * Trust is not widened by that change. The issuer must still be a DIRECTLY261- * paired device — a vouched peer cannot vouch onward — and the MV-7 gate below262- * still routes any device we have never seen into the pending queue for263- * explicit user confirmation rather than trusting it on the signature alone.264+ * paired device and a vouched peer cannot vouch onward. A v3 entry is admitted265+ * only when it also carries a valid relay route; both endpoints independently266+ * derive the same pair-only capability from their vault root and public keys.264267  */265268 async function applyVerifiedPeerManifest(266269   manifest: SignedDeviceManifest,@@ -294,127 +297,167 @@294297   if (body.issuerDeviceId !== issuer.deviceId) return;295298 296299   const ourDeviceId = await getOrCreateDeviceId();300+  const masterKey = requireMasterKey();301+  const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);297302 298-  // Revocations are absorbed BEFORE any vouching, so a manifest that both299-  // revokes and re-vouches the same device in one payload cannot race itself.300-  // A peer may not revoke US — that would let one compromised device sever the301-  // whole mesh — so our own id is filtered out first.302-  const newlyRevoked = await mergeDeviceRevocations(303-    body.revoked.filter((r) => r.deviceId !== ourDeviceId),304-  );305-  for (const deviceId of newlyRevoked) {306-    await removePairedDeviceById(deviceId);307-    await removePendingVouchedDevice(deviceId);308-    void recordSyncAlerts([309-      {310-        type: "DEVICE_REVOKED",311-        entryId: "",312-        entryTitle: deviceId,313-        deviceName: issuer.deviceName ?? "paired device",314-        timestamp: Date.now(),315-      },316-    ]);317-  }318-319-  for (const entry of body.devices) {320-    if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {321-      continue;303+  try {304+    // Revocations are absorbed BEFORE any vouching, so a manifest that both305+    // revokes and re-vouches the same device in one payload cannot race itself.306+    // A peer may not revoke US — that would let one compromised device sever the307+    // whole mesh — so our own id is filtered out first.308+    const newlyRevoked = await mergeDeviceRevocations(309+      body.revoked.filter((r) => r.deviceId !== ourDeviceId),310+    );311+    for (const deviceId of newlyRevoked) {312+      await removePairedDeviceById(deviceId);313+      await removePendingVouchedDevice(deviceId);314+      void recordSyncAlerts([315+        {316+          type: "DEVICE_REVOKED",317+          entryId: "",318+          entryTitle: deviceId,319+          deviceName: issuer.deviceName ?? "paired device",320+          timestamp: Date.now(),321+        },322+      ]);322323     }323-    // Terminal: no manifest re-admits a revoked device. Matched on the key as324-    // well as the id, because the id is the issuer's to choose — checking only325-    // the id let a revoked key walk back in under a fresh one. The check sits326-    // ahead of the existing-device lookup so it also refuses rotating a device327-    // we still trust onto a revoked key.328-    if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {329-      continue;330-    }331-    const existing = await findPairedDeviceById(entry.deviceId);332324 333-    // MV-7: a device we have NEVER seen does not become a sync target on334-    // the strength of the manifest signature alone. Queue it for explicit335-    // user confirmation; until then it cannot push entries or tombstones336-    // at us (it is absent from the paired-devices registry, so the runner337-    // never opens a session to it and inbound static keys don't match).338-    if (!existing) {339-      await addPendingVouchedDevice({325+    for (const entry of body.devices) {326+      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {327+        continue;328+      }329+      // Terminal: no manifest re-admits a revoked device. Matched on the key as330+      // well as the id, because the id is the issuer's to choose — checking only331+      // the id let a revoked key walk back in under a fresh one. The check sits332+      // ahead of the existing-device lookup so it also refuses rotating a device333+      // we still trust onto a revoked key.334+      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {335+        continue;336+      }337+      const existing = await findPairedDeviceById(entry.deviceId);338+      const meshRoute = deriveManifestMeshRoute(339+        masterKey,340+        localKeys.publicKey,341+        entry,342+      );343+344+      // A v3 manifest from a DIRECT, KK-authenticated issuer proves same-vault345+      // membership and carries enough route material for the two introduced346+      // endpoints to derive their own capability. Admit it immediately so a347+      // six-device vault converges to one graph instead of requiring ten manual348+      // confirmations on every fresh install.349+      if (!existing) {350+        if (!meshRoute) continue;351+        await upsertPairedDevice({352+          deviceId: entry.deviceId,353+          deviceName: entry.deviceName,354+          deviceType: entry.deviceType,355+          longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),356+          signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),357+          wsPort: entry.wsPort,358+          rendezvousSecretHex: meshRoute.rendezvousSecretHex,359+          relayUrl: meshRoute.relayUrl,360+          pairedAt: entry.pairedAt,361+          trustSource: "vouched",362+        });363+        await removePendingVouchedDevice(entry.deviceId);364+        continue;365+      }366+367+      // MV-8d: a key changing under a stable deviceId is legitimate after a368+      // reinstall, but it is exactly what a graph-poisoning attack looks like —369+      // signal the user either way, durably (the alert queue survives a closed370+      // popup) and live (the broadcast updates an open one).371+      if (372+        existing.longTermPublicKeyHex &&373+        !constantTimeEqualHex(374+          existing.longTermPublicKeyHex,375+          entry.longTermPublicKeyHex,376+        )377+      ) {378+        // S5: name the device from the LOCAL record, never from the manifest.379+        // The issuer is a third party here (its own entry is skipped above), so380+        // `entry.deviceName` is an unverified claim about someone else — an381+        // attacker who could put it on screen could make its own key rotation382+        // read as a rotation on a device the user trusts more.383+        const knownName = existing.deviceName ?? entry.deviceName;384+        notifyPopup({385+          type: "DEVICE_KEY_ROTATED",386+          payload: { deviceName: knownName, deviceId: entry.deviceId },387+        });388+        void recordSyncAlerts([389+          {390+            type: "DEVICE_KEY_ROTATED",391+            entryId: "",392+            entryTitle: knownName,393+            deviceName: knownName,394+            timestamp: Date.now(),395+          },396+        ]);397+      }398+399+      // Existing device — update in place as before. The name is deliberately400+      // NOT taken from the manifest: a device's name is set by that device at401+      // pairing, and letting a third-party issuer rewrite it is the rename-then-402+      // impersonate half of S5. Self-renames never arrive here anyway.403+      await upsertPairedDevice({404+        ...existing,340405         deviceId: entry.deviceId,341-        deviceName: entry.deviceName,406+        deviceName: existing.deviceName ?? entry.deviceName,342407         deviceType: entry.deviceType,343408         longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),344409         signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),345-        wsPort: entry.wsPort,346-        pairedAt: entry.pairedAt,347-        receivedAt: Date.now(),348-        vouchedByDeviceId: issuer.deviceId,410+        pairedAt: existing.pairedAt ?? entry.pairedAt,411+        trustSource: existing.trustSource ?? "direct",412+        wsPort: existing.wsPort ?? entry.wsPort,413+        rendezvousSecretHex:414+          existing.trustSource === "vouched" && meshRoute415+            ? meshRoute.rendezvousSecretHex416+            : existing.rendezvousSecretHex,417+        relayUrl:418+          existing.trustSource === "vouched" && meshRoute419+            ? meshRoute.relayUrl420+            : existing.relayUrl,421+        lastSyncedAt: existing.lastSyncedAt,349422       });350-      // No local record exists yet, so this name is the issuer's unverified351-      // claim and nothing better is available. It reaches a screen the user352-      // must explicitly confirm — treat it as untrusted display text there.353-      notifyPopup({354-        type: "VOUCHED_DEVICE_PENDING",355-        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },356-      });357-      continue; // not trusted yet358423     }424+  } finally {425+    masterKey.fill(0);426+    localKeys.secretKey.fill(0);427+  }428+}359429 360-    // MV-8d: a key changing under a stable deviceId is legitimate after a361-    // reinstall, but it is exactly what a graph-poisoning attack looks like —362-    // signal the user either way, durably (the alert queue survives a closed363-    // popup) and live (the broadcast updates an open one).364-    if (365-      existing.longTermPublicKeyHex &&366-      !constantTimeEqualHex(367-        existing.longTermPublicKeyHex,368-        entry.longTermPublicKeyHex,369-      )370-    ) {371-      // S5: name the device from the LOCAL record, never from the manifest.372-      // The issuer is a third party here (its own entry is skipped above), so373-      // `entry.deviceName` is an unverified claim about someone else — an374-      // attacker who could put it on screen could make its own key rotation375-      // read as a rotation on a device the user trusts more.376-      const knownName = existing.deviceName ?? entry.deviceName;377-      notifyPopup({378-        type: "DEVICE_KEY_ROTATED",379-        payload: { deviceName: knownName, deviceId: entry.deviceId },380-      });381-      void recordSyncAlerts([382-        {383-          type: "DEVICE_KEY_ROTATED",384-          entryId: "",385-          entryTitle: knownName,386-          deviceName: knownName,387-          timestamp: Date.now(),388-        },389-      ]);430+function deriveManifestMeshRoute(431+  masterKey: Uint8Array,432+  localPublicKey: Uint8Array,433+  entry: ManifestDeviceEntry,434+): { rendezvousSecretHex: string; relayUrl: string } | null {435+  try {436+    if (!entry.relayUrl) return null;437+    const relayUrl = normalizeRelayUrl(entry.relayUrl);438+    const secret = deriveMeshRendezvousSecret(439+      masterKey,440+      localPublicKey,441+      hexToPubkey(entry.longTermPublicKeyHex),442+    );443+    try {444+      return {445+        rendezvousSecretHex: rendezvousBytesToHex(secret),446+        relayUrl,447+      };448+    } finally {449+      secret.fill(0);390450     }391-392-    // Existing device — update in place as before. The name is deliberately393-    // NOT taken from the manifest: a device's name is set by that device at394-    // pairing, and letting a third-party issuer rewrite it is the rename-then-395-    // impersonate half of S5. Self-renames never arrive here anyway.396-    await upsertPairedDevice({397-      ...existing,398-      deviceId: entry.deviceId,399-      deviceName: existing.deviceName ?? entry.deviceName,400-      deviceType: entry.deviceType,401-      longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),402-      signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),403-      pairedAt: existing.pairedAt ?? entry.pairedAt,404-      trustSource: existing.trustSource ?? "direct",405-      wsPort: existing.wsPort ?? entry.wsPort,406-      lastSyncedAt: existing.lastSyncedAt,407-    });451+  } catch {452+    return null;408453   }409454 }410455 411-// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,412-// which is fine — the pending-vouched queue is the durable signal.413456 function notifyPopup(msg: { type: string; payload?: unknown }): void {414457   try {415458     void chrome.runtime.sendMessage(msg).catch(() => undefined);416459   } catch {417-    /* popup closed — pending queue is the durable signal */460+    /* popup closed — the durable sync-alert queue remains authoritative */418461   }419462 }420463 

view file

src/shared/device-manifest.test.ts+6 −3
@@ -23,6 +23,7 @@2323     deviceType: "android",2424     longTermPublicKeyHex: "a".repeat(64),2525     signingPublicKeyHex: "b".repeat(64),26+    relayUrl: "wss://relay.peachpasswords.com",2627     pairedAt: 1_700_000_000_000,2728   };2829 }@@ -134,7 +135,7 @@134135   });135136 });136137 137-describe("manifest v0x02 canonical form — cross-platform KAT", () => {138+describe("manifest v0x03 canonical form — cross-platform KAT", () => {138139   // Pinned byte-for-byte against peach-android's canonicalDeviceManifestBody.139140   // If either side reorders a key, renames a field, or changes number140141   // formatting, this string moves and the two stop verifying each other's@@ -142,15 +143,16 @@142143   const CANONICAL =143144     '{"devices":[{"deviceId":"peer-1","deviceName":"Peer One","deviceType":"android",' +144145     '"longTermPublicKeyHex":"' + "aa".repeat(32) + '","pairedAt":1700000000001,' +146+    '"relayUrl":"wss://relay.peachpasswords.com",' +145147     '"signingPublicKeyHex":"' + "bb".repeat(32) + '","wsPort":49142}],' +146-    '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":2,' +148+    '"issuedAt":1700000000000,"issuerDeviceId":"issuer-1","manifestVersion":3,' +147149     '"revoked":[{"deviceId":"gone-1","longTermPublicKeyHex":"' + "cc".repeat(32) + '",' +148150     '"revokedAt":1700000000002,"revokedByDeviceId":"issuer-1"}]}';149151 150152   it("canonicalises a manifest body carrying a revocation to the pinned string", () => {151153     expect(152154       canonicalise({153-        manifestVersion: 2,155+        manifestVersion: 3,154156         issuerDeviceId: "issuer-1",155157         issuedAt: 1_700_000_000_000,156158         devices: [@@ -161,6 +163,7 @@161163             longTermPublicKeyHex: "aa".repeat(32),162164             signingPublicKeyHex: "bb".repeat(32),163165             pairedAt: 1_700_000_000_001,166+            relayUrl: "wss://relay.peachpasswords.com",164167             wsPort: 49142,165168           },166169         ],

view file

src/shared/device-manifest.ts+13 −3
@@ -5,12 +5,13 @@55 // Manifests are exchanged after the entry sync completes during a normal66 // `SyncSession`, encrypted under reconnect Noise KK along with everything else.77 //8-// Purpose: propagate trust between devices without requiring additional audio9-// pairing sessions. Any DIRECTLY paired device may issue a manifest — the8+// Purpose: propagate trust between devices without repeating first-pair9+// confirmation. Any DIRECTLY paired device may issue a manifest — the1010 // browser cannot listen for connections, so the peer it joins the mesh through1111 // is a phone, and that phone is the one holding the peer list to hand over.1212 // The receiver verifies the signature against that issuer's known Ed2551913-// public key and queues any newly-vouched entries for user confirmation.13+// public key. A v3 entry that carries a valid relay route is auto-admitted as14+// same-vault "vouched" state; it still cannot vouch for another device.1415 // See docs/mesh-vouching-authority.md for the full authority trace.1516 //1617 // Security properties:@@ -31,6 +32,7 @@3132   type SignedDeviceManifest,3233 } from "./sync-types";3334 import { signEd25519, verifyEd25519 } from "./sync-identity";35+import { normalizeRelayUrl } from "./rendezvous";3436 3537 export class DeviceManifestError extends Error {3638   readonly kind:@@ -159,6 +161,13 @@159161     ) {160162       throw new DeviceManifestError("malformed", "device.wsPort invalid");161163     }164+    if (d.relayUrl !== undefined) {165+      try {166+        normalizeRelayUrl(d.relayUrl);167+      } catch {168+        throw new DeviceManifestError("malformed", "device.relayUrl invalid");169+      }170+    }162171   }163172 164173   if (!Array.isArray(manifest.body.revoked)) {@@ -209,6 +218,7 @@209218     pairedAt: d.pairedAt,210219   };211220   if (d.wsPort !== undefined) normalised.wsPort = d.wsPort;221+  if (d.relayUrl !== undefined) normalised.relayUrl = normalizeRelayUrl(d.relayUrl);212222   return normalised;213223 }214224 

view file

src/shared/mesh-peer-role.ts+59 −0
@@ -0,0 +1,59 @@1+import type { PairedDevice } from "./sync-types";2+3+export const MESH_WAITER_RETRY_DELAY_MS = 2_000;4+export const MESH_WAITER_MAX_RETRY_DELAY_MS = 30_000;5+6+export function isRouteCapableMeshPeer(device: PairedDevice): boolean {7+  const trusted =8+    device.trustSource === undefined ||9+    device.trustSource === "direct" ||10+    device.trustSource === "vouched";11+  return (12+    trusted &&13+    (device.deviceType === "extension" ||14+      device.deviceType === "android" ||15+      device.deviceType === "ios") &&16+    typeof device.longTermPublicKeyHex === "string" &&17+    /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex) &&18+    typeof device.rendezvousSecretHex === "string" &&19+    /^[0-9a-f]{64}$/.test(device.rendezvousSecretHex)20+  );21+}22+23+/** Lower public key dials; higher public key waits. */24+export function weDialMeshPeer(25+  ourLongTermPublicKeyHex: string,26+  peerLongTermPublicKeyHex: string,27+): boolean {28+  if (29+    !/^[0-9a-f]{64}$/.test(ourLongTermPublicKeyHex) ||30+    !/^[0-9a-f]{64}$/.test(peerLongTermPublicKeyHex) ||31+    ourLongTermPublicKeyHex === peerLongTermPublicKeyHex32+  ) {33+    return false;34+  }35+  return ourLongTermPublicKeyHex < peerLongTermPublicKeyHex;36+}37+38+export function selectDialTargets(39+  devices: PairedDevice[],40+  ourLongTermPublicKeyHex: string,41+): PairedDevice[] {42+  return devices.filter(43+    (device) =>44+      isRouteCapableMeshPeer(device) &&45+      weDialMeshPeer(46+        ourLongTermPublicKeyHex,47+        device.longTermPublicKeyHex!,48+      ),49+  );50+}51+52+export function nextMeshWaiterRetryDelay(53+  current: number,54+  succeeded: boolean,55+): number {56+  return succeeded57+    ? MESH_WAITER_RETRY_DELAY_MS58+    : Math.min(current * 2, MESH_WAITER_MAX_RETRY_DELAY_MS);59+}

view file

src/shared/rendezvous.test.ts+14 −0
@@ -4,6 +4,7 @@44   DEFAULT_RELAY_URL,55   buildRendezvousUrl,66   bytesToHex,7+  deriveMeshRendezvousSecret,78   deriveRendezvousId,89   deriveRendezvousSecret,910   dialingRendezvousIds,@@ -19,6 +20,19 @@1920 );2021 2122 describe("relay rendezvous contract", () => {23+  it("derives the symmetric cross-client mesh capability vector", () => {24+    const root = Uint8Array.from({ length: 32 }, (_, i) => i);25+    const low = Uint8Array.from({ length: 32 }, (_, i) => i + 32);26+    const high = Uint8Array.from({ length: 32 }, (_, i) => i + 64);27+    const forward = deriveMeshRendezvousSecret(root, low, high);28+    const reverse = deriveMeshRendezvousSecret(root, high, low);29+30+    expect(bytesToHex(forward)).toBe(31+      "ad0359be7dc8fbb7df19ed027354b5029133a23a6023f43c2b3500b43addae7b",32+    );33+    expect(reverse).toEqual(forward);34+  });35+2236   it("derives the cross-platform secret and hourly id KATs", () => {2337     const secret = deriveRendezvousSecret(HANDSHAKE_HASH);2438     expect(bytesToHex(secret)).toBe(

view file

src/shared/rendezvous.ts+43 −0
@@ -6,6 +6,7 @@66 export const PAIRING_RENDEZVOUS_TOKEN_BYTES = 16;77 88 const SECRET_INFO = new TextEncoder().encode("peach-rendezvous-secret-v1");9+const MESH_SECRET_INFO = new TextEncoder().encode("peach-mesh-rendezvous-v1");910 const WINDOW_INFO = new TextEncoder().encode("peach-rendezvous-v1");1011 const EMPTY_SALT = new Uint8Array(0);1112 @@ -18,6 +19,41 @@1819 }1920 2021 /**22+ * Derive the reconnect capability for a manifest-introduced device pair.23+ *24+ * Cross-client encoding contract:25+ *   low  = lexicographically smaller 32-byte X25519 public key26+ *   high = lexicographically larger 32-byte X25519 public key27+ *   info = UTF8("peach-mesh-rendezvous-v1") || low || high28+ *   secret = HKDF-SHA256(vaultRoot, salt="", info, 32)29+ */30+export function deriveMeshRendezvousSecret(31+  vaultRoot: Uint8Array,32+  ourPublicKey: Uint8Array,33+  peerPublicKey: Uint8Array,34+): Uint8Array {35+  if (vaultRoot.length !== 32) {36+    throw new Error("vault root must be exactly 32 bytes");37+  }38+  if (ourPublicKey.length !== 32 || peerPublicKey.length !== 32) {39+    throw new Error("mesh public keys must be exactly 32 bytes");40+  }41+  const order = compareBytes(ourPublicKey, peerPublicKey);42+  if (order === 0) {43+    throw new Error("mesh peers must have distinct public keys");44+  }45+  const [low, high] =46+    order < 047+      ? [ourPublicKey, peerPublicKey]48+      : [peerPublicKey, ourPublicKey];49+  const info = new Uint8Array(MESH_SECRET_INFO.length + 64);50+  info.set(MESH_SECRET_INFO, 0);51+  info.set(low, MESH_SECRET_INFO.length);52+  info.set(high, MESH_SECRET_INFO.length + 32);53+  return new HKDF(SHA256, vaultRoot, EMPTY_SALT, info).expand(32);54+}55+56+/**2157  * Derive one opaque hourly relay capability.2258  *2359  * Cross-client encoding contract:@@ -120,4 +156,11 @@120156   let binary = "";121157   for (const byte of bytes) binary += String.fromCharCode(byte);122158   return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");159+}160+161+function compareBytes(a: Uint8Array, b: Uint8Array): number {162+  for (let i = 0; i < a.length; i += 1) {163+    if (a[i] !== b[i]) return a[i]! < b[i]! ? -1 : 1;164+  }165+  return 0;123166 }

view file

src/shared/sync-kk-transport.test.ts+94 −1
@@ -3,6 +3,7 @@33 import { noiseKKResponder } from "./noise-js";44 import { padSyncFrame, unpadSyncFrame } from "./sync-padding";55 import {6+  openRelaySyncResponderSession,67   openRelaySyncSession,78   openSyncSession,89   type SyncPeerIdentity,@@ -106,6 +107,99 @@106107 afterEach(() => vi.unstubAllGlobals());107108 108109 describe("KK WebSocket reconnect transport", () => {110+  it("opens a browser waiter and completes a direct extension-to-extension KK session", async () => {111+    const initiatorKeys = generateKeyPair();112+    const responderKeys = generateKeyPair();113+    type MockSocket = {114+      peer?: MockSocket;115+      binaryType: string;116+      readyState: number;117+      onopen: ((event: Event) => void) | null;118+      onmessage: ((event: MessageEvent) => void) | null;119+      onerror: (() => void) | null;120+      onclose: ((event: CloseEvent) => void) | null;121+      send(data: Uint8Array | ArrayBuffer): void;122+      close(): void;123+    };124+    let waiting: MockSocket | undefined;125+    const WebSocketMock = vi.fn().mockImplementation(function() {126+      const socket: MockSocket = {127+        binaryType: "arraybuffer",128+        readyState: 1,129+        onopen: null,130+        onmessage: null,131+        onerror: null,132+        onclose: null,133+        send(data) {134+          const frame = data instanceof Uint8Array135+            ? data.slice()136+            : new Uint8Array(data.slice(0));137+          if (frame.byteLength === 0) return;138+          const target = socket.peer;139+          if (!target) return;140+          queueMicrotask(() => target.onmessage?.({141+            data: frame.buffer,142+          } as MessageEvent));143+        },144+        close() {145+          socket.readyState = 3;146+          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));147+        },148+      };149+      if (waiting) {150+        socket.peer = waiting;151+        waiting.peer = socket;152+        waiting = undefined;153+      } else {154+        waiting = socket;155+      }156+      queueMicrotask(() => socket.onopen?.(new Event("open")));157+      return socket;158+    });159+    Object.assign(WebSocketMock, { OPEN: 1 });160+    vi.stubGlobal("WebSocket", WebSocketMock);161+162+    const initiatorIdentity: SyncPeerIdentity = {163+      deviceId: "firefox-a",164+      deviceName: "Firefox A",165+      deviceType: "extension",166+      longTermPublicKeyHex: hex(initiatorKeys.publicKey),167+      longTermPrivateKey: initiatorKeys.secretKey,168+    };169+    const responderIdentity: SyncPeerIdentity = {170+      deviceId: "firefox-c",171+      deviceName: "Firefox C",172+      deviceType: "extension",173+      longTermPublicKeyHex: hex(responderKeys.publicKey),174+      longTermPrivateKey: responderKeys.secretKey,175+    };176+177+    const responderPromise = openRelaySyncResponderSession(178+      "wss://relay.example.test",179+      responderIdentity,180+      initiatorIdentity.longTermPublicKeyHex,181+      RENDEZVOUS_ID,182+      { keepaliveMs: 1_000_000 },183+    );184+    await new Promise((resolve) => setTimeout(resolve, 0));185+    const initiatorPromise = openRelaySyncSession(186+      "wss://relay.example.test",187+      initiatorIdentity,188+      responderIdentity.longTermPublicKeyHex,189+      RENDEZVOUS_ID,190+    );191+    const [initiatorSession, responderSession] = await Promise.all([192+      initiatorPromise,193+      responderPromise,194+    ]);195+196+    await initiatorSession.send(new TextEncoder().encode("direct mesh"));197+    await expect(responderSession.receive()).resolves.toEqual(198+      new TextEncoder().encode("direct mesh"),199+    );200+    await Promise.all([initiatorSession.close(), responderSession.close()]);201+  });202+109203   it("selects paired state then completes KK with both keys pinned", async () => {110204     const peer = installPeer();111205     const session = await openSyncSession(@@ -291,4 +385,3 @@291385     expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));292386   });293387 });294-

view file

src/shared/sync-transport.ts+185 −5
@@ -1,9 +1,11 @@11 // WebSocket reconnect transport — Noise KK with both paired static keys.22 // First pairing remains Noise NK; reconnect never negotiates or falls back.33 //4-// The extension is always the WebSocket client; Android (or future iOS /5-// native sync hosts) is always the server. Each binary WebSocket message6-// carries exactly one Noise frame — no length prefix, no fragmentation.4+// The lower long-term public key is the WebSocket/Noise initiator; the higher5+// key holds the relay room as responder. Device type does not affect the role,6+// so two extensions can sync directly without a native hub. Each binary7+// WebSocket message carries one Noise frame (large padded application frames8+// are split across multiple Noise messages below).79 //810 // On connect:911 //   1. Open ws://<ip>:<port>/@@ -21,7 +23,11 @@2123 // (~500ms per IP) and parallel batches of 16 to keep the worst-case scan2224 // under ~10 seconds.2325 24-import { noiseKKInitiator, type NoiseNKSession } from "./noise-js";26+import {27+  noiseKKInitiator,28+  noiseKKResponder,29+  type NoiseNKSession,30+} from "./noise-js";2531 import { SYNC_LIMITS } from "./sync-limits";2632 import {2733   padSyncFrame,@@ -56,6 +62,7 @@5662 const DEFAULT_PROBE_BATCH = 16;5763 const DEFAULT_CONNECT_TIMEOUT_MS = 4_000;5864 const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;65+export const RELAY_WAITER_KEEPALIVE_MS = 20_000;5966 /** v2 brief §5.2 — subnet probe is rate-limited to once per 60 seconds. */6067 export const SUBNET_PROBE_COOLDOWN_MS = 60_000;6168 @@ -98,6 +105,7 @@98105 export interface SyncSessionOptions {99106   connectTimeoutMs?: number;100107   handshakeTimeoutMs?: number;108+  signal?: AbortSignal;101109 }102110 103111 function encodePairIdentity(id: SyncPeerIdentity): Uint8Array {@@ -157,6 +165,153 @@157165   );158166 }159167 168+/**169+ * Hold one relay room as the deterministic responder, then run Noise KK and170+ * the bound PAIR_IDENTITY exchange. The zero-length liveness frame keeps a171+ * Chrome 116+ MV3 service worker alive while the room is empty; the Peach172+ * relay consumes that frame locally and never forwards it to the peer.173+ */174+export async function openRelaySyncResponderSession(175+  relayUrl: string,176+  ourIdentity: SyncPeerIdentity,177+  expectedRemotePubkeyHex: string,178+  rendezvousId: string,179+  options: SyncSessionOptions & { keepaliveMs?: number } = {},180+): Promise<SyncSession> {181+  if (!/^[A-Za-z0-9_-]{22,128}$/.test(rendezvousId)) {182+    throw new Error("reconnect requires a valid rotating rendezvous id");183+  }184+  if (ourIdentity.longTermPrivateKey.length !== 32) {185+    throw new Error("local reconnect private key must be exactly 32 bytes");186+  }187+  const ws = await connectWebSocketUrl(188+    buildRendezvousUrl(relayUrl, rendezvousId),189+    "relay",190+    options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,191+  );192+  const inbox: Uint8Array[] = [];193+  const readers: Array<(b: Uint8Array) => void> = [];194+  let closed = false;195+  let closeError: Error = new Error("WebSocket closed");196+  const keepaliveMs = options.keepaliveMs ?? RELAY_WAITER_KEEPALIVE_MS;197+  const abortWaiter = () => {198+    try { ws.close(1000, "relay waiter replaced"); } catch { /* ignore */ }199+  };200+  const keepalive = setInterval(() => {201+    if (ws.readyState !== WebSocket.OPEN) return;202+    try { ws.send(new Uint8Array(0)); } catch { /* onclose owns cleanup */ }203+  }, keepaliveMs);204+205+  ws.binaryType = "arraybuffer";206+  ws.onmessage = (event: MessageEvent) => {207+    if (!(event.data instanceof ArrayBuffer)) return;208+    if (event.data.byteLength === 0) return;209+    if (event.data.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {210+      closeError = new SyncFrameTooLargeError(211+        `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,212+      );213+      try { ws.close(1009, "sync frame too large"); } catch { /* ignore */ }214+      return;215+    }216+    const bytes = new Uint8Array(event.data);217+    const reader = readers.shift();218+    if (reader) reader(bytes);219+    else inbox.push(bytes);220+  };221+  ws.onerror = () => {222+    try { ws.close(); } catch { /* ignore */ }223+  };224+  ws.onclose = (event: CloseEvent) => {225+    clearInterval(keepalive);226+    options.signal?.removeEventListener("abort", abortWaiter);227+    closed = true;228+    if (event.code === 1009) closeError = new SyncFrameTooLargeError();229+    while (readers.length > 0) readers.shift()!(new Uint8Array(0));230+  };231+  // Install close handling before honoring an already-aborted signal so a232+  // synchronous/mock close cannot strand the first receive() forever.233+  options.signal?.addEventListener("abort", abortWaiter, { once: true });234+  if (options.signal?.aborted) abortWaiter();235+236+  const send = async (bytes: Uint8Array): Promise<void> => {237+    if (bytes.byteLength > SYNC_LIMITS.MAX_FRAME_BYTES) {238+      throw new SyncFrameTooLargeError(239+        `sync frame exceeds ${SYNC_LIMITS.MAX_FRAME_BYTES} bytes`,240+      );241+    }242+    ws.send(bytes as Uint8Array<ArrayBuffer>);243+  };244+  const receive = (): Promise<Uint8Array> => {245+    if (closed) return Promise.reject(closeError);246+    const buffered = inbox.shift();247+    if (buffered) return Promise.resolve(buffered);248+    return new Promise<Uint8Array>((resolve, reject) => {249+      readers.push((bytes) => {250+        if (bytes.length === 0 && closed) reject(closeError);251+        else resolve(bytes);252+      });253+    });254+  };255+256+  let noiseSession: NoiseNKSession | undefined;257+  try {258+    const selectorBytes = await receive();259+    const selector = JSON.parse(new TextDecoder().decode(selectorBytes));260+    if (261+      selector?.type !== "PEACH_RECONNECT" ||262+      typeof selector.rendezvous_id !== "string" ||263+      !equalAscii(selector.rendezvous_id, rendezvousId)264+    ) {265+      throw new Error("reconnect capability does not match relay room");266+    }267+    const handshakeTimer = setTimeout(() => {268+      closeError = new Error("sync_handshake_timeout");269+      try { ws.close(1000, "sync handshake timeout"); } catch { /* ignore */ }270+    }, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);271+    try {272+      const ourPublicKey = hexToPubkey(ourIdentity.longTermPublicKeyHex);273+      noiseSession = await noiseKKResponder(274+        ourIdentity.longTermPrivateKey,275+        ourPublicKey,276+        hexToPubkey(expectedRemotePubkeyHex),277+        send,278+        receive,279+      );280+      const theirFrame = JSON.parse(281+        new TextDecoder().decode(282+          unpadSyncFrame(noiseSession.decrypt(await receive())),283+        ),284+      );285+      if (theirFrame?.type !== "PAIR_IDENTITY") {286+        throw new Error(`expected PAIR_IDENTITY, got ${theirFrame?.type as string}`);287+      }288+      if (theirFrame.long_term_public_key_hex !== expectedRemotePubkeyHex) {289+        throw new Error("peer identity does not match authenticated KK key");290+      }291+      await send(noiseSession.encrypt(padSyncFrame(encodePairIdentity(ourIdentity))));292+    } finally {293+      clearTimeout(handshakeTimer);294+    }295+  } catch (error) {296+    clearInterval(keepalive);297+    options.signal?.removeEventListener("abort", abortWaiter);298+    noiseSession?.close();299+    try { ws.close(); } catch { /* ignore */ }300+    throw error;301+  }302+303+  return buildEncryptedSyncSession(304+    ws,305+    noiseSession,306+    expectedRemotePubkeyHex,307+    receive,308+    () => {309+      clearInterval(keepalive);310+      options.signal?.removeEventListener("abort", abortWaiter);311+    },312+  );313+}314+160315 async function openSyncSessionAtUrl(161316   url: string,162317   targetLabel: string,@@ -301,6 +456,21 @@301456     throw err;302457   }303458 459+  return buildEncryptedSyncSession(460+    ws,461+    session,462+    expectedRemotePubkeyHex,463+    receive,464+  );465+}466+467+function buildEncryptedSyncSession(468+  ws: WebSocket,469+  session: NoiseNKSession,470+  expectedRemotePubkeyHex: string,471+  receiveCiphertext: () => Promise<Uint8Array>,472+  beforeClose: () => void = () => undefined,473+): SyncSession {304474   return {305475     async send(plaintext: Uint8Array) {306476       // E7: bucket the size before encrypting, so the relay sees frame shapes@@ -348,7 +518,7 @@348518       // payload, so we read it there to learn the total.349519       let buffered: Uint8Array | null = null;350520       while (true) {351-        const ct = await receive();521+        const ct = await receiveCiphertext();352522         const pt = session.decrypt(ct);353523         if (buffered === null && pt.byteLength > 0 && pt[0] === FRAME_CONTINUATION_MARKER) {354524           // Strip the continuation byte and keep the payload.@@ -375,11 +545,21 @@375545       }376546     },377547     async close() {548+      beforeClose();378549       try { ws.close(); } catch { /* ignore */ }379550       session.close();380551     },381552     remoteStaticPublicKey: hexToPubkey(expectedRemotePubkeyHex),382553   };554+}555+556+function equalAscii(left: string, right: string): boolean {557+  const a = new TextEncoder().encode(left);558+  const b = new TextEncoder().encode(right);559+  if (a.length !== b.length) return false;560+  let diff = 0;561+  for (let i = 0; i < a.length; i += 1) diff |= a[i] ^ b[i];562+  return diff === 0;383563 }384564 385565 function connectWebSocketUrl(

view file

src/shared/sync-types.ts+9 −6
@@ -30,8 +30,9 @@3030  *3131  *   "direct"  — established via a pairing-code session with this device.3232  *   "vouched" — introduced by a directly-paired peer via a signed device33- *               manifest during a normal sync, then confirmed by the user34- *               (MV-7). Functionally equivalent to direct for sync purposes;33+ *               manifest during a normal sync. A valid v3 route is admitted34+ *               automatically as same-vault membership. It is equivalent to35+ *               direct for sync, but cannot issue manifests itself;3536  *               surfaced with an "Added automatically" badge in Settings.3637  */3738 export type TrustSource = "direct" | "vouched";@@ -126,10 +127,10 @@126127 // v3 device manifest — propagates trust between mobile devices (v2 brief §4.3)127128 // ---------------------------------------------------------------------------128129 129-/** 0x02 added the `revoked` array to the signed body. Coordinated wire change130- *  with peach-android — the canonical signing input differs, so a 0x01 peer and131- *  a 0x02 peer cannot verify each other's manifests. */132-export const DEVICE_MANIFEST_VERSION = 0x02 as const;130+/** 0x03 adds each peer edge's relay URL so a newly introduced pair can agree131+ *  on a route without depending on the introducing device. Coordinated wire132+ *  change with peach-android. */133+export const DEVICE_MANIFEST_VERSION = 0x03 as const;133134 /** Manifests older than this are silently rejected (v2 brief §4.3). */134135 export const DEVICE_MANIFEST_FRESHNESS_MS = 5 * 60_000;135136 @@ -152,6 +153,8 @@152153    * vouched entries lets mobile peers connect without another audio pairing.153154    */154155   wsPort?: number;156+  /** Relay the issuer currently uses for this peer edge. */157+  relayUrl?: string;155158   pairedAt: number;156159 }157160 

view file