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
↓ peach-browser-f341f450.tar.gzatlas at this save
files changed15 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 −0manifest.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": {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'"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 rowsrc/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+});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+}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 //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+}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) {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;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 <buttonsrc/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+}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 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+}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 thesrc/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+}