76230f557838

wire START_PAIRING_SERVER, CONNECT_WITH_CODE, GET_RECENT_PAIRING handlers and auto-accept on active token

time
2026-05-01 02:42 UTC · 5 months ago
machine
koh2
parent
f2b923812414
6 files src/background/messages.ts+120 −0src/background/pairing-server-state.ts+74 −0src/background/sync-identity-glue.ts+30 −0src/background/sync-lifecycle.ts+88 −16src/background/sync.ts+50 −2src/shared/types.ts+4 −0
src/background/messages.ts+120 −0
@@ -103,6 +103,22 @@103103 }104104 105105 // ---------------------------------------------------------------------------106+// 6-digit code pairing.107+108+const PAIRING_TOKEN_TTL_MS = 5 * 60 * 1000;109+110+function generateSixDigitToken(): string {111+  // crypto.getRandomValues, not Math.random — the token is the only secret112+  // protecting the pairing window from a same-LAN attacker. 6 decimal digits113+  // is 1 in 1,000,000; combined with a 5-minute TTL and pairing-mode gating114+  // on the responder this is the right tradeoff between UX and security.115+  const buf = new Uint32Array(1);116+  crypto.getRandomValues(buf);117+  const value = buf[0] % 1_000_000;118+  return value.toString().padStart(6, "0");119+}120+121+// ---------------------------------------------------------------------------106122 // Peer discovery glue (Phase 7 popup pairing flow).107123 //108124 // The popup opens discovery via START_PEER_DISCOVERY. We connect to the@@ -859,6 +875,110 @@859875           // best-effort cleanup860876         }861877         return { ok: true };878+      }879+      case "START_PAIRING_SERVER": {880+        const gate = await requirePro("sync");881+        if (!gate.allowed) return { ok: false, error: "pro_required" };882+        try {883+          const sync = await import('./sync');884+          const { setActivePairingToken } = await import('./pairing-server-state');885+          const { getOrCreateLocalDeviceId } = await import('./sync-identity-glue');886+          sync.connectSyncHost();887+          const token = generateSixDigitToken();888+          const expiresAt = Date.now() + PAIRING_TOKEN_TTL_MS;889+          const deviceId = await getOrCreateLocalDeviceId();890+          const name = await getLocalDeviceName();891+          await sync.startPairingServer({ token, deviceId, name });892+          await setActivePairingToken({ token, expiresAt });893+          return { ok: true, data: { token, expiresAt } };894+        } catch (err) {895+          return { ok: false, error: err instanceof Error ? err.message : "pairing_server_failed" };896+        }897+      }898+      case "STOP_PAIRING_SERVER": {899+        try {900+          const sync = await import('./sync');901+          const { setActivePairingToken } = await import('./pairing-server-state');902+          await setActivePairingToken(null);903+          if (sync.isHostConnected()) {904+            await sync.stopPairingServer();905+          }906+        } catch {907+          // best-effort cleanup908+        }909+        return { ok: true };910+      }911+      case "CONNECT_WITH_CODE": {912+        const gate = await requirePro("sync");913+        if (!gate.allowed) return { ok: false, error: "pro_required" };914+        const { token: rawToken } = msg.payload as { token: string };915+        const token = String(rawToken).replace(/\D/g, "");916+        if (token.length !== 6) return { ok: false, error: "invalid_code" };917+918+        const sync = await import('./sync');919+        const { encodeSyncMessage } = await import('../shared/sync-wire');920+        const { waitForPairingResponse } = await import('./sync-coordinator');921+        const { addPairedDevice } = await import('./sync-storage');922+        const { setRecentPairing } = await import('./pairing-server-state');923+924+        sync.connectSyncHost();925+        let connectionId: string | undefined;926+        try {927+          const peer = await sync.resolvePeerByCode(token, 8_000);928+          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);929+930+          const ourPublicKeyHex = await sync.getIdentityPublicKey();931+          const deviceName = await getLocalDeviceName();932+933+          sync.sendSyncData(934+            connectionId,935+            encodeSyncMessage({936+              type: "PAIR_REQUEST",937+              public_key_hex: ourPublicKeyHex,938+              device_name: deviceName,939+            }),940+          );941+942+          const response = await waitForPairingResponse(connectionId, 30_000);943+944+          if (response.type === "PAIR_ACCEPT") {945+            await sync.trustDevice(response.public_key_hex, response.device_name);946+            await addPairedDevice({947+              deviceId: peer.deviceId,948+              displayName: response.device_name || peer.name,949+              x25519PublicKey: response.public_key_hex,950+              pairedAt: Date.now(),951+              lastSeenAt: Date.now(),952+            });953+            sync.disconnectFromPeer(connectionId);954+            // Stash for the popup so a popup-close mid-handshake still surfaces955+            // a confirmation on next open.956+            await setRecentPairing({957+              deviceName: response.device_name || peer.name,958+              pairedAt: Date.now(),959+              side: "entering",960+            });961+            return { ok: true, data: { deviceName: response.device_name || peer.name } };962+          }963+964+          if (response.type === "PAIR_REJECT") {965+            sync.disconnectFromPeer(connectionId);966+            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };967+          }968+969+          sync.disconnectFromPeer(connectionId);970+          return { ok: false, error: "unexpected_response" };971+        } catch (err) {972+          if (connectionId) {973+            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }974+          }975+          return { ok: false, error: err instanceof Error ? err.message : "connect_failed" };976+        }977+      }978+      case "GET_RECENT_PAIRING": {979+        const { consumeRecentPairing } = await import('./pairing-server-state');980+        const recent = await consumeRecentPairing();981+        return { ok: true, data: recent };862982       }863983       case "INITIATE_PAIR_WITH_PEER": {864984         const gate = await requirePro("sync");

