49bcc955c5ce

strip v2.14 legacy — sync-coordinator/network/storage/wire/websocket-transport modules, SyncPairing+InfoSheet+ComingSoonBanner views, RestoreFromPhone tab in Lock, getVaultBlobForSync/applyIncomingVault/getSyncIdentity, all legacy message types and handlers (16 files trashed, 8 cleaned)

time
2026-05-18 03:12 UTC · 5 months ago
machine
koh2
parent
cce17ffb3143
28 files src/background/messages.test.ts+0 −60src/background/messages.ts+7 −618src/background/pairing-completed-gate.test.ts+0 −153src/background/pairing-server-state.ts+0 −74src/background/sync-coordinator.test.ts+0 −264src/background/sync-coordinator.ts+0 −125src/background/sync-identity-encryption.test.ts+0 −111src/background/sync-info-sheet.test.ts+0 −184src/background/sync-network.ts+0 −47src/background/sync-pro-gate.test.ts+0 −169src/background/sync-screen.test.ts+0 −175src/background/sync-storage.ts+0 −146src/background/sync.adversarial.test.ts+0 −883src/background/vault.test.ts+0 −15src/background/vault.ts+0 −221src/popup/App.tsx+9 −142src/popup/chirp-pair.ts+7 −3src/popup/components/SyncComingSoonBanner.tsx+0 −16src/popup/components/SyncInfoSheet.tsx+0 −49src/popup/styles/popup.css+223 −0src/popup/views/ChirpSync.tsx+390 −0src/popup/views/Lock.tsx+3 −157src/popup/views/SyncPairing.tsx+0 −608src/shared/local-network.ts+9 −89src/shared/sync-types.ts+19 −79src/shared/sync-wire.ts+0 −31src/shared/types.ts+0 −19src/shared/websocket-sync-transport.ts+0 −108
src/background/messages.test.ts+0 −60
@@ -697,66 +697,6 @@697697     expect(hits[0]!.useCount).toBe(3);698698   });699699 700-  it("DEBUG_INJECT_INCOMING_PAIRING + GET returns the request and 60s expiry window", async () => {701-    const inject = await dispatch({702-      type: "DEBUG_INJECT_INCOMING_PAIRING",703-      payload: { deviceName: "iPhone", publicKeyHex: "deadbeef" },704-    });705-    expect(inject.ok).toBe(true);706-707-    const get = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });708-    expect(get.ok).toBe(true);709-    const data = (get as { data?: { deviceName?: string; expiresAt?: number; receivedAt?: number } }).data;710-    expect(data?.deviceName).toBe("iPhone");711-    expect((data?.expiresAt ?? 0) - (data?.receivedAt ?? 0)).toBe(60_000);712-  });713-714-  it("RESPOND_INCOMING_PAIRING accept clears the request and reports the device name", async () => {715-    await dispatch({716-      type: "DEBUG_INJECT_INCOMING_PAIRING",717-      payload: { deviceName: "MacBook", publicKeyHex: "cafe" },718-    });719-    const respond = await dispatch({720-      type: "RESPOND_INCOMING_PAIRING",721-      payload: { accept: true },722-    });723-    expect(respond.ok).toBe(true);724-    expect((respond as { data?: { accepted?: boolean; deviceName?: string } }).data).toEqual({725-      accepted: true,726-      deviceName: "MacBook",727-    });728-    const after = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });729-    expect((after as { data?: unknown }).data).toBeNull();730-  });731-732-  it("RESPOND_INCOMING_PAIRING with no pending request returns no_pending_request", async () => {733-    const res = await dispatch({734-      type: "RESPOND_INCOMING_PAIRING",735-      payload: { accept: false },736-    });737-    expect(res.ok).toBe(false);738-    expect((res as { error?: string }).error).toBe("no_pending_request");739-  });740-741-  it("RESET_SYNC_IDENTITY is Pro-gated and rejects free users", async () => {742-    const res = await dispatch({ type: "RESET_SYNC_IDENTITY" });743-    expect(res.ok).toBe(false);744-    expect((res as { error?: string }).error).toBe("pro_required");745-  });746-747-  it("RESET_SYNC_IDENTITY clears local paired devices for Pro users", async () => {748-    storage["peach_paired_devices"] = [749-      { deviceId: "a", displayName: "iPhone", x25519PublicKey: "pk-a", pairedAt: 1 },750-      { deviceId: "b", displayName: "MacBook", x25519PublicKey: "pk-b", pairedAt: 2 },751-    ];752-    const license = await import("./license");753-    vi.spyOn(license, "isProUser").mockResolvedValue(true);754-    const res = await dispatch({ type: "RESET_SYNC_IDENTITY" });755-    expect(res.ok).toBe(true);756-    expect((res as { data?: { removedDevices: number } }).data?.removedDevices).toBe(2);757-    expect(storage["peach_paired_devices"]).toEqual([]);758-  });759-760700   it("SUBMIT_FEEDBACK does not refresh the auto-lock timer", async () => {761701     const vault = await import("./vault");762702     await vault.createVault("correct-pass-12345");

view file

src/background/messages.ts+7 −618
@@ -43,25 +43,8 @@4343   setPasskeyProviderEnabled,4444 } from "./passkey";4545 import { buildAdvancedHealthReport, resolveResetUrl } from "./health";46-import { storageClear, storageGet } from "../shared/storage";46+import { storageClear } from "../shared/storage";4747 import { findInvalidWords } from "../shared/recovery-words";48-import {49-  addPairedDevice,50-  addTrustedNetwork,51-  clearIncomingPairingRequest,52-  getIncomingPairingRequest,53-  getPairedDevices,54-  getTrustedNetworks,55-  removePairedDevice,56-  removeTrustedNetwork,57-  setIncomingPairingRequest,58-  updatePairedDevice,59-} from "./sync-storage";60-import { getCurrentNetwork, isOnTailscale } from "./sync-network";61-import {62-  consumeRecentPairing,63-  setActivePairingToken,64-} from "./pairing-server-state";6548 import type {6649   Message,6750   MessageResponse,@@ -70,16 +53,9 @@7053   VaultEntry,7154 } from "../shared/types";7255 import { isPasskeyOnly } from "../shared/types";73-import type { SyncStatus, SyncableDevice } from "../shared/sync-types";7456 import { isNativePlatform, nativePlugin } from "../shared/native";75-import { runBlobSyncSession, type SyncResult } from "./sync-coordinator";76-import type { SyncDeps } from "./sync-coordinator";77-import { createWebSocketSyncTransport } from "../shared/websocket-sync-transport";78-import { performResponderAuth, bytesToHex as noiseBytesToHex } from "../shared/noise-js";79-import { getLocalSubnet, discoverDevice, scanForPairingPhone } from "../shared/local-network";8057 import { predictBatch as mlPredictBatch } from "./ml";8158 import type { MLFieldFeatures } from "../shared/ml";82-import { base64ToBytes, bytesToBase64 } from "../shared/sync-wire";8359 import { buildLocalIdentity, setDeviceName as setDeviceNameV3 } from "./chirp-identity";8460 import {8561   listPairedDevices as listPairedDevicesV3,@@ -130,25 +106,14 @@130106 }131107 132108 // ---------------------------------------------------------------------------133-// 6-digit code pairing.134-135-const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;136-137-138-function generateSixDigitToken(): string {139-  // crypto.getRandomValues, not Math.random — the token is the only secret140-  // protecting the pairing window from a same-LAN attacker. 6 decimal digits141-  // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating142-  // on the responder this is the right tradeoff between UX and security.143-  const buf = new Uint32Array(1);144-  crypto.getRandomValues(buf);145-  const value = buf[0] % 1_000_000;146-  return value.toString().padStart(6, "0");147-}148-109+// Feedback submission target. Empty disables network submission while the UI110+// continues to function as a no-op (the handler returns ok:true without111+// calling fetch). When set, the URL is validated by112+// `isAllowedCanaryWebhookUrl` before any request.149113 // ---------------------------------------------------------------------------150-// Peer discovery glue (Phase 7 popup pairing flow).151114 115+const FEEDBACK_ENDPOINT = "";116+152117 const PRIVATE_IPV4_RANGES = [153118   { prefix: "10." },154119   { prefix: "127." },@@ -156,170 +121,6 @@156121   { prefix: "192.168." },157122 ];158123 159-// Empty disables network submission while the UI continues to function as a160-// no-op (the handler returns ok:true without calling fetch). When set, the161-// URL is validated by `isAllowedCanaryWebhookUrl` before any request, so the162-// same private-IP/HTTPS rules that gate canary webhooks apply here.163-const FEEDBACK_ENDPOINT = "";164-165-// ---------------------------------------------------------------------------166-// Sync helpers167-// ---------------------------------------------------------------------------168-169-/**170- * Short-lived registry of x25519 public keys that just completed a real Noise171- * NK handshake + initiator-auth proof. The PAIRING_COMPLETED handler must172- * find the supplied publicKeyHex here before it will call addPairedDevice —173- * otherwise an attacker could forge the message and pair an arbitrary key174- * (M13/SEC-005).175- *176- * Entries auto-expire after 30s. Each entry is consumed on first successful177- * PAIRING_COMPLETED so a single handshake cannot pair twice.178- */179-const PENDING_PAIRS = new Map<string, ReturnType<typeof setTimeout>>();180-const PENDING_PAIR_TTL_MS = 30_000;181-182-export function markPairingHandshakeVerified(publicKeyHex: string): void {183-  if (!publicKeyHex) return;184-  const existing = PENDING_PAIRS.get(publicKeyHex);185-  if (existing) clearTimeout(existing);186-  const t = setTimeout(() => PENDING_PAIRS.delete(publicKeyHex), PENDING_PAIR_TTL_MS);187-  PENDING_PAIRS.set(publicKeyHex, t);188-}189-190-function consumePendingPair(publicKeyHex: string): boolean {191-  const t = PENDING_PAIRS.get(publicKeyHex);192-  if (!t) return false;193-  clearTimeout(t);194-  PENDING_PAIRS.delete(publicKeyHex);195-  return true;196-}197-198-export function buildSyncDeps(): SyncDeps {199-  return {200-    isVaultUnlocked: () => vault.isUnlocked(),201-    getVaultBlobForSync: async () => {202-      const base = await vault.getVaultBlobForSync();203-      if (!base) return null;204-      const paired = await getPairedDevices();205-      return {206-        ...base,207-        devices: paired.flatMap(d =>208-          d.x25519PublicKey209-            ? [{210-                publicKeyHex: d.x25519PublicKey,211-                deviceName: d.displayName ?? "",212-                pairedAt: d.pairedAt,213-              }]214-            : [],215-        ),216-      };217-    },218-    applyIncomingVault: async (blob, ts, license, devices) => {219-      const applied = await vault.applyIncomingVault(blob, ts, license);220-      if (devices?.length) await mergeDeviceRegistry(devices);221-      if (applied) {222-        const reloaded = await vault.reloadEntriesAfterSync();223-        if (!reloaded) {224-          // The remote vault was encrypted under a different key (the remote225-          // changed password). Our in-memory entries are now stale and cannot226-          // be re-encrypted without overwriting the just-applied blob. Lock227-          // to force a re-unlock with the new password (H3/COR-005).228-          vault.lock();229-          chrome.runtime230-            .sendMessage({ type: "VAULT_RELOCK_AFTER_SYNC" })231-            .catch(() => {});232-        }233-      }234-      return applied;235-    },236-  };237-}238-239-async function doVaultSync(ip: string, publicKeyHex: string): Promise<SyncResult> {240-  // Defensive: refuse to drive a sync to a key not in the paired-devices241-  // list. The WebSocket transport already authenticates the peer via Noise242-  // NK + the initiator auth proof (H1), but pinning the key to the paired243-  // set here makes "talk to anyone matching this static key" the only path244-  // to vault application — there is no orphan-key window between pair and245-  // sync (M11/COR-014).246-  const paired = await getPairedDevices();247-  if (!paired.some(d => d.x25519PublicKey === publicKeyHex)) {248-    return { status: 'errored', applied: false, error: 'remote_not_paired' };249-  }250-  try {251-    const identity = await vault.getSyncIdentity();252-    const transport = await createWebSocketSyncTransport(253-      `ws://${ip}:44100`,254-      publicKeyHex,255-      identity.privateKeyBytes,256-      identity.publicKeyBytes,257-    );258-    const result = await runBlobSyncSession('initiator', transport, buildSyncDeps());259-    // Reaching this point means createWebSocketSyncTransport completed both260-    // the Noise NK handshake and the initiator-auth proof — the remote261-    // proved possession of the matching static key (M13/SEC-005).262-    if (result.status === 'completed') {263-      markPairingHandshakeVerified(publicKeyHex);264-    }265-    return result;266-  } catch (err) {267-    return { status: 'errored', applied: false, error: String(err) };268-  }269-}270-271-async function mergeDeviceRegistry(incoming: SyncableDevice[]): Promise<void> {272-  const existing = await getPairedDevices();273-  const existingKeys = new Set(existing.map(d => d.x25519PublicKey));274-  for (const device of incoming) {275-    if (!existingKeys.has(device.publicKeyHex)) {276-      await addPairedDevice({277-        deviceId: device.publicKeyHex,278-        displayName: device.deviceName,279-        x25519PublicKey: device.publicKeyHex,280-        pairedAt: device.pairedAt,281-        lastSeenAt: 0,282-        lastKnownIp: '',283-      }).catch(() => {});284-    }285-  }286-}287-288-// Background pairing scan — runs after START_PAIRING_SERVER fires on desktop.289-// Scans the local subnet for a phone that has accepted the pairing token,290-// stores it, runs the initial vault exchange, then notifies the popup.291-async function runPairingSubnetScan(token: string): Promise<void> {292-  const subnet = await getLocalSubnet();293-  if (!subnet) return;294-295-  const found = await scanForPairingPhone(subnet, token);296-  if (!found) return;297-298-  const { ip, publicKeyHex } = found;299-300-  try {301-    await addPairedDevice({302-      deviceId: publicKeyHex,303-      displayName: 'Peach',304-      x25519PublicKey: publicKeyHex,305-      pairedAt: Date.now(),306-      lastSeenAt: Date.now(),307-      lastKnownIp: ip,308-    });309-  } catch {310-    // Already paired — update IP311-    await updatePairedDevice(publicKeyHex, { lastKnownIp: ip, lastSeenAt: Date.now() });312-  }313-314-  // Immediate vault exchange — the free one-time transfer315-  await doVaultSync(ip, publicKeyHex);316-317-  chrome.runtime.sendMessage({318-    type: 'PAIRING_COMPLETED',319-    payload: { deviceName: 'Peach', publicKeyHex, deviceIp: ip },320-  }).catch(() => {});321-}322-323124 export function setupMessageHandler(): void {324125   chrome.runtime.onMessage.addListener(325126     (msg: unknown, sender, sendResponse) => {@@ -357,55 +158,6 @@357158     });358159   }359160 360-  // Android: subscribe to SyncPlugin events.361-  if (isNativePlatform()) {362-    const syncPlug = nativePlugin<{363-      addListener: (364-        event: string,365-        handler: (data: Record<string, string>) => void366-      ) => void;367-    }>("SyncPlugin");368-369-    // Pairing probe answered — store the paired device (desktop, no IP needed370-    // since the desktop never acts as a WS server).371-    syncPlug?.addListener?.("peach:pair-probe-answered", (data) => {372-      const deviceName = data.deviceName ?? "Peach";373-      const publicKeyHex = data.publicKeyHex ?? "";374-      // Native handled the pairing handshake — trust this entry into the375-      // verified-handshake set (M13/SEC-005). The subsequent PAIRING_COMPLETED376-      // message will consume it.377-      if (publicKeyHex) markPairingHandshakeVerified(publicKeyHex);378-      void (async () => {379-        const { addPairedDevice, setHasPaired: setPaired } = await import("./sync-storage");380-        await addPairedDevice({381-          deviceId: publicKeyHex || (data.connectionId ?? "unknown"),382-          displayName: deviceName,383-          x25519PublicKey: publicKeyHex,384-          pairedAt: Date.now(),385-          lastSeenAt: Date.now(),386-        }).catch(() => {});387-        await setPaired();388-        chrome.runtime.sendMessage({389-          type: "PAIRING_COMPLETED",390-          payload: { deviceName, publicKeyHex },391-        }).catch(() => {});392-      })();393-    });394-395-    // Sync frame received — route to the waiting responder session.396-    syncPlug?.addListener?.("peach:sync-frame", (data) => {397-      const { connectionId, frame } = data;398-      if (connectionId && frame) deliverAndroidSyncFrame(connectionId, frame);399-    });400-401-    // Sync session started (SYNC_HELLO received by SyncPlugin) — dispatch as402-    // a background message so SYNC_SESSION_STARTED handler runs the responder.403-    syncPlug?.addListener?.("peach:sync-session-started", (data) => {404-      const { connectionId } = data;405-      if (!connectionId) return;406-      void handleMessage({ type: "SYNC_SESSION_STARTED", payload: { connectionId } });407-    });408-  }409161 }410162 411163 function isMessage(m: unknown): m is Message {@@ -698,44 +450,6 @@698450         );699451         return { ok: true, data: searchResults } as MessageResponse;700452       }701-      case "PAIRING_COMPLETED": {702-        const { deviceName, publicKeyHex, deviceIp } = (msg.payload ?? {}) as {703-          deviceName?: string;704-          publicKeyHex?: string;705-          deviceIp?: string;706-        };707-        if (!publicKeyHex) return { ok: false, error: "publickeyhex_required" };708-        // Only accept publicKeyHex values that just completed a real Noise NK709-        // handshake (initiator or responder side). Without this gate, anyone710-        // who can sendMessage to the background can pair an arbitrary key711-        // (M13/SEC-005). Re-confirming an already-paired device is allowed712-        // so legitimate update-IP retries still work.713-        const alreadyPaired = (await getPairedDevices()).some(714-          d => d.x25519PublicKey === publicKeyHex,715-        );716-        const verified = consumePendingPair(publicKeyHex);717-        if (!verified && !alreadyPaired) {718-          return { ok: false, error: "pairing_not_verified" };719-        }720-        const { setHasPaired: setPaired } = await import("./sync-storage");721-        await setPaired();722-        try {723-          await addPairedDevice({724-            deviceId: publicKeyHex,725-            displayName: deviceName ?? "Unknown",726-            x25519PublicKey: publicKeyHex,727-            pairedAt: Date.now(),728-            lastSeenAt: Date.now(),729-            lastKnownIp: deviceIp,730-          });731-        } catch {732-          // Already paired — update IP if provided733-          if (deviceIp) {734-            await updatePairedDevice(publicKeyHex, { lastKnownIp: deviceIp, lastSeenAt: Date.now() });735-          }736-        }737-        return { ok: true };738-      }739453       case "AUTOFILL_QUERY": {740454         if (!vault.isUnlocked()) {741455           return { ok: false, error: "vault_locked" };@@ -1059,158 +773,6 @@1059773           return { ok: true, data: null };1060774         }1061775       }1062-      case "GET_SYNC_STATE": {1063-        const pairedDevices = await getPairedDevices();1064-        const trustedNetworks = await getTrustedNetworks();1065-        const tailscale = await isOnTailscale();1066-        const pro = await isProUser();1067-1068-        let status: SyncStatus = "offline";1069-        if (tailscale || trustedNetworks.some((n: { isTailscale?: boolean }) => !n.isTailscale)) {1070-          if (pairedDevices.length === 0) {1071-            status = "no_peers";1072-          } else {1073-            status = "synced";1074-          }1075-        } else {1076-          status = "not_trusted";1077-        }1078-1079-        const lastSyncRaw = await storageGet("peach_last_sync_at");1080-        const lastSyncParsed = lastSyncRaw ? Number.parseInt(lastSyncRaw, 10) : NaN;1081-        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : undefined;1082-1083-        return { ok: true, data: { pairedDevices, trustedNetworks, status, lastSyncAt, isPro: pro } };1084-      }1085-      case "GET_PAIRED_DEVICES": {1086-        const devices = await getPairedDevices();1087-        return { ok: true, data: { devices } };1088-      }1089-      case "GET_SYNC_STATUS": {1090-        const lastSyncRaw = await storageGet("peach_last_sync_at");1091-        const lastSyncParsed = lastSyncRaw ? parseInt(lastSyncRaw, 10) : NaN;1092-        const lastSyncAt = Number.isFinite(lastSyncParsed) ? lastSyncParsed : null;1093-        const status: 'up_to_date' | 'never' = lastSyncAt ? 'up_to_date' : 'never';1094-        return { ok: true, data: { status, lastSyncAt } };1095-      }1096-      case "ACCEPT_PAIRING": {1097-        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.1098-        return { ok: true };1099-      }1100-      case "REMOVE_PAIRED_DEVICE": {1101-        const { deviceId } = msg.payload as { deviceId: string };1102-        await removePairedDevice(deviceId);1103-        return { ok: true };1104-      }1105-      case "REMOVE_TRUSTED_NETWORK": {1106-        const payload = msg.payload as1107-          | { ssid?: unknown; bssid?: unknown; isTailscale?: unknown }1108-          | null1109-          | undefined;1110-        if (!payload || typeof payload !== "object" || Array.isArray(payload)) {1111-          return { ok: false, error: "invalid_payload" };1112-        }1113-        const { ssid, bssid, isTailscale } = payload as { ssid?: string; bssid?: string; isTailscale?: boolean };1114-        const networks = await getTrustedNetworks();1115-        const network = networks.find((n: { ssid?: string; bssid?: string; isTailscale?: boolean }) => 1116-          (isTailscale ? n.isTailscale : n.ssid === ssid) && 1117-          (!bssid || n.bssid === bssid)1118-        );1119-        if (network) {1120-          await removeTrustedNetwork(network.addedAt);1121-        }1122-        return { ok: true };1123-      }1124-      case "MARK_NETWORK_TRUSTED": {1125-        const network = await getCurrentNetwork();1126-        await addTrustedNetwork({1127-          ssid: network.ssid,1128-          bssid: network.bssid,1129-          isTailscale: network.isTailscale,1130-          addedAt: Date.now(),1131-        });1132-        return { ok: true };1133-      }1134-      case "GET_INCOMING_PAIRING_REQUEST": {1135-        const request = await getIncomingPairingRequest();1136-        return { ok: true, data: request };1137-      }1138-      case "RESPOND_INCOMING_PAIRING": {1139-        const { accept } = msg.payload as { accept: boolean };1140-        const request = await getIncomingPairingRequest();1141-        if (!request) return { ok: false, error: "no_pending_request" };1142-        if (Date.now() > request.expiresAt) {1143-          await clearIncomingPairingRequest();1144-          return { ok: false, error: "expired" };1145-        }1146-        await clearIncomingPairingRequest();1147-        if (accept) {1148-          await addPairedDevice({1149-            deviceId: request.publicKeyHex,1150-            displayName: request.deviceName,1151-            x25519PublicKey: request.publicKeyHex,1152-            pairedAt: Date.now(),1153-            lastSeenAt: Date.now(),1154-          });1155-        }1156-        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };1157-      }1158-      case "START_PAIRING_SERVER": {1159-        // One-time mobile→desktop transfer is free; only continuous sync gates on Pro.1160-        const token = generateSixDigitToken();1161-        const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;1162-        await setActivePairingToken({ token, expiresAt });1163-        // Fire-and-forget subnet scan — finds the phone after it scans the QR1164-        // and calls SCAN_PAIRING_QR to register the token with SyncPlugin.1165-        if (!isNativePlatform()) {1166-          void runPairingSubnetScan(token);1167-        }1168-        return { ok: true, data: { token, expiresAt } };1169-      }1170-      case "STOP_PAIRING_SERVER": {1171-        try {1172-          await setActivePairingToken(null);1173-        } catch { /* best-effort */ }1174-        return { ok: true };1175-      }1176-      case "GET_RECENT_PAIRING": {1177-        const recent = await consumeRecentPairing();1178-        return { ok: true, data: recent };1179-      }1180-      case "DEBUG_INJECT_INCOMING_PAIRING": {1181-        // M14/SEC-007: blocked in production builds. The handler exists for1182-        // Phase 7 banner UX rehearsal without a live native host; shipping1183-        // it in production lets any background-message sender inject a1184-        // synthetic pairing prompt. Allowed in 'development' and 'test'1185-        // modes; vite sets MODE='production' for release builds.1186-        if (import.meta.env.MODE === "production") {1187-          return { ok: false, error: "not_available" };1188-        }1189-        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {1190-          deviceName?: string;1191-          publicKeyHex?: string;1192-        };1193-        if (!deviceName || !publicKeyHex) {1194-          return { ok: false, error: "missing_payload" };1195-        }1196-        const now = Date.now();1197-        await setIncomingPairingRequest({1198-          deviceName,1199-          publicKeyHex,1200-          receivedAt: now,1201-          expiresAt: now + 60_000,1202-        });1203-        return { ok: true };1204-      }1205-      case "RESET_SYNC_IDENTITY": {1206-        const gate = await requirePro("sync");1207-        if (!gate.allowed) return { ok: false, error: "pro_required" };1208-        const devices = await getPairedDevices();1209-        for (const device of devices) {1210-          await removePairedDevice(device.deviceId);1211-        }1212-        return { ok: true, data: { removedDevices: devices.length, nativePublicKey: null } };1213-      }1214776       case "RECORD_MANUAL_SUMMON": {1215777         const { domain } = msg.payload as { domain: string };1216778         const settings = vault.getSettings();@@ -1369,62 +931,6 @@1369931           return { ok: false, error: err instanceof Error ? err.message : "network_error" };1370932         }1371933       }1372-      case "TRIGGER_SYNC": {1373-        const gate = await requirePro("sync");1374-        if (!gate.allowed) return { ok: false, error: "pro_required" };1375-        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1376-1377-        if (isNativePlatform()) {1378-          // Phone is the server — ensure it's listening for desktop connections.1379-          syncBridge()?.ensureServerRunning?.();1380-          return { ok: true, data: { syncedCount: 0, mode: "server" as const } };1381-        }1382-1383-        // Desktop: iterate all paired devices and sync with each.1384-        const paired = await getPairedDevices();1385-        if (!paired.length) return { ok: true, data: { syncedCount: 0, reason: "no_paired_devices" } };1386-1387-        const subnet = await getLocalSubnet();1388-        const results: Array<{ device: string } & SyncResult> = [];1389-1390-        for (const device of paired) {1391-          const pubkey = device.x25519PublicKey;1392-          if (!pubkey) continue;1393-1394-          let result: SyncResult;1395-1396-          // Fast path — use last known IP1397-          if (device.lastKnownIp) {1398-            result = await doVaultSync(device.lastKnownIp, pubkey);1399-          } else {1400-            result = { status: 'errored', applied: false, error: 'no_ip' };1401-          }1402-1403-          // Discovery path — scan if fast path failed1404-          if (result.status === 'errored' && subnet) {1405-            const newIp = await discoverDevice(subnet, pubkey);1406-            if (newIp) {1407-              await updatePairedDevice(pubkey, { lastKnownIp: newIp });1408-              result = await doVaultSync(newIp, pubkey);1409-            }1410-          }1411-1412-          if (result.status === 'completed') {1413-            await updatePairedDevice(pubkey, {1414-              lastSeenAt: Date.now(),1415-              lastSyncAt: Date.now(),1416-            });1417-          }1418-1419-          results.push({ device: device.displayName ?? "", ...result });1420-        }1421-1422-        const syncedCount = results.filter(r => r.status === 'completed').length;1423-        if (syncedCount > 0) {1424-          await chrome.storage.local.set({ peach_last_sync_at: String(Date.now()) });1425-        }1426-        return { ok: true, data: { syncedCount, results } };1427-      }1428934       // Android-only: biometric unlock with pre-derived key.1429935       case "UNLOCK_WITH_KEY": {1430936         const { masterKeyHex } = msg.payload as { masterKeyHex: string };@@ -1452,80 +958,6 @@1452958         }1453959         return { ok: true, data: { masterKeyHex: vault.getDerivedKeyHex() } };1454960       }1455-      // Android: scanned a QR containing a desktop pairing token.1456-      case "SCAN_PAIRING_QR": {1457-        if (!isNativePlatform()) {1458-          return { ok: false, error: "android_only" };1459-        }1460-        const { token } = msg.payload as { token: string };1461-        syncBridge()?.startPairingSession?.({ token });1462-        return { ok: true };1463-      }1464-      // Android: desktop opened a sync connection to the phone's WS server.1465-      // Run the responder side of the vault exchange.1466-      case "SYNC_SESSION_STARTED": {1467-        if (!isNativePlatform()) return { ok: false };1468-        if (!vault.isUnlocked()) {1469-          return { ok: false, error: "vault_locked" };1470-        }1471-        const { connectionId } = (msg.payload ?? {}) as { connectionId?: string };1472-        if (!connectionId) return { ok: false, error: "missing_connection_id" };1473-1474-        const transport = createAndroidResponderTransport(connectionId);1475-1476-        // Authenticate the initiator before exchanging any vault data. The1477-        // native side has already completed the Noise NK handshake; auth1478-        // frames here ride the Noise transport (native encrypts/decrypts).1479-        const identity = await vault.getSyncIdentity();1480-        try {1481-          // CAUTION: noiseSessionStub is a structural placeholder — the real1482-          // Noise NK handshake runs natively, so JS never holds the session1483-          // object. performResponderAuth currently `void`s this argument,1484-          // which is why identity-passthrough encrypt/decrypt and a zero1485-          // handshake hash are safe. If performResponderAuth is ever updated1486-          // to actually use this argument, this auth path will silently break1487-          // (no type error). Revisit this stub if that helper changes.1488-          const noiseSessionStub = {1489-            encrypt: (b: Uint8Array) => b,1490-            decrypt: (b: Uint8Array) => b,1491-            handshakeHash: new Uint8Array(32),1492-          };1493-          const { initiatorPublicKey } = await performResponderAuth(1494-            noiseSessionStub,1495-            identity.privateKeyBytes,1496-            identity.publicKeyBytes,1497-            transport.send,1498-            transport.receive,1499-          );1500-          const initiatorHex = noiseBytesToHex(initiatorPublicKey);1501-          const paired = await getPairedDevices();1502-          if (!paired.some(d => d.x25519PublicKey === initiatorHex)) {1503-            await transport.close();1504-            return { ok: false, error: "sync_rejected_unpaired_initiator" };1505-          }1506-          // Initiator just proved control of the matching static key; mark it1507-          // as a verified handshake so a follow-up PAIRING_COMPLETED message1508-          // can be trusted (M13/SEC-005).1509-          markPairingHandshakeVerified(initiatorHex);1510-        } catch (err) {1511-          await transport.close();1512-          return { ok: false, error: `sync_auth_failed: ${String(err)}` };1513-        }1514-1515-        const result = await runBlobSyncSession('responder', transport, buildSyncDeps());1516-        if (result.applied) {1517-          await publishInMemoryVault(vault.getEntries());1518-        }1519-        return { ok: true, data: result };1520-      }1521-      // Android: returns the phone's WiFi IP address.1522-      case "GET_LOCAL_IP": {1523-        if (!isNativePlatform()) {1524-          return { ok: false, error: "android_only" };1525-        }1526-        const { ip } = (await syncBridge()?.getLocalIp?.()) ?? { ip: "" };1527-        return { ok: true, data: { ip } };1528-      }1529961       case "GET_IDENTITY_SUGGESTIONS": {1530962         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1531963         const entries = vault.getEntries();@@ -1662,49 +1094,6 @@16621094   const plugin = nativePlugin<{ clearEntries?: () => Promise<void> }>("InMemoryVaultPlugin");16631095   if (!plugin?.clearEntries) return;16641096   await plugin.clearEntries();1665-}1666-1667-interface SyncBridge {1668-  ensureServerRunning?: () => void;1669-  startPairingSession?: (args: { token: string }) => void;1670-  getLocalIp?: () => Promise<{ ip: string }>;1671-  sendToConnection?: (args: { connectionId: string; data: string }) => Promise<void>;1672-  addListener?: (event: string, handler: (data: Record<string, string>) => void) => void;1673-}1674-1675-// Resolve the Capacitor SyncPlugin at call time — module-load resolution1676-// races with Capacitor's plugin registration on the Android WebView.1677-function syncBridge(): SyncBridge | null {1678-  return nativePlugin<SyncBridge>("SyncPlugin");1679-}1680-1681-// Build a SyncTransport for the Android responder path — the phone's WS server1682-// already has an open connection from the desktop. We send back via1683-// SyncPlugin.sendToConnection and receive via the peach:sync-frame queue.1684-const androidSyncFrameQueues = new Map<string, Array<(frame: string) => void>>();1685-1686-export function deliverAndroidSyncFrame(connectionId: string, frame: string): void {1687-  const queue = androidSyncFrameQueues.get(connectionId);1688-  if (queue?.length) queue.shift()!(frame);1689-}1690-1691-function createAndroidResponderTransport(connectionId: string) {1692-  const pendingReceivers: Array<(frame: string) => void> = [];1693-  androidSyncFrameQueues.set(connectionId, pendingReceivers);1694-1695-  return {1696-    send: async (data: Uint8Array) => {1697-      await syncBridge()?.sendToConnection?.({ connectionId, data: bytesToBase64(data) });1698-    },1699-    receive: (): Promise<Uint8Array> => new Promise((res) => {1700-      pendingReceivers.push((frame) => {1701-        res(frame ? base64ToBytes(frame) : new Uint8Array(0));1702-      });1703-    }),1704-    close: async () => {1705-      androidSyncFrameQueues.delete(connectionId);1706-    },1707-  };17081097 }17091098 17101099 // Returns username use-counts from the vault, sorted by frequency. Each row

view file

src/background/pairing-completed-gate.test.ts+0 −153
@@ -1,153 +0,0 @@1-/**2- * pairing-completed-gate.test.ts — M13/SEC-0053- *4- * PAIRING_COMPLETED must verify the publicKeyHex came from a real Noise NK5- * handshake before calling addPairedDevice. Forged messages with an6- * unverified key must be rejected.7- */8-9-import { beforeEach, describe, expect, it, vi } from "vitest";10-11-import type { Message, MessageResponse } from "../shared/types";12-13-type StorageShape = Record<string, unknown>;14-type MessageListener = (15-  msg: unknown,16-  sender: chrome.runtime.MessageSender,17-  sendResponse: (response: MessageResponse) => void18-) => boolean;19-20-describe("PAIRING_COMPLETED gate (M13/SEC-005)", () => {21-  let storage: StorageShape;22-  let listener: MessageListener | undefined;23-24-  beforeEach(async () => {25-    vi.resetModules();26-    vi.useRealTimers();27-    storage = { peach_settings: { breachMonitoring: false } };28-    listener = undefined;29-30-    vi.stubGlobal(31-      "fetch",32-      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })),33-    );34-    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);35-36-    const chromeMock = {37-      runtime: {38-        id: "peach-extension",39-        onMessage: {40-          addListener: vi.fn((fn: MessageListener) => {41-            listener = fn;42-          }),43-        },44-        sendMessage: vi.fn(async () => ({ ok: true })),45-      },46-      storage: {47-        local: {48-          get: vi.fn(async (key?: string | string[]) => {49-            if (!key) return storage;50-            if (typeof key === "string") return { [key]: storage[key] };51-            return key.reduce<Record<string, unknown>>((acc, k) => {52-              acc[k] = storage[k];53-              return acc;54-            }, {});55-          }),56-          set: vi.fn(async (next: Record<string, unknown>) => {57-            Object.assign(storage, next);58-          }),59-          remove: vi.fn(async (keys: string | string[]) => {60-            const list = Array.isArray(keys) ? keys : [keys];61-            for (const k of list) delete storage[k];62-          }),63-        },64-        session: {65-          get: vi.fn(async () => ({})),66-          set: vi.fn(async () => {}),67-          remove: vi.fn(async () => {}),68-        },69-      },70-      alarms: {71-        clear: vi.fn(),72-        create: vi.fn(),73-        onAlarm: { addListener: vi.fn() },74-      },75-      idle: {76-        setDetectionInterval: vi.fn(),77-        onStateChanged: { addListener: vi.fn() },78-      },79-      scripting: { executeScript: vi.fn(async () => []) },80-    } as unknown as typeof chrome;81-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;82-83-    const messages = await import("./messages");84-    messages.setupMessageHandler();85-  });86-87-  async function dispatch(message: Message): Promise<MessageResponse> {88-    if (!listener) throw new Error("listener not registered");89-    return new Promise<MessageResponse>((resolve) => {90-      listener!(message, { id: "peach-extension" } as chrome.runtime.MessageSender, resolve);91-    });92-  }93-94-  it("rejects PAIRING_COMPLETED for an unverified, unpaired publicKeyHex", async () => {95-    const fakeKey = "deadbeef".repeat(8);96-    const r = await dispatch({97-      type: "PAIRING_COMPLETED",98-      payload: { deviceName: "Attacker", publicKeyHex: fakeKey },99-    });100-    expect(r.ok).toBe(false);101-    expect(r.error).toBe("pairing_not_verified");102-103-    // Did not write to paired devices.104-    const paired = (storage.peach_paired_devices as unknown[] | undefined) ?? [];105-    expect(paired.length).toBe(0);106-  });107-108-  it("accepts PAIRING_COMPLETED for a freshly verified handshake", async () => {109-    const messages = await import("./messages");110-    const goodKey = "abcdef01".repeat(8);111-    messages.markPairingHandshakeVerified(goodKey);112-113-    const r = await dispatch({114-      type: "PAIRING_COMPLETED",115-      payload: { deviceName: "RealDevice", publicKeyHex: goodKey },116-    });117-    expect(r.ok).toBe(true);118-119-    const paired = (storage.peach_paired_devices as Array<Record<string, unknown>> | undefined) ?? [];120-    expect(paired.some(d => d.x25519PublicKey === goodKey)).toBe(true);121-  });122-123-  it("verified marker is single-use — second PAIRING_COMPLETED after the first device is removed is rejected", async () => {124-    const messages = await import("./messages");125-    const goodKey = "12345678".repeat(8);126-    messages.markPairingHandshakeVerified(goodKey);127-128-    const first = await dispatch({129-      type: "PAIRING_COMPLETED",130-      payload: { deviceName: "Dev", publicKeyHex: goodKey },131-    });132-    expect(first.ok).toBe(true);133-134-    // Wipe paired state so alreadyPaired is false.135-    delete storage.peach_paired_devices;136-137-    const second = await dispatch({138-      type: "PAIRING_COMPLETED",139-      payload: { deviceName: "Dev", publicKeyHex: goodKey },140-    });141-    expect(second.ok).toBe(false);142-    expect(second.error).toBe("pairing_not_verified");143-  });144-145-  it("missing publicKeyHex is rejected", async () => {146-    const r = await dispatch({147-      type: "PAIRING_COMPLETED",148-      payload: { deviceName: "X" },149-    });150-    expect(r.ok).toBe(false);151-    expect(r.error).toBe("publickeyhex_required");152-  });153-});

view file at the parent

src/background/pairing-server-state.ts+0 −74
@@ -1,74 +0,0 @@1-/**2- * Tracks the active 6-digit pairing-server session.3- *4- * While a token is active, `setupIncomingPairingSubscriber` auto-accepts the5- * first PAIR_REQUEST that arrives (the user's act of showing the code is the6- * consent — no banner). The token expires after `expiresAt` so a stale7- * pairing window cannot silently accept a peer minutes later.8- *9- * On a successful pairing while the popup is closed, the responder records a10- * `RecentPairing` so the next popup open can flash a "Paired with X"11- * confirmation. The record is read-once.12- */13-14-const ACTIVE_TOKEN_KEY = "peach_active_pairing_token";15-const RECENT_PAIRING_KEY = "peach_recent_pairing";16-17-export interface ActivePairingToken {18-  token: string;19-  expiresAt: number;20-}21-22-export interface RecentPairing {23-  deviceName: string;24-  pairedAt: number;25-  /** "showing" — we showed the code; "entering" — we typed a code. */26-  side: "showing" | "entering";27-}28-29-export async function setActivePairingToken(value: ActivePairingToken | null): Promise<void> {30-  try {31-    if (value === null) {32-      await chrome.storage.session.remove(ACTIVE_TOKEN_KEY);33-    } else {34-      await chrome.storage.session.set({ [ACTIVE_TOKEN_KEY]: value });35-    }36-  } catch {37-    // session storage may be unavailable in test contexts38-  }39-}40-41-export async function getActivePairingToken(): Promise<ActivePairingToken | null> {42-  try {43-    const result = await chrome.storage.session.get(ACTIVE_TOKEN_KEY);44-    const value = (result as Record<string, ActivePairingToken | undefined>)[ACTIVE_TOKEN_KEY];45-    if (!value) return null;46-    if (Date.now() >= value.expiresAt) {47-      await chrome.storage.session.remove(ACTIVE_TOKEN_KEY);48-      return null;49-    }50-    return value;51-  } catch {52-    return null;53-  }54-}55-56-export async function setRecentPairing(value: RecentPairing): Promise<void> {57-  try {58-    await chrome.storage.session.set({ [RECENT_PAIRING_KEY]: value });59-  } catch {60-    // best-effort61-  }62-}63-64-export async function consumeRecentPairing(): Promise<RecentPairing | null> {65-  try {66-    const result = await chrome.storage.session.get(RECENT_PAIRING_KEY);67-    const value = (result as Record<string, RecentPairing | undefined>)[RECENT_PAIRING_KEY];68-    if (!value) return null;69-    await chrome.storage.session.remove(RECENT_PAIRING_KEY);70-    return value;71-  } catch {72-    return null;73-  }74-}

view file at the parent

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

view file at the parent

src/background/sync-coordinator.ts+0 −125
@@ -1,125 +0,0 @@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 at the parent

src/background/sync-identity-encryption.test.ts+0 −111
@@ -1,111 +0,0 @@1-/**2- * sync-identity-encryption.test.ts — M12/SEC-0033- *4- * getSyncIdentity must encrypt the X25519 private key under the vault's5- * derived key before persisting, must require unlock to read, and must6- * migrate v0 (unencrypted) records to v1 (encrypted) on first read.7- */8-9-import { beforeEach, describe, expect, it, vi } from "vitest";10-11-describe("sync identity encryption (M12/SEC-003)", () => {12-  let storage: Record<string, unknown>;13-14-  beforeEach(() => {15-    vi.resetModules();16-    vi.useRealTimers();17-    storage = {};18-19-    const chromeMock = {20-      runtime: { id: "peach-extension", sendMessage: vi.fn() },21-      storage: {22-        local: {23-          get: vi.fn(async (key?: string | string[]) => {24-            if (!key) return storage;25-            if (typeof key === "string") return { [key]: storage[key] };26-            return key.reduce<Record<string, unknown>>((acc, k) => {27-              acc[k] = storage[k];28-              return acc;29-            }, {});30-          }),31-          set: vi.fn(async (next: Record<string, unknown>) => {32-            Object.assign(storage, next);33-          }),34-          remove: vi.fn(async (keys: string | string[]) => {35-            const list = Array.isArray(keys) ? keys : [keys];36-            for (const k of list) delete storage[k];37-          }),38-        },39-        session: {40-          get: vi.fn(async () => ({})),41-          set: vi.fn(async () => {}),42-          remove: vi.fn(async () => {}),43-        },44-      },45-      alarms: {46-        clear: vi.fn(),47-        create: vi.fn(),48-        onAlarm: { addListener: vi.fn() },49-      },50-      idle: {51-        setDetectionInterval: vi.fn(),52-        onStateChanged: { addListener: vi.fn() },53-      },54-    } as unknown as typeof chrome;55-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;56-  });57-58-  it("throws vault_locked when called before unlock", async () => {59-    const vault = await import("./vault");60-    await expect(vault.getSyncIdentity()).rejects.toThrow(/vault_locked/);61-  });62-63-  it("persisted record contains no plaintext private key field after generation", async () => {64-    const vault = await import("./vault");65-    await vault.createVault("test-pass-12345");66-    const { privateKeyBytes } = await vault.getSyncIdentity();67-68-    const stored = JSON.parse(storage.peach_sync_identity as string) as Record<string, unknown>;69-    expect(stored.priv).toBeUndefined();70-    expect(typeof stored.enc_priv).toBe("string");71-    expect(typeof stored.enc_nonce).toBe("string");72-    expect(typeof stored.enc_tag).toBe("string");73-    expect(typeof stored.pub).toBe("string");74-    expect(privateKeyBytes.length).toBe(32);75-  });76-77-  it("subsequent reads return the same private key bytes", async () => {78-    const vault = await import("./vault");79-    await vault.createVault("test-pass-12345");80-    const first = await vault.getSyncIdentity();81-    const second = await vault.getSyncIdentity();82-    expect(Array.from(second.privateKeyBytes)).toEqual(Array.from(first.privateKeyBytes));83-    expect(second.publicKeyHex).toBe(first.publicKeyHex);84-  });85-86-  it("migrates v0 (unencrypted priv/pub) record to v1 encrypted form", async () => {87-    const vault = await import("./vault");88-    await vault.createVault("test-pass-12345");89-90-    // Overwrite with a v0-shaped record.91-    const fakePriv = new Uint8Array(32).fill(7);92-    const fakePub = new Uint8Array(32).fill(8);93-    const b64 = (b: Uint8Array) => {94-      let s = "";95-      for (const v of b) s += String.fromCharCode(v);96-      return btoa(s);97-    };98-    storage.peach_sync_identity = JSON.stringify({99-      priv: b64(fakePriv),100-      pub: b64(fakePub),101-    });102-103-    const result = await vault.getSyncIdentity();104-    expect(Array.from(result.privateKeyBytes)).toEqual(Array.from(fakePriv));105-106-    // Storage was upgraded.107-    const stored = JSON.parse(storage.peach_sync_identity as string) as Record<string, unknown>;108-    expect(stored.priv).toBeUndefined();109-    expect(typeof stored.enc_priv).toBe("string");110-  });111-});

view file at the parent

src/background/sync-info-sheet.test.ts+0 −184
@@ -1,184 +0,0 @@1-import { describe, it, expect, beforeEach } from 'vitest';2-import { vi } from 'vitest';3-4-import type { Message, MessageResponse } from '../shared/types';5-6-type StorageShape = Record<string, unknown>;7-type MessageListener = (8-  msg: unknown,9-  sender: chrome.runtime.MessageSender,10-  sendResponse: (response: MessageResponse) => void11-) => boolean;12-13-describe('hasPaired flag', () => {14-  let storage: StorageShape;15-16-  function installChromeMock(s: StorageShape): void {17-    const chromeMock = {18-      storage: {19-        local: {20-          get: vi.fn(async (key?: string | string[]) => {21-            if (!key) return s;22-            if (typeof key === 'string') return { [key]: s[key] };23-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = s[k]; return acc; }, {});24-          }),25-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(s, next); }),26-          remove: vi.fn(async (keys: string | string[]) => {27-            const list = Array.isArray(keys) ? keys : [keys];28-            for (const key of list) delete s[key];29-          }),30-        },31-      },32-    } as unknown as typeof chrome;33-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;34-  }35-36-  beforeEach(async () => {37-    vi.resetModules();38-    storage = {};39-    installChromeMock(storage);40-  });41-42-  async function clearStorage(): Promise<void> {43-    for (const key of Object.keys(storage)) delete storage[key];44-  }45-46-  it('getHasPaired returns false when never paired', async () => {47-    await clearStorage();48-    const { getHasPaired } = await import('./sync-storage');49-    const result = await getHasPaired();50-    expect(result).toBe(false);51-  });52-53-  it('setHasPaired persists across calls', async () => {54-    const { setHasPaired, getHasPaired } = await import('./sync-storage');55-    await setHasPaired();56-    const result = await getHasPaired();57-    expect(result).toBe(true);58-  });59-60-  it('hasPaired remains true after multiple calls to setHasPaired', async () => {61-    const { setHasPaired, getHasPaired } = await import('./sync-storage');62-    await setHasPaired();63-    await setHasPaired(); // idempotent64-    const result = await getHasPaired();65-    expect(result).toBe(true);66-  });67-});68-69-describe('PAIRING_COMPLETED handler', () => {70-  let storage: StorageShape;71-  let sessionStorage: StorageShape;72-  let listener: MessageListener;73-74-  beforeEach(async () => {75-    vi.resetModules();76-    vi.useRealTimers();77-    storage = { peach_settings: { breachMonitoring: false } };78-    sessionStorage = {};79-80-    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));81-    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);82-83-    const chromeMock = {84-      runtime: {85-        id: 'peach-extension',86-        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },87-        sendMessage: vi.fn(async () => ({ ok: true })),88-      },89-      tabs: {90-        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {91-          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }92-          return Promise.resolve([{ id: 1 }]);93-        }),94-        sendMessage: vi.fn(async () => ({ ok: true })),95-      },96-      storage: {97-        local: {98-          get: vi.fn(async (key?: string | string[]) => {99-            if (!key) return storage;100-            if (typeof key === 'string') return { [key]: storage[key] };101-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});102-          }),103-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),104-          remove: vi.fn(async (keys: string | string[]) => {105-            const list = Array.isArray(keys) ? keys : [keys];106-            for (const key of list) delete storage[key];107-          }),108-        },109-        session: {110-          get: vi.fn(async (key?: string | string[]) => {111-            if (!key) return sessionStorage;112-            if (typeof key === 'string') return { [key]: sessionStorage[key] };113-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});114-          }),115-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),116-          remove: vi.fn(async (keys: string | string[]) => {117-            const list = Array.isArray(keys) ? keys : [keys];118-            for (const key of list) delete sessionStorage[key];119-          }),120-          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),121-        },122-      },123-      alarms: {124-        clear: vi.fn(), create: vi.fn(),125-        onAlarm: { addListener: vi.fn() },126-      },127-      idle: {128-        setDetectionInterval: vi.fn(),129-        onStateChanged: { addListener: vi.fn() },130-      },131-      scripting: { executeScript: vi.fn(async () => []) },132-    } as unknown as typeof chrome;133-134-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;135-136-    const messages = await import('./messages');137-    messages.setupMessageHandler();138-    const vault = await import('./vault');139-    await vault.ensureSettingsLoaded();140-  });141-142-  async function dispatch(message: Message): Promise<MessageResponse> {143-    return new Promise<MessageResponse>((resolve) => {144-      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);145-      expect(keepOpen).toBe(true);146-    });147-  }148-149-  it('flag is set when PAIRING_COMPLETED fires', async () => {150-    // M13/SEC-005 — PAIRING_COMPLETED now requires the publicKeyHex to have151-    // come from a real Noise handshake. Tests must register the verified key152-    // explicitly; production paths do this from doVaultSync / responder auth.153-    const messages = await import('./messages');154-    messages.markPairingHandshakeVerified('abc123');155-    await dispatch({156-      type: 'PAIRING_COMPLETED',157-      payload: {158-        deviceName: 'iPhone',159-        publicKeyHex: 'abc123',160-        deviceIp: '192.168.1.50',161-      },162-    });163-    const { getHasPaired } = await import('./sync-storage');164-    const paired = await getHasPaired();165-    expect(paired).toBe(true);166-  });167-168-  it('PAIRING_COMPLETED stores device with lastKnownIp and deviceName', async () => {169-    const messages = await import('./messages');170-    messages.markPairingHandshakeVerified('abc123');171-    await dispatch({172-      type: 'PAIRING_COMPLETED',173-      payload: {174-        deviceName: 'iPhone',175-        publicKeyHex: 'abc123',176-        deviceIp: '192.168.1.50',177-      },178-    });179-    const { getPairedDevices } = await import('./sync-storage');180-    const devices = await getPairedDevices();181-    expect(devices[0].lastKnownIp).toBe('192.168.1.50');182-    expect(devices[0].displayName).toBe('iPhone');183-  });184-});

view file at the parent

src/background/sync-network.ts+0 −47
@@ -1,47 +0,0 @@1-export interface NetworkInfo {2-  ssid?: string;3-  bssid?: string;4-  isTailscale: boolean;5-  ipAddress?: string;6-}7-8-export function isTailscaleIP(ip: string): boolean {9-  const parts = ip.split('.').map(Number);10-  if (parts.length !== 4 || parts.some(isNaN)) return false;11-  const [a, b] = parts;12-  return a === 100 && b >= 64 && b <= 127;13-}14-15-export async function isOnTailscale(): Promise<boolean> {16-  try {17-    if (typeof chrome === 'undefined' || !(chrome as unknown as { system?: { network?: { getNetworkInterfaces: () => Promise<Array<{ address: string }>> } } }).system?.network) return false;18-    const networkApi = (chrome as unknown as { system: { network: { getNetworkInterfaces: () => Promise<Array<{ address: string }>> } } }).system.network;19-    const interfaces = await networkApi.getNetworkInterfaces();20-    return interfaces.some((iface) => isTailscaleIP(iface.address));21-  } catch {22-    return false;23-  }24-}25-26-export async function getCurrentNetwork(): Promise<NetworkInfo> {27-  const info: NetworkInfo = { isTailscale: false };28-29-  try {30-    const chromeTyped = chrome as unknown as { system?: { network?: { getNetworkInterfaces: () => Promise<Array<{ address: string; name?: string }>> } } };31-    if (chromeTyped.system?.network) {32-      const networkApi = chromeTyped.system.network;33-      const interfaces = await networkApi.getNetworkInterfaces();34-      const tailscaleIface = interfaces.find((iface) => isTailscaleIP(iface.address));35-      if (tailscaleIface) {36-        info.isTailscale = true;37-        info.ipAddress = tailscaleIface.address;38-      } else if (interfaces.length > 0) {39-        info.ipAddress = interfaces[0].address;40-      }41-    }42-  } catch {43-    // Fallback: network info not available44-  }45-46-  return info;47-}

view file at the parent

src/background/sync-pro-gate.test.ts+0 −169
@@ -1,169 +0,0 @@1-import { describe, it, expect, beforeEach } from 'vitest';2-import { vi } from 'vitest';3-4-import type { Message, MessageResponse } from '../shared/types';5-6-type StorageShape = Record<string, unknown>;7-type MessageListener = (8-  msg: unknown,9-  sender: chrome.runtime.MessageSender,10-  sendResponse: (response: MessageResponse) => void11-) => boolean;12-13-describe('sync Pro gating', () => {14-  let storage: StorageShape;15-  let sessionStorage: StorageShape;16-  let listener: MessageListener;17-18-  beforeEach(async () => {19-    vi.resetModules();20-    vi.useRealTimers();21-    storage = { peach_settings: { breachMonitoring: false } };22-    sessionStorage = {};23-24-    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25-    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26-    // Default: desktop (no Capacitor)27-    (globalThis as Record<string, unknown>).Capacitor = undefined;28-29-    const chromeMock = {30-      runtime: {31-        id: 'peach-extension',32-        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },33-        sendMessage: vi.fn(async () => ({ ok: true })),34-      },35-      tabs: {36-        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {37-          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }38-          return Promise.resolve([{ id: 1 }]);39-        }),40-        sendMessage: vi.fn(async () => ({ ok: true })),41-      },42-      storage: {43-        local: {44-          get: vi.fn(async (key?: string | string[]) => {45-            if (!key) return storage;46-            if (typeof key === 'string') return { [key]: storage[key] };47-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});48-          }),49-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),50-          remove: vi.fn(async (keys: string | string[]) => {51-            const list = Array.isArray(keys) ? keys : [keys];52-            for (const key of list) delete storage[key];53-          }),54-        },55-        session: {56-          get: vi.fn(async (key?: string | string[]) => {57-            if (!key) return sessionStorage;58-            if (typeof key === 'string') return { [key]: sessionStorage[key] };59-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});60-          }),61-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),62-          remove: vi.fn(async (keys: string | string[]) => {63-            const list = Array.isArray(keys) ? keys : [keys];64-            for (const key of list) delete sessionStorage[key];65-          }),66-          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),67-        },68-      },69-      alarms: {70-        clear: vi.fn(), create: vi.fn(),71-        onAlarm: { addListener: vi.fn() },72-      },73-      idle: {74-        setDetectionInterval: vi.fn(),75-        onStateChanged: { addListener: vi.fn() },76-      },77-      scripting: { executeScript: vi.fn(async () => []) },78-    } as unknown as typeof chrome;79-80-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;81-82-    const messages = await import('./messages');83-    messages.setupMessageHandler();84-    const vault = await import('./vault');85-    await vault.ensureSettingsLoaded();86-  });87-88-  async function dispatch(message: Message): Promise<MessageResponse> {89-    return new Promise<MessageResponse>((resolve) => {90-      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);91-      expect(keepOpen).toBe(true);92-    });93-  }94-95-  async function mockNoPro(): Promise<void> {96-    const license = await import('./license');97-    vi.spyOn(license, 'isProUser').mockResolvedValue(false);98-  }99-100-  async function mockPro(): Promise<void> {101-    const license = await import('./license');102-    vi.spyOn(license, 'isProUser').mockResolvedValue(true);103-  }104-105-  function mockDesktop(): void {106-    (globalThis as Record<string, unknown>).Capacitor = undefined;107-  }108-109-  function mockNative(): void {110-    (globalThis as Record<string, unknown>).Capacitor = { isNativePlatform: () => true };111-  }112-113-  describe('pairing initiation — free', () => {114-    it('START_PAIRING_SERVER succeeds without Pro', async () => {115-      await mockNoPro();116-      mockDesktop();117-      const result = await dispatch({ type: 'START_PAIRING_SERVER' });118-      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);119-    });120-121-    it('ACCEPT_PAIRING succeeds without Pro', async () => {122-      await mockNoPro();123-      const result = await dispatch({124-        type: 'ACCEPT_PAIRING',125-        payload: { token: 'test-token' },126-      });127-      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);128-    });129-130-    it('SCAN_PAIRING_QR does not return pro_required (returns android_only on desktop)', async () => {131-      await mockNoPro();132-      mockDesktop();133-      const result = await dispatch({134-        type: 'SCAN_PAIRING_QR',135-        payload: { token: '123456' },136-      });137-      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);138-    });139-  });140-141-  describe('ongoing sync — Pro required', () => {142-    it('TRIGGER_SYNC is Pro-gated on desktop', async () => {143-      await mockNoPro();144-      mockDesktop();145-      const result = await dispatch({ type: 'TRIGGER_SYNC' });146-      expect(result.ok).toBe(false);147-      expect((result as { error: string }).error).toMatch(/pro_required/);148-    });149-150-    it('TRIGGER_SYNC with Pro on desktop does not return pro_required', async () => {151-      await mockPro();152-      mockDesktop();153-      const vault = await import('./vault');154-      await vault.createVault('test-pass-12345');155-      const result = await dispatch({ type: 'TRIGGER_SYNC' });156-      expect((result as { error?: string }).error ?? '').not.toMatch(/pro_required/);157-    });158-159-    it('TRIGGER_SYNC with Pro on native succeeds', async () => {160-      await mockPro();161-      mockNative();162-      const vault = await import('./vault');163-      await vault.createVault('test-pass-12345');164-      const result = await dispatch({ type: 'TRIGGER_SYNC' });165-      expect(result.ok).toBe(true);166-      expect((result as { data: { mode: string } }).data?.mode).toBe('server');167-    });168-  });169-});

view file at the parent

src/background/sync-screen.test.ts+0 −175
@@ -1,175 +0,0 @@1-import { describe, it, expect, beforeEach } from 'vitest';2-import { vi } from 'vitest';3-4-import type { Message, MessageResponse } from '../shared/types';5-6-type StorageShape = Record<string, unknown>;7-type MessageListener = (8-  msg: unknown,9-  sender: chrome.runtime.MessageSender,10-  sendResponse: (response: MessageResponse) => void11-) => boolean;12-13-describe('sync screen handlers', () => {14-  let storage: StorageShape;15-  let sessionStorage: StorageShape;16-  let listener: MessageListener;17-18-  beforeEach(async () => {19-    vi.resetModules();20-    vi.useRealTimers();21-    storage = { peach_settings: { breachMonitoring: false } };22-    sessionStorage = {};23-24-    vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, text: async () => '' })));25-    vi.stubGlobal('__PEACH_E2E_PASSKEYS__', false);26-27-    const chromeMock = {28-      runtime: {29-        id: 'peach-extension',30-        onMessage: { addListener: vi.fn((fn: MessageListener) => { listener = fn; }) },31-        sendMessage: vi.fn(async () => ({ ok: true })),32-      },33-      tabs: {34-        query: vi.fn((_, cb?: (tabs: unknown[]) => void) => {35-          if (typeof cb === 'function') { cb([{ id: 1 }]); return; }36-          return Promise.resolve([{ id: 1 }]);37-        }),38-        sendMessage: vi.fn(async () => ({ ok: true })),39-      },40-      storage: {41-        local: {42-          get: vi.fn(async (key?: string | string[]) => {43-            if (!key) return storage;44-            if (typeof key === 'string') return { [key]: storage[key] };45-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = storage[k]; return acc; }, {});46-          }),47-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(storage, next); }),48-          remove: vi.fn(async (keys: string | string[]) => {49-            const list = Array.isArray(keys) ? keys : [keys];50-            for (const key of list) delete storage[key];51-          }),52-        },53-        session: {54-          get: vi.fn(async (key?: string | string[]) => {55-            if (!key) return sessionStorage;56-            if (typeof key === 'string') return { [key]: sessionStorage[key] };57-            return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = sessionStorage[k]; return acc; }, {});58-          }),59-          set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(sessionStorage, next); }),60-          remove: vi.fn(async (keys: string | string[]) => {61-            const list = Array.isArray(keys) ? keys : [keys];62-            for (const key of list) delete sessionStorage[key];63-          }),64-          clear: vi.fn(async () => { for (const k of Object.keys(sessionStorage)) delete sessionStorage[k]; }),65-        },66-      },67-      alarms: {68-        clear: vi.fn(), create: vi.fn(),69-        onAlarm: { addListener: vi.fn() },70-      },71-      idle: {72-        setDetectionInterval: vi.fn(),73-        onStateChanged: { addListener: vi.fn() },74-      },75-      scripting: { executeScript: vi.fn(async () => []) },76-    } as unknown as typeof chrome;77-78-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;79-80-    const messages = await import('./messages');81-    messages.setupMessageHandler();82-    const vault = await import('./vault');83-    await vault.ensureSettingsLoaded();84-  });85-86-  async function dispatch(message: Message): Promise<MessageResponse> {87-    return new Promise<MessageResponse>((resolve) => {88-      const keepOpen = listener(message, { id: 'peach-extension' }, resolve);89-      expect(keepOpen).toBe(true);90-    });91-  }92-93-  function storageSet(key: string, value: unknown): void {94-    storage[key] = value;95-  }96-97-  describe('GET_PAIRED_DEVICES handler', () => {98-    it('returns empty array when no devices paired', async () => {99-      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });100-      expect(result.ok).toBe(true);101-      const data = (result as { data: { devices: unknown[] } }).data;102-      expect(data.devices).toEqual([]);103-    });104-105-    it('returns paired devices list', async () => {106-      storage['peach_paired_devices'] = [107-        {108-          deviceId: 'abc',109-          displayName: 'MacBook',110-          x25519PublicKey: 'abc',111-          pairedAt: Date.now(),112-          lastKnownIp: '192.168.1.1',113-        },114-      ];115-      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });116-      const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;117-      expect(data.devices.length).toBe(1);118-      expect(data.devices[0].displayName).toBe('MacBook');119-    });120-121-    it('does not expose private keys in paired devices', async () => {122-      storage['peach_paired_devices'] = [123-        {124-          deviceId: 'abc',125-          displayName: 'MacBook',126-          x25519PublicKey: 'abc',127-          pairedAt: Date.now(),128-          lastKnownIp: '192.168.1.1',129-        },130-      ];131-      const result = await dispatch({ type: 'GET_PAIRED_DEVICES' });132-      const data = (result as { data: { devices: Array<Record<string, unknown>> } }).data;133-      expect(data.devices[0].privateKey).toBeUndefined();134-    });135-  });136-137-  describe('GET_SYNC_STATUS handler', () => {138-    it('returns never status when never synced', async () => {139-      const result = await dispatch({ type: 'GET_SYNC_STATUS' });140-      expect(result.ok).toBe(true);141-      const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;142-      expect(data.status).toBe('never');143-      expect(data.lastSyncAt).toBeNull();144-    });145-146-    it('returns up_to_date after successful sync', async () => {147-      storageSet('peach_last_sync_at', String(Date.now()));148-      const result = await dispatch({ type: 'GET_SYNC_STATUS' });149-      const data = (result as { data: { status: string; lastSyncAt: unknown } }).data;150-      expect(data.status).toBe('up_to_date');151-      expect(data.lastSyncAt).toBeTruthy();152-    });153-  });154-155-  describe('RESET_SYNC_IDENTITY handler', () => {156-    it('is Pro-gated', async () => {157-      const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });158-      expect(result.ok).toBe(false);159-      expect((result as { error: string }).error).toMatch(/pro_required/);160-    });161-162-    it('clears paired devices on reset for Pro users', async () => {163-      storage['peach_paired_devices'] = [164-        { deviceId: 'a', displayName: 'iPhone', x25519PublicKey: 'pk-a', pairedAt: 1 },165-        { deviceId: 'b', displayName: 'MacBook', x25519PublicKey: 'pk-b', pairedAt: 2 },166-      ];167-      const license = await import('./license');168-      vi.spyOn(license, 'isProUser').mockResolvedValue(true);169-      const result = await dispatch({ type: 'RESET_SYNC_IDENTITY' });170-      expect(result.ok).toBe(true);171-      expect((result as { data: { removedDevices: number } }).data?.removedDevices).toBe(2);172-      expect(storage['peach_paired_devices']).toEqual([]);173-    });174-  });175-});

