f341f450afb1

add local-network vault sync — Noise NK transport, blob coordinator, mesh device propagation, subnet scan, Android bridge (v2.38.0)

time
2026-05-09 03:33 UTC · 5 months ago
machine
koh2
parent
68d9866adecf
15 files manifest.firefox.json+1 −1manifest.json+4 −1src/background/messages.ts+248 −14src/background/sync-coordinator.test.ts+265 −0src/background/sync-coordinator.ts+125 −0src/background/sync-storage.ts+15 −0src/background/vault.ts+122 −0src/popup/App.tsx+32 −0src/popup/styles/popup.css+24 −0src/popup/views/SyncPairing.tsx+11 −0src/shared/local-network.ts+108 −0src/shared/sync-types.ts+17 −10src/shared/sync-wire.ts+31 −0src/shared/types.ts+4 −0src/shared/websocket-sync-transport.ts+87 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.37.0",4+  "version": "2.38.0",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+4 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.37.0",4+  "version": "2.38.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",@@ -52,6 +52,9 @@5252     "idle",5353     "favicon",5454     "webAuthenticationProxy"55+  ],56+  "host_permissions": [57+    "<all_urls>"5558   ],5659   "content_security_policy": {5760     "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'"

view file

src/background/messages.ts+248 −14
@@ -48,6 +48,7 @@4848   removePairedDevice,4949   removeTrustedNetwork,5050   setIncomingPairingRequest,51+  updatePairedDevice,5152 } from "./sync-storage";5253 import { getCurrentNetwork, isOnTailscale } from "./sync-network";5354 import {@@ -62,8 +63,13 @@6263   VaultEntry,6364 } from "../shared/types";6465 import { isPasskeyOnly } from "../shared/types";65-import type { SyncStatus } from "../shared/sync-types";66+import type { SyncStatus, SyncableDevice } from "../shared/sync-types";6667 import { isNativePlatform, nativePlugin } from "../shared/native";68+import { runBlobSyncSession, type SyncResult } from "./sync-coordinator";69+import type { SyncDeps } from "./sync-coordinator";70+import { createWebSocketSyncTransport } from "../shared/websocket-sync-transport";71+import { getLocalSubnet, discoverDevice, scanForPairingPhone } from "../shared/local-network";72+import { base64ToBytes, bytesToBase64 } from "../shared/sync-wire";6773 6874 /**6975  * The single Pro gate. Every gated handler calls this first; the broadcast@@ -148,6 +154,98 @@148154 // same private-IP/HTTPS rules that gate canary webhooks apply here.149155 const FEEDBACK_ENDPOINT = "";150156 157+// ---------------------------------------------------------------------------158+// Sync helpers159+// ---------------------------------------------------------------------------160+161+function buildSyncDeps(): SyncDeps {162+  return {163+    isVaultUnlocked: () => vault.isUnlocked(),164+    getVaultBlobForSync: async () => {165+      const base = await vault.getVaultBlobForSync();166+      if (!base) return null;167+      const paired = await getPairedDevices();168+      return {169+        ...base,170+        devices: paired171+          .filter(d => d.x25519PublicKey)172+          .map(d => ({173+            publicKeyHex: d.x25519PublicKey,174+            deviceName: d.displayName,175+            pairedAt: d.pairedAt,176+          })),177+      };178+    },179+    applyIncomingVault: async (blob, ts, license, devices) => {180+      const applied = await vault.applyIncomingVault(blob, ts, license);181+      if (devices?.length) await mergeDeviceRegistry(devices);182+      if (applied) await vault.reloadEntriesAfterSync();183+      return applied;184+    },185+  };186+}187+188+async function doVaultSync(ip: string, publicKeyHex: string): Promise<SyncResult> {189+  try {190+    const transport = await createWebSocketSyncTransport(`ws://${ip}:44100`, publicKeyHex);191+    return await runBlobSyncSession('initiator', transport, buildSyncDeps());192+  } catch (err) {193+    return { status: 'errored', applied: false, error: String(err) };194+  }195+}196+197+async function mergeDeviceRegistry(incoming: SyncableDevice[]): Promise<void> {198+  const existing = await getPairedDevices();199+  const existingKeys = new Set(existing.map(d => d.x25519PublicKey));200+  for (const device of incoming) {201+    if (!existingKeys.has(device.publicKeyHex)) {202+      await addPairedDevice({203+        deviceId: device.publicKeyHex,204+        displayName: device.deviceName,205+        x25519PublicKey: device.publicKeyHex,206+        pairedAt: device.pairedAt,207+        lastSeenAt: 0,208+        lastKnownIp: '',209+      }).catch(() => {});210+    }211+  }212+}213+214+// Background pairing scan — runs after START_PAIRING_SERVER fires on desktop.215+// Scans the local subnet for a phone that has accepted the pairing token,216+// stores it, runs the initial vault exchange, then notifies the popup.217+async function runPairingSubnetScan(token: string): Promise<void> {218+  const subnet = await getLocalSubnet();219+  if (!subnet) return;220+221+  const found = await scanForPairingPhone(subnet, token);222+  if (!found) return;223+224+  const { ip, publicKeyHex } = found;225+226+  try {227+    await addPairedDevice({228+      deviceId: publicKeyHex,229+      displayName: 'Peach',230+      x25519PublicKey: publicKeyHex,231+      pairedAt: Date.now(),232+      lastSeenAt: Date.now(),233+      lastKnownIp: ip,234+    });235+  } catch {236+    // Already paired — update IP237+    await updatePairedDevice(publicKeyHex, { lastKnownIp: ip, lastSeenAt: Date.now() });238+  }239+240+  // Immediate vault exchange — the free one-time transfer241+  await doVaultSync(ip, publicKeyHex);242+243+  chrome.runtime.sendMessage({244+    type: 'PAIRING_COMPLETED',245+    payload: { deviceName: 'Peach', publicKeyHex, deviceIp: ip },246+  }).catch(() => {});247+}248+151249 export function setupMessageHandler(): void {152250   chrome.runtime.onMessage.addListener(153251     (msg: unknown, sender, sendResponse) => {@@ -185,10 +283,7 @@185283     });186284   }187285 188-  // Android: subscribe to SyncPlugin pair-probe events. When a remote device189-  // connects to the phone's WS server and validates the pairing token, auto-190-  // accept and broadcast PAIRING_COMPLETED so AddDeviceView shows the success191-  // screen and the paired device is persisted.286+  // Android: subscribe to SyncPlugin events.192287   if (isNativePlatform()) {193288     const syncPlug = nativePlugin<{194289       addListener: (@@ -196,17 +291,21 @@196291         handler: (data: Record<string, string>) => void197292       ) => void;198293     }>("SyncPlugin");294+295+    // Pairing probe answered — store the paired device (desktop, no IP needed296+    // since the desktop never acts as a WS server).199297     syncPlug?.addListener?.("peach:pair-probe-answered", (data) => {200-      const deviceId = data.connectionId ?? "unknown";201298       const deviceName = data.deviceName ?? "Peach";299+      const publicKeyHex = data.publicKeyHex ?? "";202300       void (async () => {203301         const { addPairedDevice, setHasPaired: setPaired } = await import("./sync-storage");204302         await addPairedDevice({205-          deviceId,303+          deviceId: publicKeyHex || (data.connectionId ?? "unknown"),206304           displayName: deviceName,207-          x25519PublicKey: data.publicKeyHex ?? "",305+          x25519PublicKey: publicKeyHex,208306           pairedAt: Date.now(),209-        });307+          lastSeenAt: Date.now(),308+        }).catch(() => {});210309         await setPaired();211310         chrome.runtime.sendMessage({212311           type: "PAIRING_COMPLETED",@@ -214,6 +313,20 @@214313         }).catch(() => {});215314       })();216315     });316+317+    // Sync frame received — route to the waiting responder session.318+    syncPlug?.addListener?.("peach:sync-frame", (data) => {319+      const { connectionId, frame } = data;320+      if (connectionId && frame) deliverAndroidSyncFrame(connectionId, frame);321+    });322+323+    // Sync session started (SYNC_HELLO received by SyncPlugin) — dispatch as324+    // a background message so SYNC_SESSION_STARTED handler runs the responder.325+    syncPlug?.addListener?.("peach:sync-session-started", (data) => {326+      const { connectionId } = data;327+      if (!connectionId) return;328+      void handleMessage({ type: "SYNC_SESSION_STARTED", payload: { connectionId } });329+    });217330   }218331 }219332 @@ -783,6 +896,11 @@783896         const token = generateSixDigitToken();784897         const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;785898         await setActivePairingToken({ token, expiresAt });899+        // Fire-and-forget subnet scan — finds the phone after it scans the QR900+        // and calls SCAN_PAIRING_QR to register the token with SyncPlugin.901+        if (!isNativePlatform()) {902+          void runPairingSubnetScan(token);903+        }786904         return { ok: true, data: { token, expiresAt } };787905       }788906       case "STOP_PAIRING_SERVER": {@@ -975,15 +1093,56 @@9751093         const gate = await requirePro("sync");9761094         if (!gate.allowed) return { ok: false, error: "pro_required" };9771095         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1096+9781097         if (isNativePlatform()) {1098+          // Phone is the server — ensure it's listening for desktop connections.9791099           syncBridge()?.ensureServerRunning?.();9801100           return { ok: true, data: { syncedCount: 0, mode: "server" as const } };9811101         }982-        // Desktop has no outbound sync path — paired devices are mobile, and983-        // the mobile side initiates. From desktop, "trigger sync" just means984-        // "make sure the pairing server is reachable", which is handled by985-        // START_PAIRING_SERVER. Surface no_paired_devices to keep callers tidy.986-        return { ok: false, error: "no_paired_devices" };1102+1103+        // Desktop: iterate all paired devices and sync with each.1104+        const paired = await getPairedDevices();1105+        if (!paired.length) return { ok: true, data: { syncedCount: 0, reason: "no_paired_devices" } };1106+1107+        const subnet = await getLocalSubnet();1108+        const results: Array<{ device: string } & SyncResult> = [];1109+1110+        for (const device of paired) {1111+          if (!device.x25519PublicKey) continue;1112+1113+          let result: SyncResult;1114+1115+          // Fast path — use last known IP1116+          if (device.lastKnownIp) {1117+            result = await doVaultSync(device.lastKnownIp, device.x25519PublicKey);1118+          } else {1119+            result = { status: 'errored', applied: false, error: 'no_ip' };1120+          }1121+1122+          // Discovery path — scan if fast path failed1123+          if (result.status === 'errored' && subnet) {1124+            const newIp = await discoverDevice(subnet, device.x25519PublicKey);1125+            if (newIp) {1126+              await updatePairedDevice(device.x25519PublicKey, { lastKnownIp: newIp });1127+              result = await doVaultSync(newIp, device.x25519PublicKey);1128+            }1129+          }1130+1131+          if (result.status === 'completed') {1132+            await updatePairedDevice(device.x25519PublicKey, {1133+              lastSeenAt: Date.now(),1134+              lastSyncAt: Date.now(),1135+            });1136+          }1137+1138+          results.push({ device: device.displayName, ...result });1139+        }1140+1141+        const syncedCount = results.filter(r => r.status === 'completed').length;1142+        if (syncedCount > 0) {1143+          await chrome.storage.local.set({ peach_last_sync_at: String(Date.now()) });1144+        }1145+        return { ok: true, data: { syncedCount, results } };9871146       }9881147       // Android-only: biometric unlock with pre-derived key.9891148       case "UNLOCK_WITH_KEY": {@@ -1018,6 +1177,23 @@10181177         syncBridge()?.startPairingSession?.({ token });10191178         return { ok: true };10201179       }1180+      // Android: desktop opened a sync connection to the phone's WS server.1181+      // Run the responder side of the vault exchange.1182+      case "SYNC_SESSION_STARTED": {1183+        if (!isNativePlatform()) return { ok: false };1184+        if (!vault.isUnlocked()) {1185+          return { ok: false, error: "vault_locked" };1186+        }1187+        const { connectionId } = (msg.payload ?? {}) as { connectionId?: string };1188+        if (!connectionId) return { ok: false, error: "missing_connection_id" };1189+1190+        const transport = createAndroidResponderTransport(connectionId);1191+        const result = await runBlobSyncSession('responder', transport, buildSyncDeps());1192+        if (result.applied) {1193+          await publishInMemoryVault(vault.getEntries());1194+        }1195+        return { ok: true, data: result };1196+      }10211197       // Android: returns the phone's WiFi IP address.10221198       case "GET_LOCAL_IP": {10231199         if (!isNativePlatform()) {@@ -1039,6 +1215,33 @@10391215         await publishInMemoryVault(entries);10401216         return { ok: true };10411217       }1218+      // --- PNA compatibility test handlers — REMOVE BEFORE RELEASE ---1219+      case "TEST_LOCAL_WEBSOCKET": {1220+        const { ip } = (msg.payload ?? {}) as { ip?: string };1221+        if (!ip) return { ok: false, error: "ip required" };1222+        return new Promise((resolve) => {1223+          try {1224+            const ws = new WebSocket(`ws://${ip}:44100`);1225+            ws.onopen = () => { ws.close(); resolve({ ok: true, data: { connected: true } }); };1226+            ws.onerror = () => resolve({ ok: false, error: "connection_failed" });1227+            setTimeout(() => { ws.close(); resolve({ ok: false, error: "timeout" }); }, 3000);1228+          } catch (err) {1229+            resolve({ ok: false, error: String(err) });1230+          }1231+        });1232+      }1233+      case "TEST_SUBNET_DETECTION": {1234+        const subnet = await getLocalSubnet();1235+        return { ok: true, data: { subnet } };1236+      }1237+      case "TEST_SUBNET_SCAN": {1238+        const subnet = await getLocalSubnet();1239+        if (!subnet) return { ok: false, error: "no_subnet" };1240+        const start = Date.now();1241+        const result = await discoverDevice(subnet, "000000000000000000000000000000000000000000000000000000000000000000");1242+        return { ok: true, data: { elapsed: Date.now() - start, found: !!result } };1243+      }1244+      // --- end test handlers ---10421245       default:10431246         return { ok: false, error: "unknown message type" };10441247     }@@ -1094,12 +1297,43 @@10941297   ensureServerRunning?: () => void;10951298   startPairingSession?: (args: { token: string }) => void;10961299   getLocalIp?: () => Promise<{ ip: string }>;1300+  sendToConnection?: (args: { connectionId: string; data: string }) => Promise<void>;1301+  addListener?: (event: string, handler: (data: Record<string, string>) => void) => void;10971302 }10981303 10991304 // Resolve the Capacitor SyncPlugin at call time — module-load resolution11001305 // races with Capacitor's plugin registration on the Android WebView.11011306 function syncBridge(): SyncBridge | null {11021307   return nativePlugin<SyncBridge>("SyncPlugin");1308+}1309+1310+// Build a SyncTransport for the Android responder path — the phone's WS server1311+// already has an open connection from the desktop. We send back via1312+// SyncPlugin.sendToConnection and receive via the peach:sync-frame queue.1313+const androidSyncFrameQueues = new Map<string, Array<(frame: string) => void>>();1314+1315+export function deliverAndroidSyncFrame(connectionId: string, frame: string): void {1316+  const queue = androidSyncFrameQueues.get(connectionId);1317+  if (queue?.length) queue.shift()!(frame);1318+}1319+1320+function createAndroidResponderTransport(connectionId: string) {1321+  const pendingReceivers: Array<(frame: string) => void> = [];1322+  androidSyncFrameQueues.set(connectionId, pendingReceivers);1323+1324+  return {1325+    send: async (data: Uint8Array) => {1326+      await syncBridge()?.sendToConnection?.({ connectionId, data: bytesToBase64(data) });1327+    },1328+    receive: (): Promise<Uint8Array> => new Promise((res) => {1329+      pendingReceivers.push((frame) => {1330+        res(frame ? base64ToBytes(frame) : new Uint8Array(0));1331+      });1332+    }),1333+    close: async () => {1334+      androidSyncFrameQueues.delete(connectionId);1335+    },1336+  };11031337 }11041338 11051339 // Returns username use-counts from the vault, sorted by frequency. Each row

view file

src/background/sync-coordinator.test.ts+265 −0
@@ -0,0 +1,265 @@1+import { describe, it, expect, vi, beforeEach } from 'vitest';2+import { runBlobSyncSession } from './sync-coordinator';3+import type { SyncDeps } from './sync-coordinator';4+import type { SyncTransport } from '../shared/websocket-sync-transport';5+import { encodeSyncMessage } from '../shared/sync-wire';6+import type { SyncableDevice } from '../shared/sync-types';7+8+// Builds a paired in-memory transport — one write on side A becomes a read on9+// side B and vice versa. Returns [transportA, transportB].10+function makePairedTransports(): [SyncTransport, SyncTransport] {11+  const queueAtoB: Uint8Array[] = [];12+  const queueBtoA: Uint8Array[] = [];13+  const waitersAtoB: ((d: Uint8Array) => void)[] = [];14+  const waitersBtoA: ((d: Uint8Array) => void)[] = [];15+16+  function push(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[], data: Uint8Array) {17+    if (waiters.length > 0) waiters.shift()!(data);18+    else queue.push(data);19+  }20+21+  function pop(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[]): Promise<Uint8Array> {22+    return new Promise(res => {23+      if (queue.length > 0) res(queue.shift()!);24+      else waiters.push(res);25+    });26+  }27+28+  const a: SyncTransport = {29+    send: async (d) => push(queueAtoB, waitersAtoB, d),30+    receive: () => pop(queueBtoA, waitersBtoA),31+    close: async () => {},32+  };33+  const b: SyncTransport = {34+    send: async (d) => push(queueBtoA, waitersBtoA, d),35+    receive: () => pop(queueAtoB, waitersAtoB),36+    close: async () => {},37+  };38+  return [a, b];39+}40+41+const DEVICE_LIST: SyncableDevice[] = [42+  { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },43+];44+45+function makeDeps(overrides: Partial<SyncDeps> = {}): SyncDeps {46+  return {47+    isVaultUnlocked: () => true,48+    getVaultBlobForSync: async () => ({49+      blob: JSON.stringify({ a: 'slotA', b: 'slotB' }),50+      timestamp: 1000,51+      devices: DEVICE_LIST,52+    }),53+    applyIncomingVault: async () => false,54+    ...overrides,55+  };56+}57+58+describe('runBlobSyncSession', () => {59+  it('initiator applies newer remote vault', async () => {60+    const apply = vi.fn().mockResolvedValue(true);61+    const [tInitiator, tResponder] = makePairedTransports();62+63+    // Responder side: sends a newer vault (timestamp 2000 > initiator's 1000)64+    const responderDeps = makeDeps({65+      getVaultBlobForSync: async () => ({66+        blob: JSON.stringify({ a: 'newer', b: null }),67+        timestamp: 2000,68+        devices: DEVICE_LIST,69+      }),70+      applyIncomingVault: async () => false,71+    });72+    const initiatorDeps = makeDeps({ applyIncomingVault: apply });73+74+    const [result] = await Promise.all([75+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),76+      runBlobSyncSession('responder', tResponder, responderDeps),77+    ]);78+79+    expect(result.status).toBe('completed');80+    expect(result.applied).toBe(true);81+    expect(apply).toHaveBeenCalledWith(82+      expect.stringContaining('newer'),83+      2000,84+      undefined,85+      DEVICE_LIST,86+    );87+  });88+89+  it('initiator keeps local vault when remote is older', async () => {90+    const apply = vi.fn().mockResolvedValue(false);91+    const [tInitiator, tResponder] = makePairedTransports();92+93+    const responderDeps = makeDeps({94+      getVaultBlobForSync: async () => ({95+        blob: JSON.stringify({ a: 'old' }),96+        timestamp: 500, // older97+        devices: DEVICE_LIST,98+      }),99+      applyIncomingVault: async () => false,100+    });101+    const initiatorDeps = makeDeps({ applyIncomingVault: apply });102+103+    const [result] = await Promise.all([104+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),105+      runBlobSyncSession('responder', tResponder, responderDeps),106+    ]);107+108+    expect(result.status).toBe('completed');109+    expect(result.applied).toBe(false);110+  });111+112+  it('both sides exchange vaults bidirectionally', async () => {113+    const applyI = vi.fn().mockResolvedValue(true);114+    const applyR = vi.fn().mockResolvedValue(true);115+    const [tInitiator, tResponder] = makePairedTransports();116+117+    const initiatorDeps = makeDeps({118+      getVaultBlobForSync: async () => ({119+        blob: JSON.stringify({ a: 'initiatorVault' }),120+        timestamp: 1000,121+        devices: DEVICE_LIST,122+      }),123+      applyIncomingVault: applyI,124+    });125+    const responderDeps = makeDeps({126+      getVaultBlobForSync: async () => ({127+        blob: JSON.stringify({ a: 'responderVault' }),128+        timestamp: 2000,129+        devices: DEVICE_LIST,130+      }),131+      applyIncomingVault: applyR,132+    });133+134+    await Promise.all([135+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),136+      runBlobSyncSession('responder', tResponder, responderDeps),137+    ]);138+139+    // Initiator received responder's vault (newer), responder received initiator's (older)140+    expect(applyI).toHaveBeenCalledWith(expect.stringContaining('responderVault'), 2000, undefined, DEVICE_LIST);141+    expect(applyR).toHaveBeenCalledWith(expect.stringContaining('initiatorVault'), 1000, undefined, DEVICE_LIST);142+  });143+144+  it('returns skipped when vault is locked', async () => {145+    const [t] = makePairedTransports();146+    const deps = makeDeps({ isVaultUnlocked: () => false });147+    const result = await runBlobSyncSession('initiator', t, deps);148+    expect(result.status).toBe('skipped');149+    expect(result.error).toBe('vault_locked');150+  });151+152+  it('transfers Pro license when receiver has none', async () => {153+    const apply = vi.fn().mockResolvedValue(true);154+    const [tInitiator, tResponder] = makePairedTransports();155+156+    const responderDeps = makeDeps({157+      getVaultBlobForSync: async () => ({158+        blob: JSON.stringify({ a: 'vault' }),159+        timestamp: 2000,160+        license: 'PRO-LICENSE-KEY',161+        devices: DEVICE_LIST,162+      }),163+      applyIncomingVault: async () => false,164+    });165+    const initiatorDeps = makeDeps({ applyIncomingVault: apply });166+167+    await Promise.all([168+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),169+      runBlobSyncSession('responder', tResponder, responderDeps),170+    ]);171+172+    expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, 'PRO-LICENSE-KEY', DEVICE_LIST);173+  });174+175+  it('does not override existing license when both sides have one', async () => {176+    // Both sides have a license — the responder's apply function receives177+    // the initiator's license but this is handled by vault.applyIncomingVault178+    // which skips overwriting. The coordinator just passes it through.179+    const apply = vi.fn().mockResolvedValue(false);180+    const [tInitiator, tResponder] = makePairedTransports();181+182+    const responderDeps = makeDeps({183+      getVaultBlobForSync: async () => ({184+        blob: JSON.stringify({ a: 'vault' }),185+        timestamp: 500,186+        license: 'RESPONDER-LICENSE',187+        devices: DEVICE_LIST,188+      }),189+      applyIncomingVault: async () => false,190+    });191+    const initiatorDeps = makeDeps({192+      getVaultBlobForSync: async () => ({193+        blob: JSON.stringify({ a: 'vault' }),194+        timestamp: 1000,195+        license: 'INITIATOR-LICENSE',196+        devices: DEVICE_LIST,197+      }),198+      applyIncomingVault: apply,199+    });200+201+    await Promise.all([202+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),203+      runBlobSyncSession('responder', tResponder, responderDeps),204+    ]);205+206+    // Initiator's apply receives 'RESPONDER-LICENSE' — vault.ts decides not to overwrite207+    expect(apply).toHaveBeenCalledWith(expect.any(String), 500, 'RESPONDER-LICENSE', DEVICE_LIST);208+  });209+});210+211+describe('mesh device registry propagation', () => {212+  it('passes remote device list to applyIncomingVault', async () => {213+    const apply = vi.fn().mockResolvedValue(true);214+    const [tInitiator, tResponder] = makePairedTransports();215+216+    const remoteDevices: SyncableDevice[] = [217+      { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },218+      { publicKeyHex: 'bbb', deviceName: 'MacBook', pairedAt: 2000 },219+      { publicKeyHex: 'ccc', deviceName: 'iPad', pairedAt: 3000 },220+    ];221+222+    const responderDeps = makeDeps({223+      getVaultBlobForSync: async () => ({224+        blob: JSON.stringify({ a: 'vault' }),225+        timestamp: 2000,226+        devices: remoteDevices,227+      }),228+      applyIncomingVault: async () => false,229+    });230+    const initiatorDeps = makeDeps({ applyIncomingVault: apply });231+232+    await Promise.all([233+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),234+      runBlobSyncSession('responder', tResponder, responderDeps),235+    ]);236+237+    expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, undefined, remoteDevices);238+  });239+240+  it('sends local device list in SYNC_VAULT_SEND', async () => {241+    const remoteApply = vi.fn().mockResolvedValue(false);242+    const [tInitiator, tResponder] = makePairedTransports();243+244+    const localDevices: SyncableDevice[] = [245+      { publicKeyHex: 'ddd', deviceName: 'Work Mac', pairedAt: 4000 },246+    ];247+248+    const initiatorDeps = makeDeps({249+      getVaultBlobForSync: async () => ({250+        blob: JSON.stringify({ a: 'vault' }),251+        timestamp: 500,252+        devices: localDevices,253+      }),254+    });255+    const responderDeps = makeDeps({ applyIncomingVault: remoteApply });256+257+    await Promise.all([258+      runBlobSyncSession('initiator', tInitiator, initiatorDeps),259+      runBlobSyncSession('responder', tResponder, responderDeps),260+    ]);261+262+    // Responder received the initiator's vault which included local devices263+    expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices);264+  });265+});

view file

src/background/sync-coordinator.ts+125 −0
@@ -0,0 +1,125 @@1+import type { SyncTransport } from '../shared/websocket-sync-transport';2+import type { SyncableDevice } from '../shared/sync-types';3+import { encodeSyncMessage, decodeSyncMessage } from '../shared/sync-wire';4+5+export interface SyncDeps {6+  getVaultBlobForSync: () => Promise<{7+    blob: string;8+    timestamp: number;9+    license?: string;10+    devices: SyncableDevice[];11+  } | null>;12+  applyIncomingVault: (13+    blob: string,14+    timestamp: number,15+    license?: string,16+    devices?: SyncableDevice[],17+  ) => Promise<boolean>;18+  isVaultUnlocked: () => boolean;19+}20+21+export interface SyncResult {22+  status: 'completed' | 'skipped' | 'errored';23+  applied: boolean;24+  error?: string;25+}26+27+// Full bidirectional vault sync session — last-write-wins on timestamp.28+// Initiator sends SYNC_HELLO, responder sends its vault first, then initiator29+// sends its vault. Both sides apply if the remote is newer.30+export async function runBlobSyncSession(31+  role: 'initiator' | 'responder',32+  transport: SyncTransport,33+  deps: SyncDeps,34+): Promise<SyncResult> {35+  if (!deps.isVaultUnlocked()) {36+    return { status: 'skipped', applied: false, error: 'vault_locked' };37+  }38+39+  try {40+    if (role === 'initiator') {41+      await transport.send(encodeSyncMessage({ type: 'SYNC_HELLO' }));42+43+      // Receive remote vault first44+      const remoteMsg = decodeSyncMessage(await transport.receive());45+      let applied = false;46+47+      if (remoteMsg.type === 'SYNC_VAULT_SEND') {48+        applied = await deps.applyIncomingVault(49+          remoteMsg.blob,50+          remoteMsg.timestamp,51+          remoteMsg.license,52+          remoteMsg.devices,53+        );54+        await transport.send(encodeSyncMessage({ type: 'SYNC_VAULT_ACK', applied }));55+      } else if (remoteMsg.type !== 'SYNC_DONE') {56+        throw new Error(`unexpected message: ${remoteMsg.type}`);57+      }58+59+      // Send our vault60+      const local = await deps.getVaultBlobForSync();61+      if (local) {62+        await transport.send(encodeSyncMessage({63+          type: 'SYNC_VAULT_SEND',64+          blob: local.blob,65+          timestamp: local.timestamp,66+          license: local.license,67+          devices: local.devices,68+        }));69+        const ack = decodeSyncMessage(await transport.receive());70+        if (ack.type !== 'SYNC_VAULT_ACK') throw new Error(`expected ACK, got ${ack.type}`);71+      }72+73+      await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));74+      await transport.receive(); // wait for remote SYNC_DONE75+      await transport.close();76+      return { status: 'completed', applied };77+78+    } else {79+      // Responder: wait for SYNC_HELLO80+      const hello = decodeSyncMessage(await transport.receive());81+      if (hello.type !== 'SYNC_HELLO') throw new Error(`expected SYNC_HELLO, got ${hello.type}`);82+83+      // Send our vault first84+      const local = await deps.getVaultBlobForSync();85+      if (local) {86+        await transport.send(encodeSyncMessage({87+          type: 'SYNC_VAULT_SEND',88+          blob: local.blob,89+          timestamp: local.timestamp,90+          license: local.license,91+          devices: local.devices,92+        }));93+        const ack = decodeSyncMessage(await transport.receive());94+        if (ack.type !== 'SYNC_VAULT_ACK') throw new Error(`expected ACK, got ${ack.type}`);95+      } else {96+        await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));97+      }98+99+      // Receive initiator's vault100+      const remoteMsg = decodeSyncMessage(await transport.receive());101+      let applied = false;102+      if (remoteMsg.type === 'SYNC_VAULT_SEND') {103+        applied = await deps.applyIncomingVault(104+          remoteMsg.blob,105+          remoteMsg.timestamp,106+          remoteMsg.license,107+          remoteMsg.devices,108+        );109+        await transport.send(encodeSyncMessage({ type: 'SYNC_VAULT_ACK', applied }));110+      }111+112+      // Wait for SYNC_DONE then echo it113+      const done = decodeSyncMessage(await transport.receive());114+      if (done.type === 'SYNC_DONE') {115+        await transport.send(encodeSyncMessage({ type: 'SYNC_DONE' }));116+      }117+118+      await transport.close();119+      return { status: 'completed', applied };120+    }121+  } catch (err) {122+    await transport.close().catch(() => {});123+    return { status: 'errored', applied: false, error: String(err) };124+  }125+}

view file

src/background/sync-storage.ts+15 −0
@@ -93,6 +93,21 @@9393   }9494 }9595 96+export async function updatePairedDevice(97+  x25519PublicKey: string,98+  patch: Partial<Pick<PairedDevice, 'lastSeenAt' | 'lastSyncAt' | 'lastKnownIp'>>,99+): Promise<void> {100+  try {101+    const devices = await getPairedDevices();102+    const updated = devices.map((d) =>103+      d.x25519PublicKey === x25519PublicKey ? { ...d, ...patch } : d,104+    );105+    await chrome.storage.local.set({ [PAIRED_DEVICES_KEY]: updated });106+  } catch (err) {107+    throw new Error(`Failed to update paired device: ${err}`);108+  }109+}110+96111 // ---------------------------------------------------------------------------97112 // Incoming Pairing Request98113 //

view file

src/background/vault.ts+122 −0
@@ -1956,3 +1956,125 @@19561956 19571957   return true;19581958 }1959+1960+// ---------------------------------------------------------------------------1961+// Sync helpers1962+// ---------------------------------------------------------------------------1963+1964+export async function getVaultBlobForSync(): Promise<{1965+  blob: string;1966+  timestamp: number;1967+  license?: string;1968+} | null> {1969+  const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);1970+  const slotA = (raw as Record<string, unknown>)[SLOT_A];1971+  if (!slotA) return null;1972+  const slotB = (raw as Record<string, unknown>)[SLOT_B];1973+  const blob = JSON.stringify({ a: slotA, b: slotB });1974+  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];1975+  const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;1976+  const license = (raw as Record<string, unknown>)['peach_license'];1977+  return {1978+    blob,1979+    timestamp,1980+    license: typeof license === 'string' ? license : undefined,1981+  };1982+}1983+1984+export async function applyIncomingVault(1985+  blobJson: string,1986+  remoteTimestamp: number,1987+  remoteLicense?: string,1988+): Promise<boolean> {1989+  const raw = await chrome.storage.local.get('peach_last_modified_at');1990+  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];1991+  const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;1992+1993+  if (remoteLicense) {1994+    const licenseRaw = await chrome.storage.local.get('peach_license');1995+    const existing = (licenseRaw as Record<string, unknown>)['peach_license'];1996+    if (!existing) {1997+      await chrome.storage.local.set({ peach_license: remoteLicense });1998+    }1999+  }2000+2001+  if (remoteTimestamp <= localTimestamp) return false;2002+2003+  // Snapshot before overwriting — failure is non-fatal2004+  await takeVaultSnapshot('sync_received').catch(() => {});2005+2006+  const { a, b } = JSON.parse(blobJson) as { a: unknown; b?: unknown };2007+  await writeSlot(SLOT_A, a as StoredVault);2008+  if (b) await writeSlot(SLOT_B, b as StoredVault);2009+  await chrome.storage.local.set({2010+    peach_last_sync_at: String(Date.now()),2011+    peach_last_modified_at: String(remoteTimestamp),2012+  });2013+2014+  return true;2015+}2016+2017+export async function getSyncIdentity(): Promise<{2018+  privateKeyBytes: Uint8Array;2019+  publicKeyBytes: Uint8Array;2020+  publicKeyHex: string;2021+}> {2022+  const raw = await chrome.storage.local.get('peach_sync_identity');2023+  const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2024+  if (typeof stored === 'string') {2025+    const { priv, pub } = JSON.parse(stored) as { priv: string; pub: string };2026+    const privateKeyBytes = base64DecodeBytes(priv);2027+    const publicKeyBytes = base64DecodeBytes(pub);2028+    return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2029+  }2030+2031+  // Generate a fresh X25519 keypair and persist it.2032+  const { generateKeyPair } = await import('@stablelib/x25519');2033+  const keypair = generateKeyPair();2034+  await chrome.storage.local.set({2035+    peach_sync_identity: JSON.stringify({2036+      priv: base64EncodeBytes(keypair.secretKey),2037+      pub: base64EncodeBytes(keypair.publicKey),2038+    }),2039+  });2040+  return {2041+    privateKeyBytes: keypair.secretKey,2042+    publicKeyBytes: keypair.publicKey,2043+    publicKeyHex: bytesToHex(keypair.publicKey),2044+  };2045+}2046+2047+// Re-decrypt vault entries from storage using the current in-memory key.2048+// Called after applyIncomingVault so the in-memory state stays consistent2049+// with what was just written to storage.2050+export async function reloadEntriesAfterSync(): Promise<boolean> {2051+  if (!derivedKey) return false;2052+  const slotA = await loadSlot(SLOT_A);2053+  if (!slotA) return false;2054+  try {2055+    const payload = await tryDecryptSlot(slotA, derivedKey);2056+    if (!payload) return false;2057+    entries = payload.entries;2058+    buildDomainIndexSync(entries);2059+    return true;2060+  } catch {2061+    return false;2062+  }2063+}2064+2065+function bytesToHex(bytes: Uint8Array): string {2066+  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2067+}2068+2069+function base64EncodeBytes(bytes: Uint8Array): string {2070+  let binary = '';2071+  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2072+  return btoa(binary);2073+}2074+2075+function base64DecodeBytes(b64: string): Uint8Array {2076+  const binary = atob(b64);2077+  const bytes = new Uint8Array(binary.length);2078+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2079+  return bytes;2080+}

view file

src/popup/App.tsx+32 −0
@@ -449,6 +449,23 @@449449     // backgrounds the app. We measure elapsed background time on resume.450450     if (isNativePlatform()) {451451       let backgroundedAt: number | null = null;452+      let syncPollInterval: ReturnType<typeof setInterval> | null = null;453+454+      function startSyncPolling() {455+        if (syncPollInterval) return;456+        syncPollInterval = setInterval(async () => {457+          const res = await sendMessage({ type: 'TRIGGER_SYNC' }).catch(() => null);458+          const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;459+          if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {460+            await sendMessage({ type: 'GET_ENTRIES' }).catch(() => null);461+          }462+        }, 60_000);463+      }464+465+      function stopSyncPolling() {466+        if (syncPollInterval) { clearInterval(syncPollInterval); syncPollInterval = null; }467+      }468+452469       const CapApp = nativePlugin<{453470         addListener: (454471           ev: string,@@ -461,6 +478,7 @@461478           ({ isActive }: { isActive: boolean }) => {462479             if (!isActive) {463480               backgroundedAt = Date.now();481+              stopSyncPolling();464482               return;465483             }466484             if (backgroundedAt !== null && autoLockMinutes() > 0) {@@ -468,11 +486,14 @@468486               if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();469487             }470488             backgroundedAt = null;489+            // Resume polling when app comes to foreground (Pro only).490+            if (proStatus()) startSyncPolling();471491           }472492         );473493         // Capacitor 6 listeners return a Promise<{ remove: () => Promise<void> }>.474494         onCleanup(() => {475495           Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});496+          stopSyncPolling();476497         });477498       }478499     }@@ -880,6 +901,17 @@880901   }881902 882903   async function openVault(preferredEntryId?: string, skipViewSet = false) {904+    // Desktop: fire-and-forget sync on every vault open (Pro only).905+    // Never blocks popup load; refreshes entries if new data arrives.906+    if (!isNativePlatform() && proStatus()) {907+      void sendMessage({ type: 'TRIGGER_SYNC' }).then((res) => {908+        const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;909+        if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {910+          void loadEntries();911+        }912+      }).catch(() => {});913+    }914+883915     // Purge expired trash entries on each vault open884916     const retDays = trashRetentionDays();885917     if (retDays > 0) {

view file

src/popup/styles/popup.css+24 −0
@@ -7743,6 +7743,30 @@77437743   white-space: nowrap;77447744 }77457745 7746+.peach-sync-hub-note {7747+  padding: 14px 16px;7748+  border: 1px solid var(--peach-border);7749+  border-radius: 10px;7750+  display: flex;7751+  flex-direction: column;7752+  gap: 6px;7753+  background: rgba(var(--peach-accent-rgb, 201, 137, 58), 0.04);7754+}7755+7756+.peach-sync-hub-note-title {7757+  font-size: 13px;7758+  font-weight: 600;7759+  color: var(--peach-t0);7760+  margin: 0;7761+}7762+7763+.peach-sync-hub-note-body {7764+  font-size: 12px;7765+  color: var(--peach-t2);7766+  line-height: 1.6;7767+  margin: 0;7768+}7769+77467770 /* Action buttons */77477771 .peach-sync-actions {77487772   display: flex;

view file

src/popup/views/SyncPairing.tsx+11 −0
@@ -187,6 +187,17 @@187187             </div>188188           </Show>189189 190+          <Show when={pairedDevices().length >= 2}>191+            <div class="peach-sync-hub-note">192+              <p class="peach-sync-hub-note-title">Your phone keeps everything in sync</p>193+              <p class="peach-sync-hub-note-body">194+                Peach has no servers — your phone acts as the hub between your other devices.195+                Desktop-to-desktop sync happens automatically through your phone when it's on196+                the same network.197+              </p>198+            </div>199+          </Show>200+190201           {/* Action buttons */}191202           <div class="peach-sync-actions">192203             <button

view file

src/shared/local-network.ts+108 −0
@@ -0,0 +1,108 @@1+const SYNC_PORT = 44100;2+const PROBE_TIMEOUT_MS = 400;3+4+// Detect the local subnet via WebRTC ICE candidate parsing.5+// Returns e.g. "192.168.1" or null if unavailable.6+export async function getLocalSubnet(): Promise<string | null> {7+  return new Promise((resolve) => {8+    try {9+      const pc = new RTCPeerConnection({ iceServers: [] });10+      pc.createDataChannel('');11+      pc.onicecandidate = (e) => {12+        if (!e.candidate) { pc.close(); resolve(null); return; }13+        const m = e.candidate.candidate.match(/(\d+\.\d+\.\d+)\.\d+/);14+        if (m) { pc.close(); resolve(m[1]); }15+      };16+      pc.createOffer().then(o => pc.setLocalDescription(o)).catch(() => { pc.close(); resolve(null); });17+      setTimeout(() => { try { pc.close(); } catch { /* ignore */ } resolve(null); }, 2000);18+    } catch {19+      resolve(null);20+    }21+  });22+}23+24+// Probe a single IP to see if it responds to PEACH_DISCOVER_PROBE with our25+// publicKeyHex. Returns true if it's our paired device.26+async function probeDiscover(ip: string, publicKeyHex: string): Promise<boolean> {27+  return new Promise((resolve) => {28+    try {29+      const ws = new WebSocket(`ws://${ip}:${SYNC_PORT}`);30+      const timer = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } resolve(false); }, PROBE_TIMEOUT_MS);31+      ws.onopen = () => ws.send(JSON.stringify({ type: 'PEACH_DISCOVER_PROBE', publicKeyHex }));32+      ws.onmessage = (e) => {33+        clearTimeout(timer);34+        try {35+          const msg = JSON.parse(e.data as string);36+          resolve(msg?.type === 'PEACH_DISCOVER_ACK' && msg?.publicKeyHex === publicKeyHex);37+        } catch { resolve(false); }38+        ws.close();39+      };40+      ws.onerror = () => { clearTimeout(timer); resolve(false); };41+      ws.onclose = () => { clearTimeout(timer); };42+    } catch {43+      resolve(false);44+    }45+  });46+}47+48+// Scan the subnet for a device with the given publicKeyHex.49+// Returns the IP if found, null otherwise.50+export async function discoverDevice(subnet: string, publicKeyHex: string): Promise<string | null> {51+  const batch = 16;52+  for (let start = 1; start <= 254; start += batch) {53+    const end = Math.min(start + batch - 1, 254);54+    const ips = Array.from({ length: end - start + 1 }, (_, i) => `${subnet}.${start + i}`);55+    const results = await Promise.all(ips.map(async (ip) => {56+      const found = await probeDiscover(ip, publicKeyHex);57+      return found ? ip : null;58+    }));59+    const hit = results.find(r => r !== null);60+    if (hit) return hit;61+  }62+  return null;63+}64+65+// Probe a single IP to see if it's a phone in pairing mode with the given token.66+// Returns { ip, publicKeyHex } if found, null otherwise.67+async function probePair(ip: string, token: string): Promise<{ ip: string; publicKeyHex: string } | null> {68+  return new Promise((resolve) => {69+    try {70+      const ws = new WebSocket(`ws://${ip}:${SYNC_PORT}`);71+      const timer = setTimeout(() => { try { ws.close(); } catch { /* ignore */ } resolve(null); }, PROBE_TIMEOUT_MS);72+      ws.onopen = () => ws.send(JSON.stringify({ type: 'PEACH_PAIR_PROBE', token }));73+      ws.onmessage = (e) => {74+        clearTimeout(timer);75+        try {76+          const msg = JSON.parse(e.data as string);77+          if (msg?.type === 'PEACH_PAIR_ACK' && typeof msg?.publicKeyHex === 'string') {78+            resolve({ ip, publicKeyHex: msg.publicKeyHex as string });79+          } else {80+            resolve(null);81+          }82+        } catch { resolve(null); }83+        ws.close();84+      };85+      ws.onerror = () => { clearTimeout(timer); resolve(null); };86+      ws.onclose = () => { clearTimeout(timer); };87+    } catch {88+      resolve(null);89+    }90+  });91+}92+93+// Scan the subnet for a phone in pairing mode with the given token.94+// Returns { ip, publicKeyHex } of the first phone that accepts the token.95+export async function scanForPairingPhone(96+  subnet: string,97+  token: string,98+): Promise<{ ip: string; publicKeyHex: string } | null> {99+  const batch = 16;100+  for (let start = 1; start <= 254; start += batch) {101+    const end = Math.min(start + batch - 1, 254);102+    const ips = Array.from({ length: end - start + 1 }, (_, i) => `${subnet}.${start + i}`);103+    const results = await Promise.all(ips.map(ip => probePair(ip, token)));104+    const hit = results.find(r => r !== null);105+    if (hit) return hit;106+  }107+  return null;108+}

view file

src/shared/sync-types.ts+17 −10
@@ -1,13 +1,3 @@1-/**2- * Types for Peach sync — full encrypted vault blob transfer over Noise NK.3- *4- * The wire protocol (SYNC_VAULT_SEND / SYNC_VAULT_ACK / SYNC_ERROR /5- * PAIR_REQUEST / PAIR_ACCEPT / PAIR_REJECT) lives on the native peer6- * (peach-android SyncPlugin) and is no longer modeled in the extension —7- * the desktop side only owns the in-popup pairing UI plus the device8- * registry.9- */10-111 export interface TrustedNetwork {122   ssid?: string;133   bssid?: string;@@ -22,7 +12,24 @@2212   pairedAt: number;2313   lastSeenAt?: number;2414   lastSyncAt?: number;15+  lastKnownIp?: string;2516 }17+18+// A stripped-down device record propagated in SYNC_VAULT_SEND so every peer19+// learns the full mesh without re-pairing. No IP — IPs are ephemeral and20+// discovered fresh via subnet scan.21+export interface SyncableDevice {22+  publicKeyHex: string;23+  deviceName: string;24+  pairedAt: number;25+}26+27+export type SyncMessage =28+  | { type: 'SYNC_HELLO' }29+  | { type: 'SYNC_VAULT_SEND'; blob: string; timestamp: number; license?: string; devices: SyncableDevice[] }30+  | { type: 'SYNC_VAULT_ACK'; applied: boolean }31+  | { type: 'SYNC_DONE' }32+  | { type: 'SYNC_ERROR'; error: string };2633 2734 export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";2835 

view file

src/shared/sync-wire.ts+31 −0
@@ -0,0 +1,31 @@1+import type { SyncMessage } from './sync-types';2+3+// Sync messages are JSON-encoded and then Noise NK encrypted. The encrypted4+// binary is base64-encoded for transmission over the text-only WS server.5+// This layer only handles the JSON ↔ SyncMessage boundary; the caller6+// is responsible for the encrypt/decrypt step around it.7+8+export function encodeSyncMessage(msg: SyncMessage): Uint8Array {9+  return new TextEncoder().encode(JSON.stringify(msg));10+}11+12+export function decodeSyncMessage(data: Uint8Array): SyncMessage {13+  const parsed = JSON.parse(new TextDecoder().decode(data));14+  if (typeof parsed?.type !== 'string') throw new Error('invalid sync message');15+  return parsed as SyncMessage;16+}17+18+// Base64 helpers for the WS text-frame transport layer.19+20+export function bytesToBase64(bytes: Uint8Array): string {21+  let binary = '';22+  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);23+  return btoa(binary);24+}25+26+export function base64ToBytes(b64: string): Uint8Array {27+  const binary = atob(b64);28+  const bytes = new Uint8Array(binary.length);29+  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);30+  return bytes;31+}

view file

src/shared/types.ts+4 −0
@@ -301,9 +301,13 @@301301   | "RECOVER_WITH_WORDS"302302   | "GET_RECOVERY_WORDS"303303   | "SCAN_PAIRING_QR"304+  | "SYNC_SESSION_STARTED"304305   | "GET_LOCAL_IP"305306   | "GET_IDENTITY_SUGGESTIONS"306307   | "POPULATE_INLINE_VAULT"308+  | "TEST_LOCAL_WEBSOCKET"309+  | "TEST_SUBNET_DETECTION"310+  | "TEST_SUBNET_SCAN"307311   // Content-script-bound message types — sent BY the background TO content308312   // scripts. They never reach messages.ts's switch. Kept in this union so309313   // existing call sites type-check; do not add cases for them in the

view file

src/shared/websocket-sync-transport.ts+87 −0
@@ -0,0 +1,87 @@1+import { noiseNKInitiator } from './noise-js';2+import { bytesToBase64, base64ToBytes } from './sync-wire';3+4+export interface SyncTransport {5+  send(data: Uint8Array): Promise<void>;6+  receive(): Promise<Uint8Array>;7+  close(): Promise<void>;8+}9+10+// Binary Noise NK frames are base64-encoded and sent as text WS frames so11+// they work over the phone's text-only PeachWebSocketServer. Each frame is12+// a plain base64 string (not JSON-wrapped) so the server's else-branch13+// forwards it via peach:sync-frame without modification.14+export async function createWebSocketSyncTransport(15+  url: string,16+  remotePublicKeyHex: string,17+): Promise<SyncTransport> {18+  const remotePublicKey = hexToBytes(remotePublicKeyHex);19+20+  return new Promise((resolve, reject) => {21+    const ws = new WebSocket(url);22+23+    const messageQueue: string[] = [];24+    const waiters: ((frame: string) => void)[] = [];25+26+    const enqueue = (frame: string) => {27+      if (waiters.length > 0) {28+        waiters.shift()!(frame);29+      } else {30+        messageQueue.push(frame);31+      }32+    };33+34+    ws.onmessage = (event) => enqueue(event.data as string);35+    ws.onerror = () => reject(new Error('WebSocket connection failed'));36+    ws.onclose = () => { waiters.forEach(w => w('')); waiters.length = 0; };37+38+    ws.onopen = async () => {39+      try {40+        const rawSend = (msg: Uint8Array) => {41+          ws.send(bytesToBase64(msg));42+          return Promise.resolve();43+        };44+        const rawReceive = (): Promise<Uint8Array> => new Promise((res) => {45+          const handle = (frame: string) => {46+            if (frame === '') { res(new Uint8Array(0)); return; }47+            res(base64ToBytes(frame));48+          };49+          if (messageQueue.length > 0) handle(messageQueue.shift()!);50+          else waiters.push(handle);51+        });52+53+        // Noise NK handshake — desktop is always the initiator.54+        const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive);55+56+        resolve({57+          send: async (plaintext) => {58+            ws.send(bytesToBase64(session.encrypt(plaintext)));59+          },60+          receive: () => new Promise((res) => {61+            const handle = (frame: string) => {62+              if (frame === '') { res(new Uint8Array(0)); return; }63+              res(session.decrypt(base64ToBytes(frame)));64+            };65+            if (messageQueue.length > 0) handle(messageQueue.shift()!);66+            else waiters.push(handle);67+          }),68+          close: () => {69+            ws.close();70+            return Promise.resolve();71+          },72+        });73+      } catch (err) {74+        ws.close();75+        reject(err);76+      }77+    };78+  });79+}80+81+function hexToBytes(hex: string): Uint8Array {82+  const bytes = new Uint8Array(hex.length / 2);83+  for (let i = 0; i < hex.length; i += 2) {84+    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);85+  }86+  return bytes;87+}

view file