view file

src/background/pairing-server-state.ts+74 −0
@@ -0,0 +1,74 @@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

src/background/sync-identity-glue.ts+30 −0
@@ -0,0 +1,30 @@1+/**2+ * Local device id helpers shared between the lifecycle tick and the3+ * 6-digit pairing flow. Both need the same base64url-encoded 16-byte id4+ * the native host expects in `Advertise` / `StartPairingServer`.5+ */6+7+const DEVICE_ID_KEY = "peach_device_id";8+9+export async function getOrCreateLocalDeviceId(): Promise<string> {10+  try {11+    const stored = await chrome.storage.local.get(DEVICE_ID_KEY);12+    const value = (stored as Record<string, string | undefined>)[DEVICE_ID_KEY];13+    if (typeof value === "string" && value) return value;14+  } catch {15+    // fall through to generation16+  }17+  const random = crypto.getRandomValues(new Uint8Array(16));18+  let binary = "";19+  for (const byte of random) binary += String.fromCharCode(byte);20+  const id = btoa(binary)21+    .replace(/\+/g, "-")22+    .replace(/\//g, "_")23+    .replace(/=+$/g, "");24+  try {25+    await chrome.storage.local.set({ [DEVICE_ID_KEY]: id });26+  } catch {27+    // best-effort28+  }29+  return id;30+}

view file

src/background/sync-lifecycle.ts+88 −16
@@ -18,8 +18,13 @@1818 import * as sync from "./sync";1919 import { isProUser } from "./license";2020 import * as vault from "./vault";21-import { getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22-import { decodeSyncMessage } from "../shared/sync-wire";21+import { addPairedDevice, getPairedDevices, setIncomingPairingRequest } from "./sync-storage";22+import { decodeSyncMessage, encodeSyncMessage } from "../shared/sync-wire";23+import {24+  getActivePairingToken,25+  setActivePairingToken,26+  setRecentPairing,27+} from "./pairing-server-state";2328 2429 const DEVICE_ID_KEY = "peach_device_id";2530 const DEVICE_NAME_KEY = "peach_device_name";@@ -222,20 +227,7 @@222227       return;223228     }224229 225-    const now = Date.now();226-    void setIncomingPairingRequest({227-      deviceName,228-      publicKeyHex,229-      receivedAt: now,230-      expiresAt: now + 60_000,231-      connectionId: info.connectionId,232-    });233-234-    // Tell the popup. If it isn't open the runtime message no-ops; the235-    // request still sits in session storage for the next popup open.236-    chrome.runtime237-      .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })238-      .catch(() => {});230+    void completePairRequest(info.connectionId, publicKeyHex, deviceName);239231   });240232 241233   // Auto-cleanup if the connection drops before any frame arrives.@@ -247,6 +239,76 @@247239   });248240 }249241 242+/**243+ * Either auto-accept (active 6-digit code session) or queue the banner.244+ * The user's act of showing a code IS the consent, so a PAIR_REQUEST that245+ * arrives within an active code window does not need a second confirmation.246+ */247+async function completePairRequest(248+  connectionId: string,249+  publicKeyHex: string,250+  deviceName: string,251+): Promise<void> {252+  const activeToken = await getActivePairingToken();253+  if (activeToken) {254+    try {255+      const ourKey = await sync.getIdentityPublicKey();256+      const ourName = await getDeviceName();257+      sync.sendSyncData(258+        connectionId,259+        encodeSyncMessage({260+          type: "PAIR_ACCEPT",261+          public_key_hex: ourKey,262+          device_name: ourName,263+        }),264+      );265+      await sync.trustDevice(publicKeyHex, deviceName);266+      try {267+        await addPairedDevice({268+          deviceId: publicKeyHex,269+          displayName: deviceName,270+          x25519PublicKey: publicKeyHex,271+          pairedAt: Date.now(),272+          lastSeenAt: Date.now(),273+        });274+      } catch {275+        // Already paired — fine, idempotent.276+      }277+      await setRecentPairing({278+        deviceName,279+        pairedAt: Date.now(),280+        side: "showing",281+      });282+      // One code = one pairing. Clear the token so a second peer can't283+      // sneak in on the same window.284+      await setActivePairingToken(null);285+      try { sync.stopPairingServer(); } catch { /* best-effort */ }286+      chrome.runtime287+        .sendMessage({ type: "PAIRING_COMPLETED", payload: { deviceName } })288+        .catch(() => {});289+    } catch (err) {290+      console.warn("auto-accept pairing failed:", err);291+    } finally {292+      try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }293+    }294+    return;295+  }296+297+  // No active code — fall back to the legacy banner flow (kept as a no-op298+  // unless something off-flow drops a pairing-only connection on us).299+  const now = Date.now();300+  await setIncomingPairingRequest({301+    deviceName,302+    publicKeyHex,303+    receivedAt: now,304+    expiresAt: now + 60_000,305+    connectionId,306+  });307+  chrome.runtime308+    .sendMessage({ type: "INCOMING_PAIRING_REQUEST", payload: { deviceName } })309+    .catch(() => {});310+}311+250312 async function runResponderSession(connectionId: string): Promise<void> {251313   try {252314     const { createNativeSyncTransport, runBlobSyncSession } = await import(@@ -295,6 +357,16 @@295357 }296358 297359 export async function onVaultLocked(): Promise<void> {360+  // A locked Peach must not be accepting new trust relationships — tear down361+  // any active 6-digit pairing window before reconciling mDNS.362+  try {363+    await setActivePairingToken(null);364+    if (nativeHostConnected) {365+      try { await sync.stopPairingServer(); } catch { /* best-effort */ }366+    }367+  } catch {368+    // best-effort369+  }298370   await syncLifecycleTick();299371 }300372 

view file

src/background/sync.ts+50 −2
@@ -37,7 +37,10 @@3737   | { type: "GET_SESSION_KEY"; connection_id: string }3838   | { type: "STATUS" }3939   | { type: "ENTER_PAIRING_MODE" }40-  | { type: "EXIT_PAIRING_MODE" };40+  | { type: "EXIT_PAIRING_MODE" }41+  | { type: "START_PAIRING_SERVER"; token: string; device_id: string; name: string }42+  | { type: "STOP_PAIRING_SERVER" }43+  | { type: "RESOLVE_PEER_BY_CODE"; token: string; timeout_ms?: number };4144 4245 type HostPeer = {4346   host: string;@@ -87,7 +90,10 @@8790     }8891   | { type: "ERROR"; connection_id?: string | null; message: string }8992   | { type: "PAIRING_MODE_ENTERED" }90-  | { type: "PAIRING_MODE_EXITED" };93+  | { type: "PAIRING_MODE_EXITED" }94+  | { type: "PAIRING_SERVER_STARTED"; tcp_port: number; ws_port?: number | null }95+  | { type: "STOP_PAIRING_SERVER_OK" }96+  | { type: "PEER_RESOLVED"; peer: HostPeer };9197 9298 export interface PeerInfo {9399   host: string;@@ -416,6 +422,48 @@416422 417423 export async function exitPairingMode(): Promise<void> {418424   await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);425+}426+427+export interface PairingServerInfo {428+  tcpPort: number;429+  wsPort?: number;430+}431+432+export async function startPairingServer(options: {433+  token: string;434+  deviceId: string;435+  name: string;436+}): Promise<PairingServerInfo> {437+  const response = await requestFromHost<Extract<HostResponse, { type: "PAIRING_SERVER_STARTED" }>>(438+    {439+      type: "START_PAIRING_SERVER",440+      token: options.token,441+      device_id: options.deviceId,442+      name: options.name,443+    },444+    ["PAIRING_SERVER_STARTED"],445+    15_000,446+  );447+  return {448+    tcpPort: response.tcp_port,449+    wsPort: response.ws_port ?? undefined,450+  };451+}452+453+export async function stopPairingServer(): Promise<void> {454+  await requestFromHost({ type: "STOP_PAIRING_SERVER" }, ["STOP_PAIRING_SERVER_OK"]);455+}456+457+export async function resolvePeerByCode(458+  token: string,459+  timeoutMs = 8_000,460+): Promise<PeerInfo> {461+  const response = await requestFromHost<Extract<HostResponse, { type: "PEER_RESOLVED" }>>(462+    { type: "RESOLVE_PEER_BY_CODE", token, timeout_ms: timeoutMs },463+    ["PEER_RESOLVED"],464+    timeoutMs + 2_000,465+  );466+  return mapPeer(response.peer);419467 }420468 421469 /**

view file

src/shared/types.ts+4 −0
@@ -253,6 +253,10 @@253253   | "START_PEER_DISCOVERY"254254   | "STOP_PEER_DISCOVERY"255255   | "INITIATE_PAIR_WITH_PEER"256+  | "START_PAIRING_SERVER"257+  | "STOP_PAIRING_SERVER"258+  | "CONNECT_WITH_CODE"259+  | "GET_RECENT_PAIRING"256260   | "GET_PENDING_PASSKEY"257261   | "APPROVE_PASSKEY"258262   | "DENY_PASSKEY"

view file