view file at the parent

src/background/sync-storage.ts+0 −146
@@ -1,146 +0,0 @@1-import type { TrustedNetwork, PairedDevice, IncomingPairingRequest } from '../shared/sync-types';2-3-const TRUSTED_NETWORKS_KEY = 'peach_trusted_networks';4-const PAIRED_DEVICES_KEY = 'peach_paired_devices';5-const INCOMING_PAIR_KEY = 'peach_incoming_pair';6-const PEACH_HAS_PAIRED_KEY = 'peach_has_paired';7-8-// ---------------------------------------------------------------------------9-// Has-paired flag10-//11-// Set once on first successful pairing. Never cleared — controls whether the12-// sync info sheet is shown.13-// ---------------------------------------------------------------------------14-15-export async function getHasPaired(): Promise<boolean> {16-  try {17-    const result = await chrome.storage.local.get(PEACH_HAS_PAIRED_KEY);18-    return (result as Record<string, unknown>)[PEACH_HAS_PAIRED_KEY] === true;19-  } catch {20-    return false;21-  }22-}23-24-export async function setHasPaired(): Promise<void> {25-  await chrome.storage.local.set({ [PEACH_HAS_PAIRED_KEY]: true });26-}27-28-// ---------------------------------------------------------------------------29-// Trusted Networks30-// ---------------------------------------------------------------------------31-32-export async function getTrustedNetworks(): Promise<TrustedNetwork[]> {33-  try {34-    const result = await chrome.storage.local.get(TRUSTED_NETWORKS_KEY);35-    return (result as { peach_trusted_networks?: TrustedNetwork[] })[TRUSTED_NETWORKS_KEY] ?? [];36-  } catch {37-    return [];38-  }39-}40-41-export async function addTrustedNetwork(network: TrustedNetwork): Promise<void> {42-  try {43-    const networks = await getTrustedNetworks();44-    networks.push(network);45-    await chrome.storage.local.set({ [TRUSTED_NETWORKS_KEY]: networks });46-  } catch (err) {47-    throw new Error(`Failed to add trusted network: ${err}`);48-  }49-}50-51-export async function removeTrustedNetwork(addedAt: number): Promise<void> {52-  try {53-    const networks = await getTrustedNetworks();54-    const filtered = networks.filter((n) => n.addedAt !== addedAt);55-    await chrome.storage.local.set({ [TRUSTED_NETWORKS_KEY]: filtered });56-  } catch (err) {57-    throw new Error(`Failed to remove trusted network: ${err}`);58-  }59-}60-61-// ---------------------------------------------------------------------------62-// Paired Devices63-// ---------------------------------------------------------------------------64-65-export async function getPairedDevices(): Promise<PairedDevice[]> {66-  try {67-    const result = await chrome.storage.local.get(PAIRED_DEVICES_KEY);68-    return (result as { peach_paired_devices?: PairedDevice[] })[PAIRED_DEVICES_KEY] ?? [];69-  } catch {70-    return [];71-  }72-}73-74-export async function addPairedDevice(device: PairedDevice): Promise<void> {75-  try {76-    const devices = await getPairedDevices();77-    const exists = devices.some((d) => d.deviceId === device.deviceId);78-    if (exists) throw new Error(`Device ${device.deviceId} is already paired`);79-    devices.push(device);80-    await chrome.storage.local.set({ [PAIRED_DEVICES_KEY]: devices });81-  } catch (err) {82-    throw new Error(`Failed to add paired device: ${err}`);83-  }84-}85-86-export async function removePairedDevice(deviceId: string): Promise<void> {87-  try {88-    const devices = await getPairedDevices();89-    const filtered = devices.filter((d) => d.deviceId !== deviceId);90-    await chrome.storage.local.set({ [PAIRED_DEVICES_KEY]: filtered });91-  } catch (err) {92-    throw new Error(`Failed to remove paired device: ${err}`);93-  }94-}95-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-111-// ---------------------------------------------------------------------------112-// Incoming Pairing Request113-//114-// Stored in session storage so the request disappears when the browser closes115-// and never lands on disk. The 60-second expiry is enforced by the consumer116-// (`getIncomingPairingRequest` returns null past expiry and clears storage).117-// ---------------------------------------------------------------------------118-119-export async function setIncomingPairingRequest(120-  request: IncomingPairingRequest121-): Promise<void> {122-  await chrome.storage.session.set({ [INCOMING_PAIR_KEY]: request });123-}124-125-export async function getIncomingPairingRequest(): Promise<IncomingPairingRequest | null> {126-  try {127-    const result = await chrome.storage.session.get(INCOMING_PAIR_KEY);128-    const request = (result as { peach_incoming_pair?: IncomingPairingRequest })[INCOMING_PAIR_KEY];129-    if (!request) return null;130-    if (Date.now() >= request.expiresAt) {131-      await clearIncomingPairingRequest();132-      return null;133-    }134-    return request;135-  } catch {136-    return null;137-  }138-}139-140-export async function clearIncomingPairingRequest(): Promise<void> {141-  try {142-    await chrome.storage.session.remove(INCOMING_PAIR_KEY);143-  } catch {144-    // session storage may be unavailable in some contexts; safe to ignore.145-  }146-}

view file at the parent

src/background/sync.adversarial.test.ts+0 −883
@@ -1,883 +0,0 @@1-/**2- * sync.adversarial.test.ts — Layer 3 §3.5 adversarial sync tests.3- *4- * Exercises vault.applyIncomingVault timestamp logic, license non-overwrite,5- * malformed blob handling, and pairing-token lifecycle. Parameterized for6- * >=150 tests.7- *8- * Note: there is no APPLY_INCOMING_VAULT message; sync goes through9- * sync-coordinator/runBlobSyncSession. We exercise the underlying10- * applyIncomingVault export directly for the data-plane tests, and the11- * START_PAIRING_SERVER / STOP_PAIRING_SERVER / SCAN_PAIRING_QR /12- * ACCEPT_PAIRING handlers via the message bus for pairing tests.13- */14-15-import { beforeEach, describe, expect, it, vi } from "vitest";16-17-import type { Message, MessageResponse } from "../shared/types";18-19-type StorageShape = Record<string, unknown>;20-type MessageListener = (21-  msg: unknown,22-  sender: chrome.runtime.MessageSender,23-  sendResponse: (response: MessageResponse) => void24-) => boolean;25-26-describe("sync adversarial", () => {27-  let storage: StorageShape;28-  let sessionStorage: StorageShape;29-  let listener: MessageListener | undefined;30-31-  beforeEach(async () => {32-    vi.resetModules();33-    vi.useRealTimers();34-    storage = { peach_settings: { breachMonitoring: false } };35-    sessionStorage = {};36-    listener = undefined;37-38-    vi.stubGlobal(39-      "fetch",40-      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" }))41-    );42-    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);43-44-    const chromeMock = {45-      runtime: {46-        id: "peach-extension",47-        onMessage: {48-          addListener: vi.fn((fn: MessageListener) => {49-            listener = fn;50-          }),51-        },52-        sendMessage: vi.fn(async () => ({ ok: true })),53-      },54-      tabs: {55-        query: vi.fn((_q: unknown, cb?: (tabs: unknown[]) => void) => {56-          if (typeof cb === "function") {57-            cb([{ id: 1 }]);58-            return;59-          }60-          return Promise.resolve([{ id: 1 }]);61-        }),62-        sendMessage: vi.fn(async () => ({ ok: true })),63-      },64-      storage: {65-        local: {66-          get: vi.fn(async (key?: string | string[]) => {67-            if (!key) return storage;68-            if (typeof key === "string") return { [key]: storage[key] };69-            return key.reduce<Record<string, unknown>>((acc, k) => {70-              acc[k] = storage[k];71-              return acc;72-            }, {});73-          }),74-          set: vi.fn(async (next: Record<string, unknown>) => {75-            Object.assign(storage, next);76-          }),77-          remove: vi.fn(async (keys: string | string[]) => {78-            const list = Array.isArray(keys) ? keys : [keys];79-            for (const key of list) delete storage[key];80-          }),81-        },82-        session: {83-          get: vi.fn(async (key?: string | string[]) => {84-            if (!key) return sessionStorage;85-            if (typeof key === "string") return { [key]: sessionStorage[key] };86-            return key.reduce<Record<string, unknown>>((acc, k) => {87-              acc[k] = sessionStorage[k];88-              return acc;89-            }, {});90-          }),91-          set: vi.fn(async (next: Record<string, unknown>) => {92-            Object.assign(sessionStorage, next);93-          }),94-          remove: vi.fn(async (keys: string | string[]) => {95-            const list = Array.isArray(keys) ? keys : [keys];96-            for (const key of list) delete sessionStorage[key];97-          }),98-          clear: vi.fn(async () => {99-            for (const k of Object.keys(sessionStorage)) delete sessionStorage[k];100-          }),101-        },102-      },103-      alarms: {104-        clear: vi.fn(),105-        create: vi.fn(),106-        onAlarm: { addListener: vi.fn() },107-      },108-      idle: {109-        setDetectionInterval: vi.fn(),110-        onStateChanged: { addListener: vi.fn() },111-      },112-      scripting: { executeScript: vi.fn(async () => []) },113-    } as unknown as typeof chrome;114-115-    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;116-117-    const messages = await import("./messages");118-    messages.setupMessageHandler();119-    const vault = await import("./vault");120-    await vault.ensureSettingsLoaded();121-  });122-123-  async function dispatch(message: Message): Promise<MessageResponse> {124-    if (!listener) throw new Error("listener not registered");125-    return new Promise<MessageResponse>((resolve) => {126-      const keepOpen = listener!(127-        message,128-        { id: "peach-extension" } as chrome.runtime.MessageSender,129-        resolve130-      );131-      expect(keepOpen).toBe(true);132-    });133-  }134-135-  async function setupBlobs() {136-    const vault = await import("./vault");137-    await vault.createVault("test-pass-12345");138-    // Force a known last_modified_at.139-    storage.peach_last_modified_at = String(1_700_000_000_000);140-    return vault;141-  }142-143-  function makeIncomingBlob(slotsLike?: {144-    a?: Record<string, unknown>;145-    b?: Record<string, unknown>;146-  }): string {147-    const a = slotsLike?.a ?? storage.peach_vault_a;148-    const b = slotsLike?.b ?? storage.peach_vault_b;149-    return JSON.stringify({ a, b });150-  }151-152-  // ── applyIncomingVault timestamp logic ──────────────────────────────────153-154-  describe("applyIncomingVault timestamp adversarial", () => {155-    const olderDeltas = [156-      -1, -10, -1000, -60_000, -3_600_000, -86_400_000, -1_000_000_000,157-    ];158-    for (const delta of olderDeltas) {159-      it(`incoming vault with timestamp delta ${delta}ms (older): applied=false`, async () => {160-        const vault = await setupBlobs();161-        const remoteTs = 1_700_000_000_000 + delta;162-        const applied = await vault.applyIncomingVault(163-          makeIncomingBlob(),164-          remoteTs165-        );166-        expect(applied).toBe(false);167-      });168-    }169-170-    it("same timestamp: applied=false (not equal-overwrite)", async () => {171-      const vault = await setupBlobs();172-      const applied = await vault.applyIncomingVault(173-        makeIncomingBlob(),174-        1_700_000_000_000175-      );176-      expect(applied).toBe(false);177-    });178-179-    const newerDeltas = [1, 10, 1_000, 60_000, 3_600_000, 1_000_000_000];180-    for (const delta of newerDeltas) {181-      it(`incoming vault delta +${delta}ms (newer): applied=true`, async () => {182-        const vault = await setupBlobs();183-        const applied = await vault.applyIncomingVault(184-          makeIncomingBlob(),185-          1_700_000_000_000 + delta186-        );187-        expect(applied).toBe(true);188-      });189-    }190-191-    // AUD-006: applyIncomingVault now bounds the remote timestamp by a192-    // 5-minute clock-skew budget so a paired peer with a bad/malicious clock193-    // cannot pin last-write-wins permanently with Number.MAX_SAFE_INTEGER.194-    describe("AUD-006: timestamp skew budget", () => {195-      it("rejects remote timestamp beyond 5-minute skew budget", async () => {196-        const vault = await setupBlobs();197-        const farFuture = Date.now() + 6 * 60 * 1_000;198-        const applied = await vault.applyIncomingVault(199-          makeIncomingBlob(),200-          farFuture201-        );202-        expect(applied).toBe(false);203-      });204-205-      it("rejects Number.MAX_SAFE_INTEGER timestamp", async () => {206-        const vault = await setupBlobs();207-        const applied = await vault.applyIncomingVault(208-          makeIncomingBlob(),209-          Number.MAX_SAFE_INTEGER210-        );211-        expect(applied).toBe(false);212-      });213-214-      it("rejects Date.now() * 100 (previously accepted)", async () => {215-        const vault = await setupBlobs();216-        const applied = await vault.applyIncomingVault(217-          makeIncomingBlob(),218-          Date.now() * 100219-        );220-        expect(applied).toBe(false);221-      });222-223-      it("accepts remote timestamp within 5-minute skew budget", async () => {224-        const vault = await setupBlobs();225-        const slightlyFuture = Date.now() + 4 * 60 * 1_000;226-        const applied = await vault.applyIncomingVault(227-          makeIncomingBlob(),228-          slightlyFuture229-        );230-        expect(applied).toBe(true);231-      });232-233-      it("accepts legitimately newer remote timestamp", async () => {234-        const vault = await setupBlobs();235-        const applied = await vault.applyIncomingVault(236-          makeIncomingBlob(),237-          1_700_000_000_000 + 10_000238-        );239-        expect(applied).toBe(true);240-      });241-    });242-  });243-244-  // ── applyIncomingVault malformed blob ───────────────────────────────────245-246-  describe("applyIncomingVault malformed blob handling", () => {247-    const malformedBlobs = [248-      "",249-      "not json",250-      "{",251-      "[]",252-      "null",253-      "true",254-      "42",255-      '{"a": null}',256-      '{"a": "not-an-object"}',257-    ];258-    for (const blob of malformedBlobs) {259-      it(`malformed blob ${JSON.stringify(blob).slice(0, 30)} does not poison local vault`, async () => {260-        const vault = await setupBlobs();261-        const before = storage.peach_vault_a;262-        let threw = false;263-        try {264-          await vault.applyIncomingVault(blob, Date.now() + 100_000);265-        } catch {266-          threw = true;267-        }268-        // Either it threw cleanly or it ran. Either way, original vault must269-        // still be unlockable.270-        const reloaded = storage.peach_vault_a;271-        if (!threw) {272-          // If it didn't throw, slot A may have been overwritten with the273-          // value of `a` from the parsed blob. That's a poison surface.274-          // We pin observed behaviour: either it threw, or the slot is now275-          // some new value.276-          expect(reloaded).not.toBe(undefined);277-        } else {278-          expect(reloaded).toBe(before);279-        }280-      });281-    }282-  });283-284-  // ── applyIncomingVault license non-overwrite ────────────────────────────285-286-  describe("applyIncomingVault license handling", () => {287-    it("existing license is not overwritten by incoming license", async () => {288-      const vault = await setupBlobs();289-      storage.peach_license = "my-existing-license";290-      await vault.applyIncomingVault(291-        makeIncomingBlob(),292-        Date.now() + 100_000,293-        "their-different-license"294-      );295-      expect(storage.peach_license).toBe("my-existing-license");296-    });297-298-    it("no local license: invalid incoming license is rejected (L16/SEC-010)", async () => {299-      // L16 hardens applyIncomingVault to validate the signature before300-      // adopting. Without a real signed license fixture, an unsigned string301-      // must be silently dropped — a peer cannot plant arbitrary text as302-      // our peach_license.303-      const vault = await setupBlobs();304-      delete storage.peach_license;305-      await vault.applyIncomingVault(306-        makeIncomingBlob(),307-        Date.now() + 100_000,308-        "incoming-license"309-      );310-      expect(storage.peach_license).toBeUndefined();311-    });312-313-    it("no incoming license: local license is unchanged", async () => {314-      const vault = await setupBlobs();315-      storage.peach_license = "local-pro";316-      await vault.applyIncomingVault(317-        makeIncomingBlob(),318-        Date.now() + 100_000319-      );320-      expect(storage.peach_license).toBe("local-pro");321-    });322-323-    it("empty-string incoming license is treated as no license", async () => {324-      const vault = await setupBlobs();325-      delete storage.peach_license;326-      await vault.applyIncomingVault(327-        makeIncomingBlob(),328-        Date.now() + 100_000,329-        ""330-      );331-      // Empty string is falsy; no adoption.332-      expect(storage.peach_license).toBeUndefined();333-    });334-  });335-336-  // ── applyIncomingVault snapshot behaviour ───────────────────────────────337-338-  describe("applyIncomingVault snapshot", () => {339-    it("a snapshot is taken before applying a newer incoming vault", async () => {340-      const vault = await setupBlobs();341-      const historyBefore = await vault.getVaultHistory();342-      await vault.applyIncomingVault(343-        makeIncomingBlob(),344-        Date.now() + 100_000345-      );346-      const historyAfter = await vault.getVaultHistory();347-      expect(historyAfter.length).toBeGreaterThanOrEqual(historyBefore.length);348-    });349-  });350-351-  // ── Pairing token adversarial ───────────────────────────────────────────352-353-  describe("pairing token lifecycle", () => {354-    it("START_PAIRING_SERVER yields a 6-digit token", async () => {355-      const r = await dispatch({ type: "START_PAIRING_SERVER" });356-      expect(r.ok).toBe(true);357-      const data = (r as { data: { token: string; expiresAt: number } }).data;358-      expect(data.token).toMatch(/^\d{6}$/);359-      expect(data.expiresAt).toBeGreaterThan(Date.now());360-    });361-362-    it("STOP_PAIRING_SERVER clears the token", async () => {363-      await dispatch({ type: "START_PAIRING_SERVER" });364-      const r = await dispatch({ type: "STOP_PAIRING_SERVER" });365-      expect(r.ok).toBe(true);366-    });367-368-    it("SCAN_PAIRING_QR returns android_only off mobile", async () => {369-      const r = await dispatch({370-        type: "SCAN_PAIRING_QR",371-        payload: { token: "123456" },372-      });373-      expect(r.ok).toBe(false);374-      expect(r.error).toBe("android_only");375-    });376-377-    it("SCAN_PAIRING_QR with garbage token still returns android_only off mobile", async () => {378-      const r = await dispatch({379-        type: "SCAN_PAIRING_QR",380-        payload: { token: "not-a-token" as unknown as string },381-      });382-      expect(r.ok).toBe(false);383-    });384-385-    it("ACCEPT_PAIRING always returns ok (free tier)", async () => {386-      const r = await dispatch({ type: "ACCEPT_PAIRING", payload: {} });387-      expect(r.ok).toBe(true);388-    });389-390-    for (let i = 0; i < 10; i++) {391-      it(`generated token #${i} is a fresh 6-digit code`, async () => {392-        const r = await dispatch({ type: "START_PAIRING_SERVER" });393-        const data = (r as { data: { token: string } }).data;394-        expect(data.token).toMatch(/^\d{6}$/);395-      });396-    }397-  });398-399-  // ── RESPOND_INCOMING_PAIRING adversarial ────────────────────────────────400-401-  describe("RESPOND_INCOMING_PAIRING adversarial", () => {402-    it("no pending request: returns no_pending_request", async () => {403-      const r = await dispatch({404-        type: "RESPOND_INCOMING_PAIRING",405-        payload: { accept: true },406-      });407-      expect(r.ok).toBe(false);408-      expect(r.error).toBe("no_pending_request");409-    });410-411-    it("expired request: getIncomingPairingRequest returns null", async () => {412-      await dispatch({413-        type: "DEBUG_INJECT_INCOMING_PAIRING",414-        payload: {415-          deviceName: "fake",416-          publicKeyHex: "00".repeat(32),417-        },418-      });419-      // Force expiry — overwrite the stored expiresAt to the past.420-      const sessionRaw = sessionStorage["peach_incoming_pair"] as421-        | Record<string, unknown>422-        | undefined;423-      if (sessionRaw && typeof sessionRaw === "object") {424-        (sessionRaw as Record<string, unknown>).expiresAt = Date.now() - 1000;425-      }426-      const r = await dispatch({427-        type: "RESPOND_INCOMING_PAIRING",428-        payload: { accept: true },429-      });430-      // Expired request causes either explicit "expired" or "no_pending_request"431-      // (because getIncomingPairingRequest auto-clears on expiry).432-      expect(r.ok).toBe(false);433-    });434-435-    it("missing payload returns no_pending or error", async () => {436-      const r = await dispatch({437-        type: "RESPOND_INCOMING_PAIRING",438-        payload: {} as { accept: boolean },439-      });440-      expect(r).toBeDefined();441-    });442-  });443-444-  // ── DEBUG_INJECT_INCOMING_PAIRING adversarial ───────────────────────────445-446-  describe("DEBUG_INJECT_INCOMING_PAIRING adversarial", () => {447-    it("missing fields returns missing_payload", async () => {448-      const r = await dispatch({449-        type: "DEBUG_INJECT_INCOMING_PAIRING",450-        payload: {},451-      });452-      expect(r.ok).toBe(false);453-      expect(r.error).toBe("missing_payload");454-    });455-456-    const partials = [457-      { deviceName: "x" },458-      { publicKeyHex: "y" },459-      { deviceName: "", publicKeyHex: "" },460-    ];461-    for (const [i, p] of partials.entries()) {462-      it(`partial payload #${i} returns missing_payload`, async () => {463-        const r = await dispatch({464-          type: "DEBUG_INJECT_INCOMING_PAIRING",465-          payload: p,466-        });467-        expect(r.ok).toBe(false);468-        expect(r.error).toBe("missing_payload");469-      });470-    }471-472-    it("valid payload returns ok", async () => {473-      const r = await dispatch({474-        type: "DEBUG_INJECT_INCOMING_PAIRING",475-        payload: {476-          deviceName: "test-device",477-          publicKeyHex: "ab".repeat(32),478-        },479-      });480-      expect(r.ok).toBe(true);481-    });482-  });483-484-  // ── GET_SYNC_STATUS shape ───────────────────────────────────────────────485-486-  describe("GET_SYNC_STATUS shape", () => {487-    it("never synced: status=never", async () => {488-      delete storage.peach_last_sync_at;489-      const r = await dispatch({ type: "GET_SYNC_STATUS" });490-      const data = (r as { data: { status: string } }).data;491-      expect(data.status).toBe("never");492-    });493-494-    it("with last_sync_at: status=up_to_date", async () => {495-      storage.peach_last_sync_at = String(Date.now());496-      const r = await dispatch({ type: "GET_SYNC_STATUS" });497-      const data = (r as { data: { status: string } }).data;498-      expect(data.status).toBe("up_to_date");499-    });500-501-    it("garbage last_sync_at: status=never", async () => {502-      storage.peach_last_sync_at = "not-a-number";503-      const r = await dispatch({ type: "GET_SYNC_STATUS" });504-      const data = (r as { data: { status: string } }).data;505-      expect(data.status).toBe("never");506-    });507-  });508-509-  // ── REMOVE_PAIRED_DEVICE adversarial ────────────────────────────────────510-511-  describe("REMOVE_PAIRED_DEVICE adversarial", () => {512-    const badIds = ["", "   ", "not-a-real-id", "\x00", "a".repeat(1000)];513-    for (const deviceId of badIds) {514-      it(`unknown deviceId ${JSON.stringify(deviceId.slice(0, 20))} succeeds (idempotent)`, async () => {515-        const r = await dispatch({516-          type: "REMOVE_PAIRED_DEVICE",517-          payload: { deviceId },518-        });519-        // The handler is idempotent — non-existent ids do not error.520-        expect(r.ok).toBe(true);521-      });522-    }523-  });524-525-  // ── Newer-timestamp sweep ────────────────────────────────────────────────526-527-  describe("timestamp boundary sweep", () => {528-    // AUD-006: MAX_SAFE_INTEGER is now beyond the 5-min skew budget, so529-    // "newer is accepted" only holds for timestamps not far in the future.530-    for (const baseTs of [0, 1, 1_000, 1_700_000_000_000]) {531-      it(`base ts ${baseTs}: applies when remote is strictly newer`, async () => {532-        const vault = await import("./vault");533-        await vault.createVault("test-pass-12345");534-        storage.peach_last_modified_at = String(baseTs);535-        const applied = await vault.applyIncomingVault(536-          makeIncomingBlob(),537-          baseTs + 1538-        );539-        expect(applied).toBe(true);540-      });541-542-      it(`base ts ${baseTs}: rejects when remote is equal`, async () => {543-        const vault = await import("./vault");544-        await vault.createVault("test-pass-12345");545-        storage.peach_last_modified_at = String(baseTs);546-        const applied = await vault.applyIncomingVault(547-          makeIncomingBlob(),548-          baseTs549-        );550-        expect(applied).toBe(false);551-      });552-553-      it(`base ts ${baseTs}: rejects when remote is strictly older`, async () => {554-        const vault = await import("./vault");555-        await vault.createVault("test-pass-12345");556-        storage.peach_last_modified_at = String(baseTs);557-        const applied = await vault.applyIncomingVault(558-          makeIncomingBlob(),559-          baseTs - 1560-        );561-        expect(applied).toBe(false);562-      });563-    }564-  });565-566-  // ── Token format adversarial ────────────────────────────────────────────567-568-  describe("token format adversarial", () => {569-    const badFormats = [570-      "",571-      "12345",572-      "1234567",573-      "abcdef",574-      "12 34 56",575-      "<script>",576-      "null",577-      "undefined",578-      "-12345",579-      "1.23456",580-      "🔢🔢🔢🔢🔢🔢",581-    ];582-    for (const token of badFormats) {583-      it(`SCAN_PAIRING_QR with token ${JSON.stringify(token)} returns android_only`, async () => {584-        const r = await dispatch({585-          type: "SCAN_PAIRING_QR",586-          payload: { token },587-        });588-        expect(r.ok).toBe(false);589-      });590-    }591-  });592-593-  // ── runBlobSyncSession via deps (no transport) ──────────────────────────594-595-  describe("runBlobSyncSession safety", () => {596-    it("locked vault returns skipped status", async () => {597-      const { runBlobSyncSession } = await import("./sync-coordinator");598-      // Don't unlock. Build a stub transport that never resolves.599-      const result = await runBlobSyncSession(600-        "initiator",601-        {602-          send: vi.fn(async () => {}),603-          receive: vi.fn(async () => new Uint8Array()),604-          close: vi.fn(async () => {}),605-        } as unknown as Parameters<typeof runBlobSyncSession>[1],606-        {607-          getVaultBlobForSync: async () => null,608-          applyIncomingVault: async () => false,609-          isVaultUnlocked: () => false,610-        }611-      );612-      expect(result.status).toBe("skipped");613-      expect(result.applied).toBe(false);614-    });615-  });616-617-  // H3/COR-005 — when applyIncomingVault succeeds but reloadEntriesAfterSync618-  // fails (the remote was encrypted under a different password), the vault619-  // must lock rather than let stale in-memory entries clobber the new blob.620-  describe("H3/COR-005 sync re-decrypt failure lock", () => {621-    async function buildRemoteBlobWithDifferentKey(): Promise<string> {622-      const vault = await import("./vault");623-      const slotA = await vault.loadSlotA();624-      if (!slotA) throw new Error("no slot A");625-      // Re-use the same slot A shape but with a fresh random salt + a626-      // garbage ciphertext. Decryption will fail because the salt doesn't627-      // match the current in-memory derivedKey.628-      const rand = (n: number) =>629-        Array.from(crypto.getRandomValues(new Uint8Array(n)))630-          .map(b => b.toString(16).padStart(2, "0"))631-          .join("");632-      const remoteA = {633-        ...slotA,634-        salt: rand(16),635-        nonce: rand(12),636-        ciphertext: rand(64),637-        tag: rand(16),638-      };639-      return JSON.stringify({ a: remoteA, b: storage.peach_vault_b });640-    }641-642-    it("vault locks when reloadEntriesAfterSync fails after apply", async () => {643-      const vault = await setupBlobs();644-      const messages = await import("./messages");645-      const deps = messages.buildSyncDeps();646-      const remote = await buildRemoteBlobWithDifferentKey();647-      // Newer timestamp so applyIncomingVault writes.648-      const remoteTs = 1_700_000_000_000 + 100_000;649-      expect(vault.isUnlocked()).toBe(true);650-      const applied = await deps.applyIncomingVault(remote, remoteTs);651-      expect(applied).toBe(true);652-      expect(vault.isUnlocked()).toBe(false);653-    });654-655-    it("stale in-memory entries cannot clobber remote blob after sync", async () => {656-      await setupBlobs();657-      const messages = await import("./messages");658-      const deps = messages.buildSyncDeps();659-      const remote = await buildRemoteBlobWithDifferentKey();660-      const remoteTs = 1_700_000_000_000 + 100_000;661-      await deps.applyIncomingVault(remote, remoteTs);662-      // Vault is locked — any vault mutation must fail.663-      const r = await dispatch({664-        type: "ADD_ENTRY",665-        payload: { title: "Ghost", password: "p" },666-      });667-      expect(r.ok).toBe(false);668-    });669-670-    it("when reloadEntriesAfterSync succeeds, vault stays unlocked", async () => {671-      const vault = await setupBlobs();672-      const messages = await import("./messages");673-      const deps = messages.buildSyncDeps();674-      // Build a remote blob that decrypts under the *same* key — re-use675-      // current slot A but bump the timestamp via writeSlot equivalent.676-      const slotA = storage.peach_vault_a;677-      const sameKeyBlob = JSON.stringify({ a: slotA, b: storage.peach_vault_b });678-      const applied = await deps.applyIncomingVault(679-        sameKeyBlob,680-        1_700_000_000_000 + 50_000,681-      );682-      expect(applied).toBe(true);683-      expect(vault.isUnlocked()).toBe(true);684-    });685-  });686-687-  // H1/THREAT-002 — responder must authenticate the initiator and reject any688-  // initiator whose authenticated X25519 public key is not in the paired list.689-  // The Noise NK transport itself is run by the native layer in production;690-  // we drive the auth round directly over the JS transport in these tests.691-  describe("sync initiator authentication", () => {692-    // Helper: simulate one full responder-side SYNC_SESSION_STARTED handshake.693-    // Returns: status='completed' if the responder accepted us, 'rejected' if694-    // it closed the transport (auth failure or unpaired key); vaultBlobSent695-    // reflects whether the responder progressed to the blob exchange.696-    async function simulateResponderHandshake(opts: {697-      initiatorKeypair: { secretKey: Uint8Array; publicKey: Uint8Array };698-      pairedX25519Hex: string[];699-    }): Promise<{ status: "completed" | "rejected"; vaultBlobSent: boolean }> {700-      const { generateKeyPair, sharedKey } = await import("@stablelib/x25519");701-      const { HMAC } = await import("@stablelib/hmac");702-      const { SHA256 } = await import("@stablelib/sha256");703-704-      // Pre-seed responder X25519 identity so we know its public key for the705-      // proof MAC. The responder reads it lazily from chrome.storage.local706-      // via getSyncIdentity().707-      const responderKp = generateKeyPair();708-      const b64 = (bytes: Uint8Array) => {709-        let s = "";710-        for (const b of bytes) s += String.fromCharCode(b);711-        return btoa(s);712-      };713-      storage.peach_sync_identity = JSON.stringify({714-        priv: b64(responderKp.secretKey),715-        pub: b64(responderKp.publicKey),716-      });717-718-      // Seed paired devices (sync-storage reads from chrome.storage.local).719-      storage.peach_paired_devices = pairedX25519Hex(opts.pairedX25519Hex);720-721-      // Capacitor + SyncPlugin stub so isNativePlatform() returns true and722-      // the responder's outgoing frames are captured.723-      const sentFrames: string[] = [];724-      (globalThis as unknown as { Capacitor: unknown }).Capacitor = {725-        isNativePlatform: () => true,726-        Plugins: {727-          SyncPlugin: {728-            addListener: () => {},729-            sendToConnection: async ({ data }: { connectionId: string; data: string }) => {730-              sentFrames.push(data);731-            },732-          },733-        },734-      };735-736-      // Re-import messages so the SyncPlugin listener registers under the new Capacitor.737-      vi.resetModules();738-      const messages = await import("./messages");739-      messages.setupMessageHandler();740-      const vault = await import("./vault");741-      await vault.ensureSettingsLoaded();742-      await vault.createVault("test-pass-sync");743-744-      // Fire SYNC_SESSION_STARTED — responder begins by sending CHALLENGE.745-      const sessionPromise = dispatch({746-        type: "SYNC_SESSION_STARTED",747-        payload: { connectionId: "test-conn" },748-      });749-750-      // Wait for the responder to send CHALLENGE.751-      const waitForFrame = async (): Promise<string> => {752-        for (let i = 0; i < 200; i++) {753-          if (sentFrames.length > 0) return sentFrames.shift()!;754-          await new Promise(r => setTimeout(r, 5));755-        }756-        throw new Error("timeout waiting for responder frame");757-      };758-      const challengeB64 = await waitForFrame();759-      const challengeBytes = base64ToU8(challengeB64);760-      const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as {761-        type: string;762-        nonce_hex: string;763-        responder_public_key_hex: string;764-      };765-      if (challenge.type !== "CHALLENGE") throw new Error("expected CHALLENGE");766-767-      // Compute proof: HMAC-SHA256(X25519(our_priv, responder_pub),768-      //   "peach-sync-v1-initiator-auth" || our_pub || responder_pub || nonce)769-      const responderPub = hexToBytes(challenge.responder_public_key_hex);770-      const dh = sharedKey(opts.initiatorKeypair.secretKey, responderPub);771-      const mac = new HMAC(SHA256, dh);772-      mac.update(new TextEncoder().encode("peach-sync-v1-initiator-auth"));773-      mac.update(opts.initiatorKeypair.publicKey);774-      mac.update(responderPub);775-      mac.update(hexToBytes(challenge.nonce_hex));776-      const proof = mac.digest();777-778-      const proofFrame = JSON.stringify({779-        type: "PROOF",780-        initiator_public_key_hex: bytesToHexStr(opts.initiatorKeypair.publicKey),781-        proof_hex: bytesToHexStr(proof),782-      });783-      // Inject the PROOF frame as the next "incoming" sync frame.784-      const { deliverAndroidSyncFrame } = messages;785-      deliverAndroidSyncFrame("test-conn", u8ToBase64(new TextEncoder().encode(proofFrame)));786-787-      // After auth, responder either rejects (closes transport, returns788-      // ok:false) or proceeds to runBlobSyncSession (which will hang waiting789-      // for SYNC_HELLO; we time it out and infer "completed handshake").790-      const result = await Promise.race([791-        sessionPromise.then(r => ({ kind: "resolved" as const, response: r })),792-        new Promise<{ kind: "blob-stage" }>(resolve =>793-          setTimeout(() => resolve({ kind: "blob-stage" }), 200)794-        ),795-      ]);796-797-      if (result.kind === "resolved") {798-        const resp = result.response;799-        if (resp.ok === false) {800-          return { status: "rejected", vaultBlobSent: false };801-        }802-        // ok=true means the auth passed and runBlobSyncSession ran (probably803-        // erroring on missing SYNC_HELLO, but auth succeeded).804-        return { status: "completed", vaultBlobSent: sentFrames.length > 0 };805-      }806-      // Still pending after auth — the responder is past the auth gate.807-      return { status: "completed", vaultBlobSent: sentFrames.length > 0 };808-    }809-810-    function pairedX25519Hex(keys: string[]): Array<Record<string, unknown>> {811-      return keys.map((k, i) => ({812-        deviceId: k,813-        displayName: `Dev${i}`,814-        x25519PublicKey: k,815-        pairedAt: 1_700_000_000_000,816-        lastSeenAt: 1_700_000_000_000,817-      }));818-    }819-820-    function hexToBytes(s: string): Uint8Array {821-      const out = new Uint8Array(s.length / 2);822-      for (let i = 0; i < s.length; i += 2) out[i / 2] = parseInt(s.slice(i, i + 2), 16);823-      return out;824-    }825-    function bytesToHexStr(b: Uint8Array): string {826-      return Array.from(b).map(x => x.toString(16).padStart(2, "0")).join("");827-    }828-    function base64ToU8(b64: string): Uint8Array {829-      const bin = atob(b64);830-      const out = new Uint8Array(bin.length);831-      for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);832-      return out;833-    }834-    function u8ToBase64(u: Uint8Array): string {835-      let s = "";836-      for (const b of u) s += String.fromCharCode(b);837-      return btoa(s);838-    }839-840-    it("unpaired initiator is rejected — vault blob never sent", async () => {841-      const { generateKeyPair } = await import("@stablelib/x25519");842-      const initKp = generateKeyPair();843-      const result = await simulateResponderHandshake({844-        initiatorKeypair: initKp,845-        pairedX25519Hex: [],846-      });847-      expect(result.status).toBe("rejected");848-      expect(result.vaultBlobSent).toBe(false);849-    });850-851-    it("paired initiator passes the auth gate", async () => {852-      const { generateKeyPair } = await import("@stablelib/x25519");853-      const initKp = generateKeyPair();854-      const initHex = Array.from(initKp.publicKey)855-        .map(x => x.toString(16).padStart(2, "0"))856-        .join("");857-      const result = await simulateResponderHandshake({858-        initiatorKeypair: initKp,859-        pairedX25519Hex: [initHex],860-      });861-      expect(result.status).toBe("completed");862-    });863-864-    it("forged public key in PROOF frame is rejected (proof MAC fails)", async () => {865-      const { generateKeyPair } = await import("@stablelib/x25519");866-      const realKp = generateKeyPair();867-      const realHex = Array.from(realKp.publicKey)868-        .map(x => x.toString(16).padStart(2, "0"))869-        .join("");870-      // The proof MAC is computed from initKp's private key — but the device871-      // list contains a different (forged) public key. Even though our MAC872-      // verifies cryptographically (it's bound to initKp.publicKey, which we873-      // send in the PROOF frame), that hex is NOT in the paired list.874-      const forgedKp = generateKeyPair();875-      const result = await simulateResponderHandshake({876-        initiatorKeypair: forgedKp,877-        pairedX25519Hex: [realHex], // only the real key is paired878-      });879-      expect(result.status).toBe("rejected");880-      expect(result.vaultBlobSent).toBe(false);881-    });882-  });883-});

view file at the parent

src/background/vault.test.ts+0 −15
@@ -347,21 +347,6 @@347347     expect(Number(storage.peach_last_modified_at)).toBeGreaterThan(previous);348348   });349349 350-  it("applyIncomingVault preserves the remote last-modified timestamp", async () => {351-    const vault = await import("./vault");352-    await vault.createVault("remote-pass");353-    const blob = JSON.stringify({354-      a: storage.peach_vault_a,355-      b: storage.peach_vault_b,356-    });357-    storage.peach_last_modified_at = "1000";358-    const remoteTimestamp = 2000;359-360-    expect(await vault.applyIncomingVault(blob, remoteTimestamp)).toBe(true);361-362-    expect(storage.peach_last_modified_at).toBe(String(remoteTimestamp));363-  });364-365350   it("changePassword with wrong current password returns false and leaves slot A unchanged", async () => {366351     const vault = await import("./vault");367352 

view file

src/background/vault.ts+0 −221
@@ -2225,224 +2225,3 @@22252225   return true;22262226 }22272227 2228-// ---------------------------------------------------------------------------2229-// Sync helpers2230-// ---------------------------------------------------------------------------2231-2232-export async function getVaultBlobForSync(): Promise<{2233-  blob: string;2234-  timestamp: number;2235-  license?: string;2236-} | null> {2237-  const raw = await chrome.storage.local.get([SLOT_A, SLOT_B, 'peach_last_modified_at', 'peach_license']);2238-  const slotA = (raw as Record<string, unknown>)[SLOT_A];2239-  if (!slotA) return null;2240-  const slotB = (raw as Record<string, unknown>)[SLOT_B];2241-  const blob = JSON.stringify({ a: slotA, b: slotB });2242-  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2243-  const timestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2244-  const license = (raw as Record<string, unknown>)['peach_license'];2245-  return {2246-    blob,2247-    timestamp,2248-    license: typeof license === 'string' ? license : undefined,2249-  };2250-}2251-2252-export async function applyIncomingVault(2253-  blobJson: string,2254-  remoteTimestamp: number,2255-  remoteLicense?: string,2256-): Promise<boolean> {2257-  const raw = await chrome.storage.local.get('peach_last_modified_at');2258-  const rawTs = (raw as Record<string, unknown>)['peach_last_modified_at'];2259-  const localTimestamp = typeof rawTs === 'string' ? parseInt(rawTs) : 0;2260-2261-  if (remoteLicense) {2262-    const licenseRaw = await chrome.storage.local.get('peach_license');2263-    const existing = (licenseRaw as Record<string, unknown>)['peach_license'];2264-    if (!existing) {2265-      // L16/SEC-010: validate signature before storing. An unverified peer2266-      // could otherwise plant an arbitrary string as the local license and2267-      // poison the next isProUser() / activateLicense() check.2268-      const { validateLicense } = await import('./license');2269-      const validation = await validateLicense(remoteLicense);2270-      if (validation.valid) {2271-        await chrome.storage.local.set({ peach_license: remoteLicense });2272-      }2273-      // Silently ignore invalid — never store garbage.2274-    }2275-  }2276-2277-  // AUD-006: reject implausibly future timestamps. A paired peer with a bad2278-  // clock — or a malicious peer — can send Number.MAX_SAFE_INTEGER and pin2279-  // last-write-wins so no local change ever wins. 5 minutes covers legitimate2280-  // clock drift; anything further is implausible.2281-  const SYNC_CLOCK_SKEW_BUDGET_MS = 5 * 60 * 1_000;2282-  if (remoteTimestamp > Date.now() + SYNC_CLOCK_SKEW_BUDGET_MS) {2283-    console.warn(2284-      '[Peach] sync rejected: remote timestamp too far in future',2285-      { remoteTimestamp, now: Date.now(), delta: remoteTimestamp - Date.now() },2286-    );2287-    return false;2288-  }2289-2290-  if (remoteTimestamp <= localTimestamp) return false;2291-2292-  // Parse + shape-validate before any storage mutation. A malformed blob2293-  // (non-JSON, non-object, missing slot fields) must NOT touch slot A —2294-  // otherwise an unauthenticated remote could poison the local vault by2295-  // sending '[]' or '42' (M10/COR-012). Mirror the structural check2296-  // already used in unlockFromCodex.2297-  let parsed: unknown;2298-  try {2299-    parsed = JSON.parse(blobJson);2300-  } catch {2301-    throw new Error('invalid_incoming_vault_blob');2302-  }2303-  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {2304-    throw new Error('invalid_incoming_vault_blob');2305-  }2306-  const { a, b } = parsed as { a?: unknown; b?: unknown };2307-  if (!hasValidVaultSchema(a)) {2308-    throw new Error('invalid_incoming_vault_blob');2309-  }2310-2311-  // Snapshot before overwriting — non-fatal but user-visible.2312-  // M4/QUA-006: a silent snapshot failure leaves the user without their2313-  // safety net for an incoming sync write; surface it.2314-  try {2315-    await takeVaultSnapshot('sync_received');2316-  } catch (snapshotErr) {2317-    console.warn('[Peach] vault snapshot failed during sync:', snapshotErr);2318-    if (String(snapshotErr).includes('QUOTA') || String(snapshotErr).includes('quota')) {2319-      try {2320-        await chrome.runtime.sendMessage({ type: 'VAULT_STORAGE_QUOTA_WARNING' });2321-      } catch {2322-        // popup may be closed; surfaced via console.warn above2323-      }2324-    }2325-  }2326-2327-  await writeSlot(SLOT_A, a);2328-  if (hasValidVaultSchema(b)) await writeSlot(SLOT_B, b);2329-  await chrome.storage.local.set({2330-    peach_last_sync_at: String(Date.now()),2331-    peach_last_modified_at: String(remoteTimestamp),2332-  });2333-2334-  return true;2335-}2336-2337-/**2338- * Returns the X25519 sync identity, encrypting the private key on first call2339- * and on any unencrypted-shape migration. Requires the vault to be unlocked2340- * — the private key is wrapped under the same derived key as the vault, so2341- * a locked browser cannot leak it from storage alone (M12/SEC-003).2342- *2343- * Persistence shapes:2344- *   v0 (pre-M12, migrated on first read):2345- *     { priv: base64, pub: base64 }2346- *   v1 (encrypted):2347- *     { pub: base64, enc_priv: hex, enc_nonce: hex, enc_tag: hex }2348- */2349-export async function getSyncIdentity(): Promise<{2350-  privateKeyBytes: Uint8Array;2351-  publicKeyBytes: Uint8Array;2352-  publicKeyHex: string;2353-}> {2354-  if (!derivedKey) throw new Error('vault_locked');2355-  const raw = await chrome.storage.local.get('peach_sync_identity');2356-  const stored = (raw as Record<string, unknown>)['peach_sync_identity'];2357-2358-  if (typeof stored === 'string') {2359-    const parsed = JSON.parse(stored) as {2360-      pub: string;2361-      priv?: string;2362-      enc_priv?: string;2363-      enc_nonce?: string;2364-      enc_tag?: string;2365-    };2366-    const publicKeyBytes = base64DecodeBytes(parsed.pub);2367-2368-    if (parsed.enc_priv && parsed.enc_nonce && parsed.enc_tag) {2369-      const privHex = await decrypt(2370-        derivedKey,2371-        parsed.enc_nonce,2372-        parsed.enc_priv,2373-        parsed.enc_tag,2374-        "sync_identity_v1",2375-      );2376-      const privateKeyBytes = unhex(privHex);2377-      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2378-    }2379-2380-    if (parsed.priv) {2381-      // Migrate v0 (unencrypted private key) → v1.2382-      const privateKeyBytes = base64DecodeBytes(parsed.priv);2383-      await persistEncryptedSyncIdentity(privateKeyBytes, publicKeyBytes);2384-      return { privateKeyBytes, publicKeyBytes, publicKeyHex: bytesToHex(publicKeyBytes) };2385-    }2386-  }2387-2388-  // Generate a fresh X25519 keypair and persist it encrypted.2389-  const { generateKeyPair } = await import('@stablelib/x25519');2390-  const keypair = generateKeyPair();2391-  await persistEncryptedSyncIdentity(keypair.secretKey, keypair.publicKey);2392-  return {2393-    privateKeyBytes: keypair.secretKey,2394-    publicKeyBytes: keypair.publicKey,2395-    publicKeyHex: bytesToHex(keypair.publicKey),2396-  };2397-}2398-2399-async function persistEncryptedSyncIdentity(2400-  privateKeyBytes: Uint8Array,2401-  publicKeyBytes: Uint8Array,2402-): Promise<void> {2403-  if (!derivedKey) throw new Error('vault_locked');2404-  const enc = await encrypt(derivedKey, hex(privateKeyBytes), "sync_identity_v1");2405-  await chrome.storage.local.set({2406-    peach_sync_identity: JSON.stringify({2407-      pub: base64EncodeBytes(publicKeyBytes),2408-      enc_priv: enc.ciphertext,2409-      enc_nonce: enc.nonce,2410-      enc_tag: enc.tag,2411-    }),2412-  });2413-}2414-2415-// Re-decrypt vault entries from storage using the current in-memory key.2416-// Called after applyIncomingVault so the in-memory state stays consistent2417-// with what was just written to storage.2418-export async function reloadEntriesAfterSync(): Promise<boolean> {2419-  if (!derivedKey) return false;2420-  const slotA = await loadSlot(SLOT_A);2421-  if (!slotA) return false;2422-  try {2423-    const payload = await tryDecryptSlot(slotA, derivedKey, "slot_a_v1");2424-    if (!payload) return false;2425-    entries = payload.entries;2426-    buildDomainIndexSync(entries);2427-    return true;2428-  } catch {2429-    return false;2430-  }2431-}2432-2433-function bytesToHex(bytes: Uint8Array): string {2434-  return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');2435-}2436-2437-function base64EncodeBytes(bytes: Uint8Array): string {2438-  let binary = '';2439-  for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);2440-  return btoa(binary);2441-}2442-2443-function base64DecodeBytes(b64: string): Uint8Array {2444-  const binary = atob(b64);2445-  const bytes = new Uint8Array(binary.length);2446-  for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);2447-  return bytes;2448-}

view file

src/popup/App.tsx+9 −142
@@ -4,7 +4,6 @@44 import { sendMessage } from "../shared/bridge";55 import { ACCENT_COLORS } from "../shared/types";66 import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";7-import type { IncomingPairingRequest } from "../shared/sync-types";87 import { isPasskeyOnly } from "../shared/types";98 import { LockView } from "./views/Lock";109 import { Onboarding } from "./views/Onboarding";@@ -13,7 +12,7 @@1312 import { VaultView, type SortOrder } from "./views/Vault";1413 import { EntryView } from "./views/Entry";1514 import { AddView } from "./views/Add";16-import { PeachMark, PeachWordmark } from "./components/PeachMark";15+import { PeachWordmark } from "./components/PeachMark";1716 import { PasskeyApprovalView } from "./views/PasskeyApproval";1817 import { extractDomain } from "../shared/domain";1918 import { popupCopy } from "./copy";@@ -37,8 +36,7 @@3736 const HealthView    = lazy(() => import("./views/Health").then(m => ({ default: m.HealthView })));3837 const TrashView     = lazy(() => import("./views/TrashView").then(m => ({ default: m.TrashView })));3938 const PanicView     = lazy(() => import("./views/Panic").then(m => ({ default: m.PanicView })));40-const SyncPairingView = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.SyncPairingView })));41-const AddDeviceView   = lazy(() => import("./views/SyncPairing").then(m => ({ default: m.AddDeviceView })));39+const ChirpSyncView   = lazy(() => import("./views/ChirpSync").then(m => ({ default: m.ChirpSyncView })));4240 const CodexGenerateView = lazy(() => import("./views/CodexGenerateView").then(m => ({ default: m.CodexGenerateView })));4341 const CodexRestoreView  = lazy(() => import("./views/CodexRestoreView").then(m => ({ default: m.CodexRestoreView })));4442 @@ -49,8 +47,7 @@4947   | "list"5048   | "add"5149   | "settings"52-  | "sync-pairing"53-  | "sync-add-device"50+  | "chirp-sync"5451   | "import"5552   | "export"5653   | "health"@@ -160,16 +157,6 @@160157   const [listSearchSeedNonce, setListSearchSeedNonce] = createSignal(0);161158   const [pendingCelebration, setPendingCelebration] = createSignal(false);162159   const [pendingProNotice, setPendingProNotice] = createSignal<{ feature: string } | null>(null);163-  const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);164-  const [recentPairToast, setRecentPairToast] = createSignal<string | null>(null);165-  const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);166-  const [incomingPairTick, setIncomingPairTick] = createSignal(0);167-  const incomingPairSecondsLeft = () => {168-    void incomingPairTick();169-    const req = incomingPair();170-    if (!req) return 0;171-    return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));172-  };173160   // True while Panic Step 1 has bounced the user out to Export. When Export's174161   // onBack fires we return to Panic instead of Settings so the user keeps the175162   // destruction context they came from.@@ -382,8 +369,6 @@382369     }383370 384371     await refreshPendingPasskey();385-    await refreshIncomingPairing();386-    await consumeRecentPairing();387372 388373     // Track tab changes so smart sort stays current389374     const handleTabActivated = () => { void refreshCurrentTab(); };@@ -445,23 +430,7 @@445430     // backgrounds the app. We measure elapsed background time on resume.446431     if (isNativePlatform()) {447432       let backgroundedAt: number | null = null;448-      let syncPollInterval: ReturnType<typeof setInterval> | null = null;449433 450-      function startSyncPolling() {451-        if (syncPollInterval) return;452-        syncPollInterval = setInterval(async () => {453-          const res = await sendMessage({ type: 'TRIGGER_SYNC' }).catch(() => null);454-          const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;455-          if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {456-            await sendMessage({ type: 'GET_ENTRIES' }).catch(() => null);457-          }458-        }, 60_000);459-      }460-461-      function stopSyncPolling() {462-        if (syncPollInterval) { clearInterval(syncPollInterval); syncPollInterval = null; }463-      }464-465434       const CapApp = nativePlugin<{466435         addListener: (467436           ev: string,@@ -474,7 +443,6 @@474443           ({ isActive }: { isActive: boolean }) => {475444             if (!isActive) {476445               backgroundedAt = Date.now();477-              stopSyncPolling();478446               return;479447             }480448             if (backgroundedAt !== null && autoLockMinutes() > 0) {@@ -482,14 +450,11 @@482450               if (elapsed >= autoLockMinutes() * 60_000) handleHeaderLock();483451             }484452             backgroundedAt = null;485-            // Resume polling when app comes to foreground (Pro only).486-            if (proStatus()) startSyncPolling();487453           }488454         );489455         // Capacitor 6 listeners return a Promise<{ remove: () => Promise<void> }>.490456         onCleanup(() => {491457           Promise.resolve(sub).then((s) => s?.remove?.()).catch(() => {});492-          stopSyncPolling();493458         });494459       }495460 @@ -554,7 +519,7 @@554519         handleBack();555520         return;556521       }557-      if (v === "sync-pairing") { setView("settings"); return; }522+      if (v === "chirp-sync") { setView("settings"); return; }558523     };559524     window.addEventListener("peach:back", handlePeachBack);560525     onCleanup(() => window.removeEventListener("peach:back", handlePeachBack));@@ -742,8 +707,7 @@742707         if (showNotes()) { setShowNotes(false); setOpenNoteId(undefined); return; }743708         if (v === "list") { setView("entry"); return; }744709         if (v === "settings" || v === "import" || v === "export" || v === "add" || v === "health") { handleBack(); return; }745-        if (v === "sync-pairing") { setView("settings"); return; }746-        if (v === "sync-add-device") { setView("sync-pairing"); return; }710+        if (v === "chirp-sync") { setView("settings"); return; }747711       }748712 749713       // Bindings-driven dispatch. Single-key letter bindings without@@ -810,48 +774,6 @@810774     setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);811775   }812776 813-  async function refreshIncomingPairing() {814-    const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });815-    const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;816-    setIncomingPair(data);817-  }818-819-  // Drains a single one-shot record left by the background when 6-digit820-  // pairing succeeded while the popup was closed (or the SyncPairing view was821-  // navigated away from before it could show its own success card).822-  async function consumeRecentPairing() {823-    const res = await sendMessage({ type: "GET_RECENT_PAIRING" });824-    const data = (res as { ok: boolean; data?: { deviceName?: string } | null }).data;825-    if (data?.deviceName) {826-      setRecentPairToast(data.deviceName);827-      window.setTimeout(() => setRecentPairToast(null), 4000);828-    }829-  }830-831-  async function respondIncomingPairing(accept: boolean) {832-    if (incomingPairResponding()) return;833-    setIncomingPairResponding(true);834-    await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });835-    setIncomingPair(null);836-    setIncomingPairResponding(false);837-  }838-839-  // 60-second expiry ticker for the incoming pairing banner. Re-runs when a840-  // request appears, ticks once per second, and clears the banner if the841-  // request expires while the popup is open.842-  createEffect(() => {843-    const req = incomingPair();844-    if (!req) return;845-    const handle = window.setInterval(() => {846-      setIncomingPairTick((tick) => tick + 1);847-      if (Date.now() >= req.expiresAt) {848-        setIncomingPair(null);849-        window.clearInterval(handle);850-      }851-    }, 1000);852-    onCleanup(() => window.clearInterval(handle));853-  });854-855777   async function refreshProStatus() {856778     const res = await sendMessage({ type: "GET_LICENSE" });857779     const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;@@ -926,17 +848,6 @@926848   }927849 928850   async function openVault(preferredEntryId?: string, skipViewSet = false) {929-    // Desktop: fire-and-forget sync on every vault open (Pro only).930-    // Never blocks popup load; refreshes entries if new data arrives.931-    if (!isNativePlatform() && proStatus()) {932-      void sendMessage({ type: 'TRIGGER_SYNC' }).then((res) => {933-        const r = res as { ok?: boolean; data?: { syncedCount?: number } } | null;934-        if (r?.ok && (r?.data?.syncedCount ?? 0) > 0) {935-          void loadEntries();936-        }937-      }).catch(() => {});938-    }939-940851     // Purge expired trash entries on each vault open941852     const retDays = trashRetentionDays();942853     if (retDays > 0) {@@ -1275,41 +1186,6 @@12751186               </div>12761187             </header>12771188 1278-            <Show when={recentPairToast()}>1279-              <div class="peach-pair-toast" role="status" aria-live="polite">1280-                Paired with <strong>{recentPairToast()}</strong>1281-              </div>1282-            </Show>1283-1284-            <Show when={incomingPair() !== null}>1285-              <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1286-                <div class="peach-pair-request-header">1287-                  <span class="peach-pair-request-mark"><PeachMark class="peach-pair-request-icon" /></span>1288-                  <span class="peach-pair-request-title">Pairing request</span>1289-                  <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1290-                </div>1291-                <p class="peach-pair-request-body">1292-                  <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1293-                </p>1294-                <div class="peach-pair-request-actions">1295-                  <button1296-                    class="peach-btn peach-btn--ghost peach-btn--sm"1297-                    onClick={() => void respondIncomingPairing(false)}1298-                    disabled={incomingPairResponding()}1299-                  >1300-                    Reject1301-                  </button>1302-                  <button1303-                    class="peach-btn peach-btn--accent peach-btn--sm"1304-                    onClick={() => void respondIncomingPairing(true)}1305-                    disabled={incomingPairResponding()}1306-                  >1307-                    Accept1308-                  </button>1309-                </div>1310-              </div>1311-            </Show>1312-13131189             <Show when={pendingProNotice() !== null}>13141190               <div class="peach-pro-notice-banner">13151191                 <span>✦ {pendingProNotice()?.feature === "sync" ? "Sync" : "A Pro feature"} requires Peach Pro</span>@@ -1482,7 +1358,7 @@14821358                   onExport={() => setView("export")}14831359                   onBreachCheckDone={loadEntries}14841360                   onOpenHealth={openHealth}1485-                  onOpenSyncPairing={() => setView("sync-pairing")}1361+                  onOpenSyncPairing={() => setView("chirp-sync")}14861362                   onOpenFileImport={handleOpenFileImport}14871363                   onOpenPanic={() => setView("panic")}14881364                   onOpenCodexGenerate={() => setView("codex-generate")}@@ -1513,21 +1389,12 @@15131389                 />15141390               </Suspense>15151391             </Show>1516-            <Show when={view() === "sync-pairing"}>1392+            <Show when={view() === "chirp-sync"}>15171393               <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1518-                <SyncPairingView1394+                <ChirpSyncView15191395                   isPro={proStatus()}1520-                  onAddDevice={() => setView("sync-add-device")}1521-                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}15221396                   onBack={() => setView("settings")}1523-                />1524-              </Suspense>1525-            </Show>1526-            <Show when={view() === "sync-add-device"}>1527-              <Suspense fallback={<div class="peach-loading"><PeachWordmark /></div>}>1528-                <AddDeviceView1529-                  onClose={() => setView("sync-pairing")}1530-                  onPaired={() => setView("sync-pairing")}1397+                  onLearnPro={() => { setSettingsInitialSection("license"); setView("settings"); }}15311398                 />15321399               </Suspense>15331400             </Show>

view file

src/popup/chirp-pair.ts+7 −3
@@ -48,6 +48,8 @@4848   fetchLocalIdentity(): Promise<LocalIdentity>;4949   /** Background round-trip — persists the paired device. */5050   completePairing(paired: PairedDevice): Promise<void>;51+  /** Optional — cache the peer's IP for next session's fast path. */52+  cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;5153   /** Returns "192.168.1" for a /24; null if not detectable. */5254   getLocalSubnet(): Promise<string | null>;5355   /** Inject the WebSocket transport. Override in tests. */@@ -133,10 +135,12 @@133135   }134136   await transport.close();135137 136-  // Use the connection's IP as our cached `lastKnownIp`; the wsPort came137-  // in the PAIR_IDENTITY frame.138-  paired = { ...paired, lastKnownIp: peerEndpoint.ip };138+  // The connection's IP is cached in `chrome.storage.local` keyed by139+  // pubkey — see `paired-devices.ts#setLastKnownIp`. The persistent v3140+  // `PairedDevice` record itself has no `lastKnownIp` field because the141+  // IP is per-network and must not roam over `chrome.storage.sync`.139142   await deps.completePairing(paired);143+  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);140144   deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });141145   return paired;142146 }

view file

src/popup/components/SyncComingSoonBanner.tsx+0 −16
@@ -1,16 +0,0 @@1-export function SyncComingSoonBanner() {2-  return (3-    <div class="peach-sync-coming-soon">4-      <div class="peach-sync-coming-soon__icon">5-        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">6-          <circle cx="8" cy="8" r="6.5" />7-          <path d="M8 5v3.5l2 1.5" />8-        </svg>9-      </div>10-      <div>11-        <p class="peach-sync-coming-soon__title">Sync requires the mobile app</p>12-        <p class="peach-sync-coming-soon__body">Download Peach for iOS or Android to pair and sync your vault between devices.</p>13-      </div>14-    </div>15-  );16-}

view file at the parent

src/popup/components/SyncInfoSheet.tsx+0 −49
@@ -1,49 +0,0 @@1-import { Component } from 'solid-js';2-3-interface SyncInfoSheetProps {4-  onGotIt: () => void;5-  onLearnPro: () => void;6-}7-8-export const SyncInfoSheet: Component<SyncInfoSheetProps> = (props) => {9-  return (10-    <div class="peach-sync-info-backdrop" onClick={props.onGotIt}>11-      <div12-        class="peach-sync-info-sheet"13-        onClick={e => e.stopPropagation()}14-      >15-        <div class="peach-sync-info-handle" />16-17-        <h2 class="peach-sync-info-title">How Peach Sync works</h2>18-19-        <div class="peach-sync-info-body">20-          <p class="peach-sync-info-point">21-            <span class="peach-sync-info-icon">⇄</span>22-            You can transfer your vault to another device for free —23-            scan a code and your passwords move over instantly.24-          </p>25-          <p class="peach-sync-info-point">26-            <span class="peach-sync-info-icon">✦</span>27-            Continuous sync across multiple devices — keeping everything28-            up to date automatically — requires Peach Pro.29-          </p>30-        </div>31-32-        <div class="peach-sync-info-actions">33-          <button34-            class="peach-btn peach-btn--accent peach-btn--full"35-            onClick={props.onGotIt}36-          >37-            Got it38-          </button>39-          <button40-            class="peach-sync-info-pro-link"41-            onClick={props.onLearnPro}42-          >43-            Learn about Pro →44-          </button>45-        </div>46-      </div>47-    </div>48-  );49-};

view file at the parent

src/popup/styles/popup.css+223 −0
@@ -8888,3 +8888,226 @@88888888   0% { transform: scale(0.6); opacity: 0.6; }88898889   100% { transform: scale(2.4); opacity: 0; }88908890 }8891+8892+/* ─────────────────────────────────────────────────────────────────8893+   Settings → Sync surface (v3 ChirpSync). Wraps the same bird8894+   animation used by the pairing flow with a paired-devices list and8895+   the "Pair a device" CTA.8896+   ───────────────────────────────────────────────────────────────── */8897+.peach-chirp-settings {8898+  display: flex;8899+  flex-direction: column;8900+  gap: 18px;8901+  padding: 18px 20px 28px;8902+  width: 100%;8903+  box-sizing: border-box;8904+  overflow-y: auto;8905+  height: 100%;8906+}8907+.peach-chirp-settings--pairing {8908+  padding: 0;8909+  align-items: stretch;8910+  justify-content: stretch;8911+}8912+.peach-chirp-settings-header {8913+  display: flex;8914+  align-items: center;8915+  gap: 10px;8916+}8917+.peach-chirp-settings-title {8918+  margin: 0;8919+  font-size: 18px;8920+  font-weight: 600;8921+  color: var(--peach-text);8922+  letter-spacing: 0.01em;8923+}8924+.peach-chirp-settings-section {8925+  margin: 6px 0 -4px;8926+  font-size: 11px;8927+  font-weight: 600;8928+  letter-spacing: 0.08em;8929+  text-transform: uppercase;8930+  color: var(--peach-text-muted);8931+}8932+.peach-chirp-settings-error {8933+  font-size: 12px;8934+  padding: 8px 10px;8935+  border-radius: 8px;8936+  background: color-mix(in srgb, var(--peach-danger, #c25b4a) 18%, transparent);8937+  color: var(--peach-text);8938+}8939+.peach-chirp-settings-footer {8940+  margin-top: auto;8941+  padding-top: 18px;8942+  font-size: 11.5px;8943+  line-height: 1.5;8944+  color: var(--peach-text-muted);8945+  text-align: center;8946+}8947+8948+/* Hero card shown when no devices are paired yet. */8949+.peach-chirp-hero {8950+  display: flex;8951+  flex-direction: column;8952+  align-items: center;8953+  gap: 10px;8954+  padding: 18px 12px 4px;8955+  text-align: center;8956+}8957+.peach-chirp-hero-svg {8958+  display: block;8959+  max-width: 100%;8960+  filter: drop-shadow(0 4px 12px color-mix(in srgb, var(--peach-accent) 25%, transparent));8961+}8962+.peach-chirp-hero-headline {8963+  margin: 0;8964+  font-size: 16px;8965+  font-weight: 600;8966+  color: var(--peach-text);8967+}8968+.peach-chirp-hero-sub {8969+  margin: 0;8970+  font-size: 13px;8971+  line-height: 1.5;8972+  color: var(--peach-text-dim);8973+  max-width: 320px;8974+}8975+8976+.peach-chirp-pair-cta {8977+  display: flex;8978+  justify-content: center;8979+  padding: 4px 0 6px;8980+}8981+.peach-chirp-pair-button {8982+  display: inline-flex;8983+  align-items: center;8984+  gap: 8px;8985+  min-width: 220px;8986+  justify-content: center;8987+}8988+.peach-chirp-pair-button-icon {8989+  flex: 0 0 auto;8990+}8991+8992+/* Paired devices list. Roomy rows that look at home next to the rest8993+   of Settings; stale rows get a soft warm-warning border tint. */8994+.peach-chirp-device-list {8995+  list-style: none;8996+  margin: 0;8997+  padding: 0;8998+  display: flex;8999+  flex-direction: column;9000+  gap: 8px;9001+}9002+.peach-chirp-device-row {9003+  display: flex;9004+  align-items: center;9005+  gap: 12px;9006+  padding: 12px;9007+  border-radius: 10px;9008+  background: color-mix(in srgb, var(--peach-bg-elevated, var(--peach-bg)) 88%, var(--peach-accent) 12%);9009+  border: 1px solid color-mix(in srgb, var(--peach-accent) 12%, transparent);9010+}9011+.peach-chirp-device-row[data-stale="true"] {9012+  border-color: color-mix(in srgb, var(--peach-warn, #d29257) 36%, transparent);9013+}9014+.peach-chirp-device-icon {9015+  width: 32px;9016+  height: 32px;9017+  display: flex;9018+  align-items: center;9019+  justify-content: center;9020+  border-radius: 8px;9021+  color: var(--peach-text);9022+  background: color-mix(in srgb, var(--peach-accent) 18%, transparent);9023+}9024+.peach-chirp-device-info {9025+  flex: 1;9026+  display: flex;9027+  flex-direction: column;9028+  gap: 2px;9029+  min-width: 0;9030+}9031+.peach-chirp-device-name {9032+  font-size: 14px;9033+  font-weight: 600;9034+  color: var(--peach-text);9035+  overflow: hidden;9036+  text-overflow: ellipsis;9037+  white-space: nowrap;9038+}9039+.peach-chirp-device-meta {9040+  font-size: 11.5px;9041+  color: var(--peach-text-muted);9042+  display: flex;9043+  flex-wrap: wrap;9044+  align-items: center;9045+  gap: 8px;9046+}9047+.peach-chirp-device-badge {9048+  font-size: 10.5px;9049+  font-weight: 500;9050+  letter-spacing: 0.02em;9051+  padding: 2px 6px;9052+  border-radius: 999px;9053+  background: color-mix(in srgb, var(--peach-accent) 22%, transparent);9054+  color: var(--peach-text);9055+}9056+.peach-chirp-device-stale {9057+  font-size: 10.5px;9058+  font-weight: 500;9059+  color: var(--peach-warn, #d29257);9060+}9061+.peach-chirp-device-remove {9062+  flex: 0 0 auto;9063+  color: var(--peach-text-muted);9064+}9065+.peach-chirp-device-remove:hover {9066+  color: var(--peach-danger, #c25b4a);9067+}9068+.peach-chirp-unpair-confirm {9069+  display: flex;9070+  gap: 6px;9071+  align-items: center;9072+}9073+9074+/* Pro-gate card — soft upsell with a stylised wave illustration. */9075+.peach-chirp-pro-card {9076+  margin: 8px 0;9077+  padding: 22px 18px;9078+  border-radius: 14px;9079+  background: color-mix(in srgb, var(--peach-bg-elevated, var(--peach-bg)) 84%, var(--peach-accent) 16%);9080+  border: 1px solid color-mix(in srgb, var(--peach-accent) 18%, transparent);9081+  display: flex;9082+  flex-direction: column;9083+  align-items: center;9084+  gap: 12px;9085+  text-align: center;9086+}9087+.peach-chirp-pro-illustration {9088+  display: flex;9089+  align-items: center;9090+  justify-content: center;9091+  gap: 10px;9092+  color: var(--peach-accent);9093+}9094+.peach-chirp-pro-mark {9095+  width: 44px;9096+  height: 44px;9097+}9098+.peach-chirp-pro-waves {9099+  opacity: 0.85;9100+}9101+.peach-chirp-pro-headline {9102+  margin: 2px 0 0;9103+  font-size: 16px;9104+  font-weight: 600;9105+  color: var(--peach-text);9106+}9107+.peach-chirp-pro-body {9108+  margin: 0;9109+  font-size: 13px;9110+  line-height: 1.55;9111+  color: var(--peach-text-dim);9112+  max-width: 320px;9113+}

view file

src/popup/views/ChirpSync.tsx+390 −0
@@ -0,0 +1,390 @@1+// ChirpSync — the v3 Settings → Sync surface.2+//3+// Three modes the user can be in:4+//5+//   1. Locked behind Pro (free user) — soft upsell card, "Learn more" routes6+//      to the License section.7+//   2. Idle — list of paired devices + "Pair a device" hero button. Stale8+//      devices (>7 days since last sync) show a soft warning indicator;9+//      vouched devices (received via device manifest, v2 brief §4.2) show10+//      an "Added automatically" badge.11+//   3. Pairing — ChirpPairingFlow takes over the full surface and drives12+//      the three-act bird animation in lockstep with the audio protocol.13+//14+// The pairing orchestrator lives in `popup/chirp-pair.ts`; this view is15+// the binding between that orchestrator, the background-message16+// CHIRP_* handlers, and the Settings navigation surrounding it.17+18+import {19+  Component,20+  Show,21+  createMemo,22+  createSignal,23+  For,24+  onMount,25+} from "solid-js";26+import { sendMessage } from "../../shared/bridge";27+import type {28+  DeviceType,29+  PairedDevice,30+  PairingProgress,31+} from "../../shared/sync-types";32+import { ChirpPairingFlow } from "../components/ChirpPairingFlow";33+import {34+  ChirpPairingFailure,35+  runChirpPairing,36+  type ChirpPairingDeps,37+} from "../chirp-pair";38+import { getLocalSubnet } from "../../shared/local-network";39+import { PeachMark } from "../components/PeachMark";40+41+export interface ChirpSyncViewProps {42+  isPro: boolean;43+  onBack(): void;44+  onLearnPro(): void;45+}46+47+const STALE_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;48+49+type Mode = "idle" | "pairing";50+51+export const ChirpSyncView: Component<ChirpSyncViewProps> = (props) => {52+  const [devices, setDevices] = createSignal<PairedDevice[]>([]);53+  const [mode, setMode] = createSignal<Mode>("idle");54+  const [pairing, setPairing] = createSignal<PairingProgress>({ phase: "idle" });55+  const [error, setError] = createSignal<string | null>(null);56+  const [confirmUnpair, setConfirmUnpair] = createSignal<string | null>(null);57+58+  onMount(() => {59+    void refresh();60+  });61+62+  async function refresh(): Promise<void> {63+    if (!props.isPro) return;64+    const res = await sendMessage({ type: "CHIRP_LIST_PAIRED_DEVICES" });65+    const data = (res as { ok: boolean; data?: { devices: PairedDevice[] } }).data;66+    setDevices(data?.devices ?? []);67+  }68+69+  async function handleStartPairing(): Promise<void> {70+    setError(null);71+    setPairing({ phase: "idle" });72+    setMode("pairing");73+74+    const deps: ChirpPairingDeps = {75+      onProgress: (p) => setPairing(p),76+      fetchLocalIdentity: async () => {77+        const res = await sendMessage({ type: "CHIRP_GET_LOCAL_IDENTITY" });78+        const r = res as { ok: boolean; data?: Parameters<ChirpPairingDeps["onProgress"]>[0] & object; error?: string };79+        if (!r.ok || !r.data) throw new Error(r.error ?? "identity_unavailable");80+        // The handler returns a LocalIdentity; we trust the background's shape.81+        return r.data as unknown as Awaited<ReturnType<ChirpPairingDeps["fetchLocalIdentity"]>>;82+      },83+      completePairing: async (paired) => {84+        const res = await sendMessage({85+          type: "CHIRP_COMPLETE_PAIRING",86+          payload: { paired },87+        });88+        const r = res as { ok: boolean; error?: string };89+        if (!r.ok) throw new Error(r.error ?? "complete_pairing_failed");90+      },91+      getLocalSubnet,92+      openPairingTransport: async (endpoint) => {93+        // Native WebSocket transport for the pairing-mode peer scan. The94+        // chirp-pair orchestrator only uses send/receive/close, so a thin95+        // adapter over the platform WebSocket suffices.96+        return new Promise((resolve, reject) => {97+          let ws: WebSocket;98+          try {99+            ws = new WebSocket(`ws://${endpoint.ip}:${endpoint.port}/`);100+          } catch (err) {101+            reject(err);102+            return;103+          }104+          const inbox: Uint8Array[] = [];105+          const waiters: Array<(b: Uint8Array) => void> = [];106+          let closed = false;107+          ws.binaryType = "arraybuffer";108+          const timer = setTimeout(() => {109+            try { ws.close(); } catch { /* ignore */ }110+            reject(new Error("pairing_transport_timeout"));111+          }, 2_000);112+          ws.onopen = () => {113+            clearTimeout(timer);114+            resolve({115+              async send(data: Uint8Array) {116+                ws.send(data as Uint8Array<ArrayBuffer>);117+              },118+              receive(): Promise<Uint8Array> {119+                if (closed) return Promise.reject(new Error("closed"));120+                const v = inbox.shift();121+                if (v) return Promise.resolve(v);122+                return new Promise<Uint8Array>((res) => waiters.push(res));123+              },124+              async close() {125+                try { ws.close(); } catch { /* ignore */ }126+              },127+            });128+          };129+          ws.onmessage = (ev) => {130+            if (!(ev.data instanceof ArrayBuffer)) return;131+            const bytes = new Uint8Array(ev.data);132+            const w = waiters.shift();133+            if (w) w(bytes); else inbox.push(bytes);134+          };135+          ws.onerror = () => {136+            clearTimeout(timer);137+            reject(new Error("pairing_transport_error"));138+          };139+          ws.onclose = () => {140+            closed = true;141+            while (waiters.length > 0) waiters.shift()!(new Uint8Array(0));142+          };143+        });144+      },145+    };146+147+    try {148+      await runChirpPairing(deps);149+      await refresh();150+    } catch (err) {151+      if (err instanceof ChirpPairingFailure) {152+        // The orchestrator already pushed the terminal phase into153+        // `pairing()`; the inline error copy will render. No further154+        // action needed — the user can tap "Try again" or "Cancel".155+        return;156+      }157+      setError((err as Error).message);158+      setMode("idle");159+    }160+  }161+162+  async function handleUnpair(deviceId: string): Promise<void> {163+    await sendMessage({ type: "CHIRP_UNPAIR_DEVICE", payload: { deviceId } });164+    setConfirmUnpair(null);165+    await refresh();166+  }167+168+  function relativeTime(when: number | undefined): string {169+    if (!when) return "Not synced yet";170+    const elapsed = Date.now() - when;171+    if (elapsed < 60_000) return "Just now";172+    const minutes = Math.floor(elapsed / 60_000);173+    if (minutes < 60) return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;174+    const hours = Math.floor(minutes / 60);175+    if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"} ago`;176+    const days = Math.floor(hours / 24);177+    if (days < 7) return `${days} day${days === 1 ? "" : "s"} ago`;178+    return "Not seen recently";179+  }180+181+  function isStale(d: PairedDevice): boolean {182+    if (d.lastSyncedAt === undefined) return false;183+    return Date.now() - d.lastSyncedAt > STALE_THRESHOLD_MS;184+  }185+186+  // --- Sub-views --------------------------------------------------187+188+  const proGateView = () => (189+    <div class="peach-chirp-settings peach-chirp-settings--gated">190+      <header class="peach-chirp-settings-header">191+        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>192+          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">193+            <path d="M11 4 L5 9 L11 14" />194+          </svg>195+        </button>196+        <h1 class="peach-chirp-settings-title">Sync</h1>197+      </header>198+      <div class="peach-chirp-pro-card">199+        <div class="peach-chirp-pro-illustration">200+          <PeachMark class="peach-chirp-pro-mark" />201+          <svg class="peach-chirp-pro-waves" width="200" height="80" viewBox="0 0 200 80" fill="none" stroke="currentColor" stroke-width="1.4">202+            <path d="M 30 40 Q 100 10 170 40" />203+            <path d="M 30 50 Q 100 20 170 50" opacity="0.6" />204+            <path d="M 30 60 Q 100 30 170 60" opacity="0.3" />205+          </svg>206+        </div>207+        <h2 class="peach-chirp-pro-headline">Chirp Sync is a Peach Pro feature</h2>208+        <p class="peach-chirp-pro-body">209+          Pair your phone with your laptop using a quick inaudible chirp —210+          then your vault stays in sync across devices on your local network.211+          No QR codes, no relay servers, no cloud accounts.212+        </p>213+        <button class="peach-btn peach-btn-primary" onClick={() => props.onLearnPro()}>214+          Learn more about Peach Pro215+        </button>216+      </div>217+    </div>218+  );219+220+  const idleView = () => (221+    <div class="peach-chirp-settings">222+      <header class="peach-chirp-settings-header">223+        <button class="peach-icon-btn" aria-label="Back" onClick={() => props.onBack()}>224+          <svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">225+            <path d="M11 4 L5 9 L11 14" />226+          </svg>227+        </button>228+        <h1 class="peach-chirp-settings-title">Sync</h1>229+      </header>230+231+      <Show when={error()}>232+        <div class="peach-chirp-settings-error" role="alert">{error()}</div>233+      </Show>234+235+      <Show when={devices().length === 0}>236+        <div class="peach-chirp-hero">237+          <ChirpHeroBirds />238+          <h2 class="peach-chirp-hero-headline">Pair your phone with this Peach</h2>239+          <p class="peach-chirp-hero-sub">240+            Hold your phone near your laptop. Peach will chirp once,241+            inaudibly, and pair automatically. No codes, no servers.242+          </p>243+        </div>244+      </Show>245+246+      <div class="peach-chirp-pair-cta">247+        <button class="peach-btn peach-btn-primary peach-chirp-pair-button" onClick={() => void handleStartPairing()}>248+          <svg class="peach-chirp-pair-button-icon" width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">249+            <path d="M3 9 L9 3 L15 9" />250+            <path d="M3 13 L9 7 L15 13" opacity="0.5" />251+          </svg>252+          Pair a device253+        </button>254+      </div>255+256+      <Show when={devices().length > 0}>257+        <h2 class="peach-chirp-settings-section">Paired devices</h2>258+        <ul class="peach-chirp-device-list">259+          <For each={devices()}>260+            {(d) => (261+              <li class="peach-chirp-device-row" data-stale={isStale(d) ? "true" : "false"}>262+                <DeviceTypeIcon type={d.deviceType ?? "extension"} />263+                <div class="peach-chirp-device-info">264+                  <span class="peach-chirp-device-name">{d.deviceName ?? "Unnamed device"}</span>265+                  <span class="peach-chirp-device-meta">266+                    {relativeTime(d.lastSyncedAt)}267+                    <Show when={d.trustSource === "vouched"}>268+                      <span class="peach-chirp-device-badge">Added automatically</span>269+                    </Show>270+                    <Show when={isStale(d)}>271+                      <span class="peach-chirp-device-stale">Not seen recently</span>272+                    </Show>273+                  </span>274+                </div>275+                <Show276+                  when={confirmUnpair() !== d.deviceId}277+                  fallback={278+                    <div class="peach-chirp-unpair-confirm">279+                      <button class="peach-btn peach-btn-ghost peach-btn-sm" onClick={() => setConfirmUnpair(null)}>280+                        Cancel281+                      </button>282+                      <button class="peach-btn peach-btn-destructive peach-btn-sm" onClick={() => void handleUnpair(d.deviceId)}>283+                        Remove284+                      </button>285+                    </div>286+                  }287+                >288+                  <button289+                    class="peach-icon-btn peach-chirp-device-remove"290+                    aria-label={`Unpair ${d.deviceName}`}291+                    onClick={() => setConfirmUnpair(d.deviceId)}292+                  >293+                    <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">294+                      <path d="M3 3 L11 11 M11 3 L3 11" />295+                    </svg>296+                  </button>297+                </Show>298+              </li>299+            )}300+          </For>301+        </ul>302+      </Show>303+304+      <footer class="peach-chirp-settings-footer">305+        <p>306+          Chirp Sync stays entirely on your local network. No relay servers,307+          no cloud accounts — just your devices in the same room.308+        </p>309+      </footer>310+    </div>311+  );312+313+  const pairingView = () => (314+    <div class="peach-chirp-settings peach-chirp-settings--pairing">315+      <ChirpPairingFlow316+        progress={pairing()}317+        onCancel={() => setMode("idle")}318+        onRetry={() => void handleStartPairing()}319+        onDismiss={() => {320+          setMode("idle");321+          void refresh();322+        }}323+      />324+    </div>325+  );326+327+  const currentMode = createMemo(() => {328+    if (!props.isPro) return "gated";329+    if (mode() === "pairing") return "pairing";330+    return "idle";331+  });332+333+  return (334+    <Show335+      when={currentMode() === "gated"}336+      fallback={337+        <Show when={currentMode() === "pairing"} fallback={idleView()}>338+          {pairingView()}339+        </Show>340+      }341+    >342+      {proGateView()}343+    </Show>344+  );345+};346+347+const DeviceTypeIcon: Component<{ type: DeviceType }> = (props) => {348+  return (349+    <div class="peach-chirp-device-icon" data-type={props.type}>350+      <Show when={props.type === "android" || props.type === "ios"}>351+        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">352+          <rect x="6" y="2" width="8" height="16" rx="1.5" />353+          <circle cx="10" cy="15.4" r="0.6" fill="currentColor" />354+        </svg>355+      </Show>356+      <Show when={props.type === "extension" || props.type === "desktop"}>357+        <svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">358+          <rect x="2" y="4" width="16" height="10" rx="1.4" />359+          <path d="M7 17 L13 17" />360+          <path d="M9 14 L9 17 M11 14 L11 17" />361+        </svg>362+      </Show>363+    </div>364+  );365+};366+367+// Schematic two-bird illustration shown when no devices are paired yet.368+// The desktop bird stands ready, the mobile bird hops in from the right —369+// frozen mid-greeting. The live pairing animation drives the same SVG370+// shapes from ChirpPairingFlow once the user taps "Pair a device".371+const ChirpHeroBirds: Component = () => {372+  return (373+    <svg class="peach-chirp-hero-svg" width="240" height="120" viewBox="0 0 240 120" fill="none">374+      <ellipse cx="80" cy="78" rx="32" ry="24" class="peach-chirp-bird-body" />375+      <circle cx="98" cy="56" r="16" class="peach-chirp-bird-head" />376+      <circle cx="103" cy="54" r="2.4" class="peach-chirp-bird-eye" />377+      <path d="M 113 56 L 122 56 L 116 62 Z" class="peach-chirp-bird-beak" />378+      <path d="M 60 64 Q 50 76 60 86" class="peach-chirp-bird-wing" fill="none" stroke-width="2.4" stroke-linecap="round" />379+380+      <path d="M 130 60 Q 160 36 180 60" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.45" />381+      <path d="M 130 70 Q 160 46 180 70" stroke="var(--peach-accent)" stroke-width="1.6" fill="none" stroke-linecap="round" opacity="0.25" />382+383+      <ellipse cx="184" cy="80" rx="22" ry="17" class="peach-chirp-bird-body" />384+      <circle cx="170" cy="64" r="11" class="peach-chirp-bird-head" />385+      <circle cx="166" cy="62" r="1.8" class="peach-chirp-bird-eye" />386+      <path d="M 158 64 L 151 64 L 156 69 Z" class="peach-chirp-bird-beak" />387+      <path d="M 200 72 Q 210 82 200 92" class="peach-chirp-bird-wing" fill="none" stroke-width="2.2" stroke-linecap="round" />388+    </svg>389+  );390+};

view file

src/popup/views/Lock.tsx+3 −157
@@ -532,11 +532,11 @@532532 533533 // ── Restore tab view ───────────────────────────────────────────────────────534534 535-type RestoreTab = "symbols" | "csv" | "phone";535+type RestoreTab = "symbols" | "csv";536536 537537 function tabLabel(tab: RestoreTab): string {538538   return (539-    { symbols: "PeachScript", csv: "CSV", phone: "From Phone" }[tab]539+    { symbols: "PeachScript", csv: "CSV" }[tab]540540   );541541 }542542 @@ -580,7 +580,7 @@580580 581581       <div class="peach-newvault-card peach-restore-tabcard">582582         <div class="peach-restore-tabs" role="tablist">583-          <For each={["symbols", "csv", "phone"] as RestoreTab[]}>583+          <For each={["symbols", "csv"] as RestoreTab[]}>584584             {(tab) => (585585               <button586586                 class="peach-restore-tab"@@ -601,9 +601,6 @@601601           <Show when={activeTab() === "csv"}>602602             <RestoreCSV hasVault={props.hasVault} onSuccess={handleRestoreSuccess} />603603           </Show>604-          <Show when={activeTab() === "phone"}>605-            <RestoreFromPhone onSuccess={handleRestoreSuccess} />606-          </Show>607604         </div>608605       </div>609606 @@ -963,157 +960,6 @@963960       <p class="peach-rt-csv-warning">PLAIN-TEXT — DELETE THE FILE AFTER IMPORT</p>964961       <Show when={error()}>965962         <p class="peach-rt-error">{error()}</p>966-      </Show>967-    </div>968-  );969-}970-971-// ── From Phone tab ─────────────────────────────────────────────────────────972-973-type PhoneState = "waiting" | "connected" | "trust" | "error";974-975-function RestoreFromPhone(props: { onSuccess: () => void | Promise<void> }) {976-  const [state, setState] = createSignal<PhoneState>("waiting");977-  const [code, setCode] = createSignal<string | null>(null);978-  const [qrUrl, setQrUrl] = createSignal<string | null>(null);979-  const [expired, setExpired] = createSignal(false);980-  const [deviceName, setDeviceName] = createSignal<string | null>(null);981-  const [accepting, setAccepting] = createSignal(false);982-  const [serverError, setServerError] = createSignal<string | null>(null);983-  let expireTimer: ReturnType<typeof setTimeout> | null = null;984-985-  function clearExpire() {986-    if (expireTimer !== null) { clearTimeout(expireTimer); expireTimer = null; }987-  }988-989-  async function startPairing() {990-    setExpired(false);991-    setCode(null);992-    setQrUrl(null);993-    setServerError(null);994-    clearExpire();995-    const res = await sendMessage({ type: "START_PAIRING_SERVER" });996-    const r = res as { ok: boolean; error?: string; data?: { token: string; expiresAt: number } };997-    if (!r.ok || !r.data) {998-      setServerError(r.error === "pro_required" ? "Pro required." : "Couldn't start pairing server.");999-      return;1000-    }1001-    const token = r.data.token;1002-    const formatted = token.length === 6 ? `${token.slice(0, 3)} ${token.slice(3)}` : token;1003-    setCode(formatted);1004-    const qrData = `peach://handoff?token=${token}`;1005-    try {1006-      const QRCode = (await import("qrcode")).default;1007-      const url = await QRCode.toDataURL(qrData, { errorCorrectionLevel: "M", margin: 2, scale: 4, color: { dark: "#000000", light: "#ffffff" } });1008-      setQrUrl(url);1009-    } catch {1010-      // QR optional — code is the primary affordance1011-    }1012-    const remaining = Math.max(0, r.data.expiresAt - Date.now());1013-    expireTimer = setTimeout(() => {1014-      setCode(null);1015-      setQrUrl(null);1016-      setExpired(true);1017-    }, remaining);1018-  }1019-1020-  function handleRuntimeMsg(msg: unknown) {1021-    const m = msg as { type?: string; payload?: { deviceName?: string } };1022-    if (m?.type === "PAIRING_COMPLETED") {1023-      clearExpire();1024-      setDeviceName(m.payload?.deviceName ?? "your phone");1025-      setState("trust");1026-    }1027-  }1028-1029-  onMount(() => {1030-    void startPairing();1031-    // chrome global is absent in the Capacitor WebView — guard at runtime.1032-    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1033-      chrome.runtime.onMessage.addListener(handleRuntimeMsg);1034-    }1035-  });1036-1037-  onCleanup(() => {1038-    clearExpire();1039-    if (typeof chrome !== "undefined" && chrome.runtime?.onMessage) {1040-      chrome.runtime.onMessage.removeListener(handleRuntimeMsg);1041-    }1042-    sendMessage({ type: "STOP_PAIRING_SERVER" }).catch(() => {});1043-  });1044-1045-  async function handleAccept(trust: boolean) {1046-    setAccepting(true);1047-    if (trust) {1048-      await sendMessage({ type: "ACCEPT_PAIRING" }).catch(() => {});1049-    }1050-    await props.onSuccess();1051-  }1052-1053-  return (1054-    <div class="peach-rt-phone">1055-      <Show when={state() === "waiting"}>1056-        <Show when={!expired()}>1057-          <Show when={code() !== null} fallback={<p class="peach-rt-phone-hint">starting…</p>}>1058-            <p class="peach-rt-phone-code">{code()}</p>1059-            <Show when={qrUrl() !== null}>1060-              <img class="peach-rt-phone-qr" src={qrUrl()!} alt="pairing QR code" />1061-            </Show>1062-            <p class="peach-rt-phone-hint">1063-              Open Peach on your phone and tap<br />1064-              Settings → Send to desktop.<br />1065-              Same WiFi required.1066-            </p>1067-          </Show>1068-          <Show when={serverError()}>1069-            <p class="peach-rt-error">{serverError()}</p>1070-          </Show>1071-        </Show>1072-        <Show when={expired()}>1073-          <p class="peach-rt-phone-hint">Code expired.</p>1074-          <button class="peach-btn peach-btn--ghost peach-btn--sm" onClick={() => void startPairing()}>1075-            Generate new code1076-          </button>1077-        </Show>1078-      </Show>1079-1080-      <Show when={state() === "connected"}>1081-        <p class="peach-rt-phone-hint">1082-          Connected to {deviceName() ?? "your phone"}.<br />1083-          Receiving vault…1084-        </p>1085-      </Show>1086-1087-      <Show when={state() === "trust"}>1088-        <p class="peach-rt-phone-hint" style="margin-bottom:10px">1089-          Vault received from {deviceName() ?? "your phone"}.<br />1090-          Trust this network for sync?1091-        </p>1092-        <button1093-          class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit"1094-          onClick={() => void handleAccept(true)}1095-          disabled={accepting()}1096-        >1097-          Yes, trust this network1098-        </button>1099-        <button1100-          class="peach-btn peach-btn--ghost peach-btn--sm"1101-          style="margin-top:6px"1102-          onClick={() => void handleAccept(false)}1103-          disabled={accepting()}1104-        >1105-          Not now1106-        </button>1107-      </Show>1108-1109-      <Show when={state() === "error"}>1110-        <p class="peach-rt-phone-hint">1111-          Could not connect. Check that both devices<br />1112-          are on the same WiFi network.1113-        </p>1114-        <button class="peach-btn peach-btn--accent peach-btn--md peach-rt-submit" onClick={() => { setState("waiting"); void startPairing(); }}>1115-          Try again1116-        </button>1117963       </Show>1118964     </div>1119965   );

view file

src/popup/views/SyncPairing.tsx+0 −608

not shown here: too long for one page

view file at the parent

src/shared/local-network.ts+9 −89
@@ -1,7 +1,13 @@1-const SYNC_PORT = 44100;2-const PROBE_TIMEOUT_MS = 400;1+// Local network discovery — minimal helper used by chirp pairing.2+//3+// `getLocalSubnet` returns the /24 prefix (e.g. "192.168.1") that the4+// browser is on, derived from a host-relative WebRTC ICE candidate. The5+// extension uses this to scan the LAN for the peer's pairing-port6+// WebSocket after the audio chirp exchange completes. Legacy v2.147+// discovery/pair probes have been removed — see chirp-pair.ts for the8+// v3 pairing-port scan and chirp-sync-transport.ts for the rate-limited9+// Phase-2 subnet probe.310 4-// Detect the local subnet via WebRTC ICE candidate parsing.511 // Returns e.g. "192.168.1" or null if unavailable.612 export async function getLocalSubnet(): Promise<string | null> {713   return new Promise((resolve) => {@@ -19,90 +25,4 @@1925       resolve(null);2026     }2127   });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;10828 }

view file

src/shared/sync-types.ts+19 −79
@@ -1,11 +1,5 @@1-// Chirp Sync — type definitions.1+// Chirp Sync — type definitions (Master Brief v2).22 //3-// This file is in the middle of a v2.14 → v3 migration. The v3 chirp-sync4-// architecture (audio-bootstrapped pairing + manifest-diff WebSocket sync)5-// is being added alongside the v2.14 full-blob-over-native-host types.6-// The v2.14 types are scheduled for removal once every consumer in7-// messages.ts has been migrated; do not write new code against them.8-//93 // v3 — audio pairing once per device pair (Phase 1). All ongoing vault sync104 // after that runs over a direct WebSocket on the local network (Phase 2).115 // The two phases never overlap.@@ -64,23 +58,6 @@6458 // ---------------------------------------------------------------------------6559 6660 /**67- * v3 paired-device record. Stored in `chrome.storage.sync` so the registry68- * roams across browser profiles signed into Chrome/Firefox sync.69- *70- * `longTermPublicKeyHex` is the counterpart's vault-derived sync identity71- * static X25519 public key — see `deriveSyncIdentityKeypair`. Mutual Noise72- * XX authentication on every WebSocket connection rejects any peer not73- * present in this registry.74- *75- * `lastKnownIp` is intentionally NOT stored here — it's per-network and76- * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See77- * `getLastKnownIp` / `setLastKnownIp` in sync-storage.ts.78- *79- * Legacy v2.14 fields `displayName`, `x25519PublicKey`, `lastSeenAt`,80- * `lastSyncAt`, `lastKnownIp` are kept for migration only; new code reads81- * `deviceName`, `longTermPublicKeyHex`, `lastSyncedAt`.82- */83-/**8461  * Trust source of a paired-device entry — per v2 brief §4.2.8562  *8663  *   "direct"  — established via an audio pairing session with this device.@@ -93,6 +70,19 @@9370  */9471 export type TrustSource = "direct" | "vouched";9572 73+/**74+ * v3 paired-device record. Stored in `chrome.storage.sync` so the registry75+ * roams across browser profiles signed into Chrome/Firefox sync.76+ *77+ * `longTermPublicKeyHex` is the counterpart's vault-derived X25519 sync78+ * identity static key — see `deriveSyncIdentityKeypair`. Mutual Noise XX79+ * authentication on every WebSocket connection rejects any peer not80+ * present in this registry.81+ *82+ * `lastKnownIp` is intentionally NOT stored here — it's per-network and83+ * lives in `chrome.storage.local` keyed by `longTermPublicKeyHex`. See84+ * `getLastKnownIp` / `setLastKnownIp` in `paired-devices.ts`.85+ */9686 export interface PairedDevice {9787   deviceId: string;9888   deviceName?: string;@@ -102,30 +92,18 @@10292    * Long-term Ed25519 verification key for the peer's device-manifest10393    * signatures (v2 brief §4.3). Derived deterministically from the same10494    * master key as `longTermPublicKeyHex`, via `deriveSyncSigningKeypair`.105-   * Optional during the migration — older paired records lack it, so106-   * manifests issued by such peers can't be verified and will be rejected.10795    */10896   signingPublicKeyHex?: string;10997   /** v2 brief §4.2 — distinguishes direct-paired vs vouched-via-manifest. */11098   trustSource?: TrustSource;11199   /**112-   * The peer's WebSocket sync port. Android picks a random high port113-   * (49152–65535) at first launch and announces it inside the encrypted114-   * pairing channel; the extension caches it here. iOS / Tauri sync hosts115-   * follow the same convention.100+   * The peer's WebSocket sync port. Mobile hosts pick a random high port101+   * (49152–65535) at first launch and announce it inside the encrypted102+   * pairing channel; the extension caches it here.116103    */117104   wsPort?: number;118105   pairedAt: number;119106   lastSyncedAt?: number;120-121-  // v2.14 legacy — kept readable so the migration sweep doesn't crash122-  // every test that still constructs the old shape. New writers must use123-  // the v3 fields above.124-  displayName?: string;125-  x25519PublicKey?: string;126-  lastSeenAt?: number;127-  lastSyncAt?: number;128-  lastKnownIp?: string;129107 }130108 131109 // ---------------------------------------------------------------------------@@ -133,8 +111,8 @@133111 // ---------------------------------------------------------------------------134112 135113 /**136- * Phase 1 pairing state, surfaced to the SyncPairing view. The view binds137- * its bird-animation acts directly to these states: `listening` and114+ * Phase 1 pairing state, surfaced to the chirp pairing view. The view115+ * binds its bird-animation acts directly to these states: `listening` and138116  * `chirping` map to Act 1, `handshake` maps to Act 2, `paired` to Act 3.139117  */140118 export type PairingPhase =@@ -218,41 +196,3 @@218196 }219197 220198 export type SyncStatusV3 = "up_to_date" | "syncing" | "unreachable" | "never";221-222-// ---------------------------------------------------------------------------223-// v2.14 legacy types — DO NOT USE IN NEW CODE224-//225-// These remain only because messages.ts and the v2.14 coordinator/network226-// modules import them. Once the migration sweep lands, this whole block227-// goes away.228-// ---------------------------------------------------------------------------229-230-export interface TrustedNetwork {231-  ssid?: string;232-  bssid?: string;233-  isTailscale: boolean;234-  addedAt: number;235-}236-237-export interface SyncableDevice {238-  publicKeyHex: string;239-  deviceName: string;240-  pairedAt: number;241-}242-243-export type SyncMessage =244-  | { type: 'SYNC_HELLO' }245-  | { type: 'SYNC_VAULT_SEND'; blob: string; timestamp: number; license?: string; devices: SyncableDevice[] }246-  | { type: 'SYNC_VAULT_ACK'; applied: boolean }247-  | { type: 'SYNC_DONE' }248-  | { type: 'SYNC_ERROR'; error: string };249-250-export type SyncStatus = "synced" | "syncing" | "no_peers" | "not_trusted" | "offline";251-252-export interface IncomingPairingRequest {253-  deviceName: string;254-  publicKeyHex: string;255-  receivedAt: number;256-  expiresAt: number;257-  connectionId?: string;258-}

view file

src/shared/sync-wire.ts+0 −31
@@ -1,31 +0,0 @@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 at the parent

src/shared/types.ts+0 −19
@@ -251,26 +251,11 @@251251   | "VERIFY_MAIN_PASSWORD"252252   | "CHANGE_PASSWORD"253253   | "GET_SITE_PASSWORD_POLICY"254-  | "GET_SYNC_STATE"255-  | "GET_PAIRED_DEVICES"256-  | "GET_SYNC_STATUS"257-  | "TRIGGER_SYNC"258-  | "ACCEPT_PAIRING"259-  | "REMOVE_PAIRED_DEVICE"260-  | "REMOVE_TRUSTED_NETWORK"261-  | "MARK_NETWORK_TRUSTED"262-  | "RESET_SYNC_IDENTITY"263254   | "CHIRP_GET_LOCAL_IDENTITY"264255   | "CHIRP_COMPLETE_PAIRING"265256   | "CHIRP_LIST_PAIRED_DEVICES"266257   | "CHIRP_UNPAIR_DEVICE"267258   | "CHIRP_RENAME_LOCAL_DEVICE"268-  | "GET_INCOMING_PAIRING_REQUEST"269-  | "RESPOND_INCOMING_PAIRING"270-  | "DEBUG_INJECT_INCOMING_PAIRING"271-  | "START_PAIRING_SERVER"272-  | "STOP_PAIRING_SERVER"273-  | "GET_RECENT_PAIRING"274259   | "GET_PENDING_PASSKEY"275260   | "APPROVE_PASSKEY"276261   | "DENY_PASSKEY"@@ -312,9 +297,6 @@312297   | "VALIDATE_RECOVERY_WORDS"313298   | "RECOVER_WITH_WORDS"314299   | "GET_RECOVERY_WORDS"315-  | "SCAN_PAIRING_QR"316-  | "SYNC_SESSION_STARTED"317-  | "GET_LOCAL_IP"318300   | "GET_IDENTITY_SUGGESTIONS"319301   | "TOUCH_AUTO_LOCK"320302   | "POPULATE_INLINE_VAULT"@@ -322,7 +304,6 @@322304   | "DELETE_NOTE"323305   | "GET_NOTES"324306   | "SEARCH_VAULT"325-  | "PAIRING_COMPLETED"326307   | "ML_INIT"327308   | "ML_PREDICT_BATCH"328309   // Content-script-bound message types — sent BY the background TO content

view file

src/shared/websocket-sync-transport.ts+0 −108
@@ -1,108 +0,0 @@1-import { noiseNKInitiator, performInitiatorAuth } 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-  ourPrivateKey: Uint8Array,18-  ourPublicKey: Uint8Array,19-): Promise<SyncTransport> {20-  const remotePublicKey = hexToBytes(remotePublicKeyHex);21-22-  return new Promise((resolve, reject) => {23-    const ws = new WebSocket(url);24-25-    const messageQueue: string[] = [];26-    const waiters: ((frame: string) => void)[] = [];27-28-    const enqueue = (frame: string) => {29-      if (waiters.length > 0) {30-        waiters.shift()!(frame);31-      } else {32-        messageQueue.push(frame);33-      }34-    };35-36-    ws.onmessage = (event) => enqueue(event.data as string);37-    ws.onerror = () => reject(new Error('WebSocket connection failed'));38-    ws.onclose = () => { waiters.forEach(w => w('')); waiters.length = 0; };39-40-    ws.onopen = async () => {41-      try {42-        const rawSend = (msg: Uint8Array) => {43-          ws.send(bytesToBase64(msg));44-          return Promise.resolve();45-        };46-        const rawReceive = (): Promise<Uint8Array> => new Promise((res) => {47-          const handle = (frame: string) => {48-            if (frame === '') { res(new Uint8Array(0)); return; }49-            res(base64ToBytes(frame));50-          };51-          if (messageQueue.length > 0) handle(messageQueue.shift()!);52-          else waiters.push(handle);53-        });54-55-        // Noise NK handshake — desktop is always the initiator.56-        const session = await noiseNKInitiator(remotePublicKey, rawSend, rawReceive);57-58-        // Post-handshake initiator auth round: prove possession of our static59-        // private key to the responder so the responder can verify we are a60-        // paired device. Plaintext bytes are Noise-encrypted on the wire.61-        const encryptedSend = (data: Uint8Array) =>62-          rawSend(session.encrypt(data));63-        const encryptedReceive = async () => {64-          const frame = await rawReceive();65-          if (frame.length === 0) return frame;66-          return session.decrypt(frame);67-        };68-        await performInitiatorAuth(69-          session,70-          ourPrivateKey,71-          ourPublicKey,72-          remotePublicKey,73-          encryptedSend,74-          encryptedReceive,75-        );76-77-        resolve({78-          send: async (plaintext) => {79-            ws.send(bytesToBase64(session.encrypt(plaintext)));80-          },81-          receive: () => new Promise((res) => {82-            const handle = (frame: string) => {83-              if (frame === '') { res(new Uint8Array(0)); return; }84-              res(session.decrypt(base64ToBytes(frame)));85-            };86-            if (messageQueue.length > 0) handle(messageQueue.shift()!);87-            else waiters.push(handle);88-          }),89-          close: () => {90-            ws.close();91-            return Promise.resolve();92-          },93-        });94-      } catch (err) {95-        ws.close();96-        reject(err);97-      }98-    };99-  });100-}101-102-function hexToBytes(hex: string): Uint8Array {103-  const bytes = new Uint8Array(hex.length / 2);104-  for (let i = 0; i < hex.length; i += 2) {105-    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);106-  }107-  return bytes;108-}

view file at the parent