90d6ab2a5e88

feat: Discover-and-tap pairing flow via mDNS (v2.13.30)

time
2026-04-30 15:17 UTC · 6 months ago
machine
koh2
parent
51b129be8db6
9 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+28 −0src/background/messages.ts+104 −0src/popup/styles/popup.css+44 −0src/popup/views/SyncPairing.tsx+98 −129src/shared/sync-types.ts+16 −0src/shared/types.ts+3 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.29",4+  "version": "2.13.30",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.13.29",4+  "version": "2.13.30",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "2.13.29",3+  "version": "2.13.30",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.test.ts+28 −0
@@ -697,6 +697,34 @@697697     expect(hits[0]!.useCount).toBe(3);698698   });699699 700+  it("START_PEER_DISCOVERY is Pro-gated and rejects free users", async () => {701+    const res = await dispatch({ type: "START_PEER_DISCOVERY" });702+    expect(res.ok).toBe(false);703+    expect((res as { error?: string }).error).toBe("pro_required");704+  });705+706+  it("INITIATE_PAIR_WITH_PEER is Pro-gated and rejects free users", async () => {707+    const res = await dispatch({708+      type: "INITIATE_PAIR_WITH_PEER",709+      payload: {710+        peer: {711+          deviceId: "p1",712+          deviceName: "iPhone",713+          host: "192.168.1.10",714+          port: 44100,715+          x25519PublicKey: "abc",716+        },717+      },718+    });719+    expect(res.ok).toBe(false);720+    expect((res as { error?: string }).error).toBe("pro_required");721+  });722+723+  it("STOP_PEER_DISCOVERY succeeds even when discovery never started", async () => {724+    const res = await dispatch({ type: "STOP_PEER_DISCOVERY" });725+    expect(res.ok).toBe(true);726+  });727+700728   it("DEBUG_INJECT_INCOMING_PAIRING + GET returns the request and 60s expiry window", async () => {701729     const inject = await dispatch({702730       type: "DEBUG_INJECT_INCOMING_PAIRING",

view file

src/background/messages.ts+104 −0
@@ -82,6 +82,67 @@8282   return { allowed: false };8383 }8484 85+// ---------------------------------------------------------------------------86+// Peer discovery glue (Phase 7 popup pairing flow).87+//88+// The popup opens discovery via START_PEER_DISCOVERY. We connect to the89+// native sync host, subscribe once, and re-broadcast every PEER_DISCOVERED /90+// PEER_LOST event to the popup as a SYNC_PEER_DISCOVERED / SYNC_PEER_LOST91+// runtime message. The popup is the only consumer; if it closes, STOP is92+// sent on cleanup which tears down subscriptions and the native discovery.93+// ---------------------------------------------------------------------------94+95+let peerDiscoverySubscriptions: Array<() => void> = [];96+97+async function startPeerDiscoveryForPopup(): Promise<void> {98+  await stopPeerDiscoveryForPopup();99+  const sync = await import('./sync');100+  const { getPairedDevices } = await import('./sync-storage');101+  sync.connectSyncHost();102+103+  const unsubDiscovered = sync.onPeerDiscovered((peer) => {104+    void (async () => {105+      const paired = await getPairedDevices();106+      const alreadyPaired = paired.some((d) => d.deviceId === peer.deviceId);107+      if (Capacitor.isNativePlatform()) return;108+      chrome.runtime109+        .sendMessage({110+          type: "SYNC_PEER_DISCOVERED",111+          peer: {112+            deviceId: peer.deviceId,113+            deviceName: peer.name,114+            host: peer.host,115+            port: peer.port,116+            x25519PublicKey: peer.x25519PublicKey,117+            alreadyPaired,118+          },119+        })120+        .catch(() => {});121+    })();122+  });123+  const unsubLost = sync.onPeerLost((deviceId) => {124+    if (Capacitor.isNativePlatform()) return;125+    chrome.runtime126+      .sendMessage({ type: "SYNC_PEER_LOST", deviceId })127+      .catch(() => {});128+  });129+  peerDiscoverySubscriptions = [unsubDiscovered, unsubLost];130+  sync.discoverPeers();131+}132+133+async function stopPeerDiscoveryForPopup(): Promise<void> {134+  for (const unsub of peerDiscoverySubscriptions) unsub();135+  peerDiscoverySubscriptions = [];136+  try {137+    const sync = await import('./sync');138+    if (sync.isHostConnected()) {139+      await sync.stopDiscovery();140+    }141+  } catch {142+    // best-effort143+  }144+}145+85146 const PRIVATE_IPV4_RANGES = [86147   { prefix: "10." },87148   { prefix: "127." },@@ -642,6 +703,49 @@642703           }643704         }644705         return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };706+      }707+      case "START_PEER_DISCOVERY": {708+        const gate = await requirePro("sync");709+        if (!gate.allowed) return { ok: false, error: "pro_required" };710+        try {711+          await startPeerDiscoveryForPopup();712+          return { ok: true };713+        } catch (err) {714+          return { ok: false, error: err instanceof Error ? err.message : "discovery_failed" };715+        }716+      }717+      case "STOP_PEER_DISCOVERY": {718+        try {719+          await stopPeerDiscoveryForPopup();720+        } catch {721+          // best-effort cleanup722+        }723+        return { ok: true };724+      }725+      case "INITIATE_PAIR_WITH_PEER": {726+        const gate = await requirePro("sync");727+        if (!gate.allowed) return { ok: false, error: "pro_required" };728+        const { peer } = msg.payload as {729+          peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };730+        };731+        try {732+          const sync = await import('./sync');733+          const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);734+          // Connection success means the peer accepted the dormant Noise NK735+          // pairing handshake. Trust persistence happens once the user-side736+          // approval banner on the *peer* device confirms — we record local737+          // state here so Settings reflects the new device immediately.738+          const { addPairedDevice } = await import('./sync-storage');739+          await addPairedDevice({740+            deviceId: peer.deviceId,741+            displayName: peer.deviceName,742+            x25519PublicKey: peer.x25519PublicKey,743+            pairedAt: Date.now(),744+          });745+          return { ok: true, data: { connectionId, deviceName: peer.deviceName } };746+        } catch (err) {747+          return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };748+        }645749       }646750       case "DEBUG_INJECT_INCOMING_PAIRING": {647751         // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing

view file

src/popup/styles/popup.css+44 −0
@@ -4792,6 +4792,50 @@47924792   text-align: center;47934793 }47944794 4795+.peach-pairing-discover {4796+  justify-content: flex-start;4797+  align-items: stretch;4798+  text-align: left;4799+  gap: 12px;4800+}4801+4802+.peach-pairing-discover .peach-pairing-headline {4803+  text-align: center;4804+  margin-bottom: 4px;4805+}4806+4807+.peach-pairing-peer-card {4808+  display: flex;4809+  align-items: center;4810+  justify-content: space-between;4811+  gap: 12px;4812+  padding: 10px 12px;4813+  border: 1px solid var(--peach-t3);4814+  border-radius: 10px;4815+  background: var(--peach-bg-card);4816+}4817+4818+.peach-pairing-peer-info {4819+  display: flex;4820+  flex-direction: column;4821+  gap: 2px;4822+  flex: 1;4823+  min-width: 0;4824+}4825+4826+.peach-pairing-peer-name {4827+  font-size: 13px;4828+  color: var(--peach-t0);4829+  white-space: nowrap;4830+  overflow: hidden;4831+  text-overflow: ellipsis;4832+}4833+4834+.peach-pairing-peer-meta {4835+  font-size: 11px;4836+  color: var(--peach-t2);4837+}4838+47954839 .peach-pairing-headline {47964840   font-family: var(--font-display);47974841   font-style: italic;

view file

src/popup/views/SyncPairing.tsx+98 −129
@@ -1,6 +1,6 @@1-import { createSignal, onCleanup, Show } from "solid-js";1+import { createSignal, For, onCleanup, onMount, Show } from "solid-js";22 import { sendMessage } from "../App";3-import type { PairedDevice, PairingStep } from "../../shared/sync-types";3+import type { DiscoveredPeer, PairedDevice, PairingStep } from "../../shared/sync-types";44 55 interface SyncPairingProps {66   mode: "initiate" | "accept";@@ -8,49 +8,6 @@88   onPaired: (device: PairedDevice) => void;99 }1010 11-function generatePairingCode(): string {12-  const array = new Uint32Array(1);13-  crypto.getRandomValues(array);14-  return String(array[0] % 1000000).padStart(6, "0");15-}16-17-function CodeDisplay(props: { code: string }) {18-  return (19-    <div class="peach-pairing-code-display">20-      {props.code.split("").map((digit) => (21-        <span class="peach-pairing-code-digit">{digit}</span>22-      ))}23-    </div>24-  );25-}26-27-function CountdownRing(props: { secondsLeft: number }) {28-  const radius = 22;29-  const circumference = 2 * Math.PI * radius;30-  const progress = () => (props.secondsLeft / 30) * circumference;31-  return (32-    <div class="peach-pairing-countdown-ring">33-      <svg width="56" height="56" viewBox="0 0 56 56">34-        <circle35-          cx="28"36-          cy="28"37-          r={radius}38-          class="peach-pairing-ring-track"39-        />40-        <circle41-          cx="28"42-          cy="28"43-          r={radius}44-          class="peach-pairing-ring-progress"45-          stroke-dasharray={String(circumference)}46-          stroke-dashoffset={String(circumference - progress())}47-        />48-      </svg>49-      <span class="peach-pairing-countdown-num">{props.secondsLeft}</span>50-    </div>51-  );52-}53-5411 function Spinner() {5512   return <span class="peach-pairing-spinner" />;5613 }@@ -58,60 +15,77 @@5815 export function SyncPairingView(props: SyncPairingProps) {5916   const [step, setStep] = createSignal<PairingStep>({ stage: "idle" });6017   const [codeInput, setCodeInput] = createSignal("");61-  const [secondsLeft, setSecondsLeft] = createSignal(30);6218   const [codeError, setCodeError] = createSignal<string | null>(null);6319 64-  let countdownTimer: number | undefined;65-  let pairingTimer: number | undefined;20+  // Discover-and-tap state for the initiate flow. The background broadcasts21+  // SYNC_PEER_DISCOVERED / SYNC_PEER_LOST as peers come and go on mDNS;22+  // tapping a peer routes to INITIATE_PAIR_WITH_PEER.23+  const [discoveredPeers, setDiscoveredPeers] = createSignal<DiscoveredPeer[]>([]);24+  const [discoveryActive, setDiscoveryActive] = createSignal(false);25+  const [discoveryError, setDiscoveryError] = createSignal<string | null>(null);26+  const [pairingPeerId, setPairingPeerId] = createSignal<string | null>(null);6627 28+  onMount(() => {29+    if (props.mode !== "initiate") return;30+    void startDiscovery();31+  });32+6733   onCleanup(() => {68-    if (countdownTimer !== undefined) window.clearInterval(countdownTimer);69-    if (pairingTimer !== undefined) window.clearTimeout(pairingTimer);34+    if (discoveryActive()) void sendMessage({ type: "STOP_PEER_DISCOVERY" });7035   });7136 72-  function startCountdown(expiresAt: number) {73-    if (countdownTimer !== undefined) window.clearInterval(countdownTimer);74-    setSecondsLeft(Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)));75-    countdownTimer = window.setInterval(() => {76-      const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000));77-      setSecondsLeft(remaining);78-      if (remaining === 0) {79-        window.clearInterval(countdownTimer);80-        setStep({ stage: "idle" });81-        void sendMessage({ type: "CANCEL_PAIRING" });82-      }83-    }, 500);37+  function handleRuntimeMessage(msg: unknown) {38+    const m = msg as { type?: string; peer?: DiscoveredPeer; deviceId?: string };39+    if (m?.type === "SYNC_PEER_DISCOVERED" && m.peer) {40+      const peer = m.peer;41+      setDiscoveredPeers((prev) => {42+        if (prev.some((p) => p.deviceId === peer.deviceId)) return prev;43+        return [...prev, peer];44+      });45+    }46+    if (m?.type === "SYNC_PEER_LOST" && m.deviceId) {47+      const id = m.deviceId;48+      setDiscoveredPeers((prev) => prev.filter((p) => p.deviceId !== id));49+    }8450   }8551 86-  async function handleGenerateCode() {87-    const code = generatePairingCode();88-    const expiresAt = Date.now() + 30_000;89-    setStep({ stage: "generating_code", code, expiresAt });90-    startCountdown(expiresAt);52+  async function startDiscovery() {53+    chrome.runtime.onMessage.addListener(handleRuntimeMessage);54+    onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));9155 56+    const res = await sendMessage({ type: "START_PEER_DISCOVERY" });57+    const response = res as { ok: boolean; error?: string };58+    if (response.ok) {59+      setDiscoveryActive(true);60+      setDiscoveryError(null);61+    } else {62+      setDiscoveryError(response.error ?? "discovery_failed");63+    }64+  }65+66+  async function handlePairWithPeer(peer: DiscoveredPeer) {67+    setPairingPeerId(peer.deviceId);68+    setStep({ stage: "connecting", peerName: peer.deviceName });9269     const res = await sendMessage({93-      type: "START_PAIRING",94-      payload: { code, expiresAt },70+      type: "INITIATE_PAIR_WITH_PEER",71+      payload: { peer },9572     });96-    if (!(res as { ok: boolean }).ok) {97-      setStep({ stage: "error", message: "Failed to start pairing" });73+    const response = res as { ok: boolean; error?: string };74+    setPairingPeerId(null);75+    if (!response.ok) {76+      setStep({ stage: "error", message: response.error ?? "pair_failed" });9877       return;9978     }100-    setStep({ stage: "waiting_for_peer" });101-102-    pairingTimer = window.setTimeout(() => {103-      setStep({ stage: "idle" });104-      void sendMessage({ type: "CANCEL_PAIRING" });105-    }, 30_000);79+    const paired: PairedDevice = {80+      deviceId: peer.deviceId,81+      displayName: peer.deviceName,82+      x25519PublicKey: peer.x25519PublicKey,83+      pairedAt: Date.now(),84+    };85+    setStep({ stage: "done", device: paired });86+    props.onPaired(paired);10687   }10788 108-  async function handleCancel() {109-    if (countdownTimer !== undefined) window.clearInterval(countdownTimer);110-    if (pairingTimer !== undefined) window.clearTimeout(pairingTimer);111-    setStep({ stage: "idle" });112-    await sendMessage({ type: "CANCEL_PAIRING" });113-  }114-11589   async function handleConnect() {11690     const code = codeInput().replace(/\D/g, "").slice(0, 6);11791     if (code.length !== 6) {@@ -163,52 +137,47 @@163137       </div>164138 165139       <div class="peach-pairing-body">166-        <Show when={props.mode === "initiate"}>167-          <Show when={step().stage === "idle"}>168-            <div class="peach-pairing-content">169-              <div class="peach-pairing-hero">170-                <svg width="40" height="40" viewBox="0 0 40 40" fill="none" class="peach-pairing-icon">171-                  <circle cx="20" cy="20" r="18" stroke="var(--peach-accent)" stroke-width="1.5" stroke-dasharray="4 3" />172-                  <path d="M14 20h12M20 14v12" stroke="var(--peach-accent)" stroke-width="1.5" stroke-linecap="round" />173-                </svg>174-              </div>175-              <p class="peach-pairing-headline">Pair a new device</p>176-              <p class="peach-pairing-subline">177-                Generate a code and wait for another device to connect.140+        <Show when={props.mode === "initiate" && step().stage !== "done" && step().stage !== "error" && step().stage !== "connecting" && step().stage !== "verifying"}>141+          <div class="peach-pairing-content peach-pairing-discover">142+            <p class="peach-pairing-headline">Pair a device</p>143+            <Show when={discoveryError()}>144+              <p class="peach-pairing-error">145+                {discoveryError() === "pro_required"146+                  ? "Sync requires Peach Pro."147+                  : "Native sync host is not installed yet."}178148               </p>179-              <button180-                class="peach-btn peach-btn--accent peach-btn--md peach-lock-submit"181-                onClick={() => void handleGenerateCode()}182-              >183-                Generate pairing code184-              </button>185-            </div>186-          </Show>187-188-              <Show when={step().stage === "generating_code" || step().stage === "waiting_for_peer"}>189-            <div class="peach-pairing-content">190-              <Show when={step().stage === "generating_code"}>191-                <CodeDisplay code={(step() as { stage: "generating_code"; code: string }).code} />192-                <CountdownRing secondsLeft={secondsLeft()} />193-              </Show>194-              <Show when={step().stage === "waiting_for_peer"}>195-                <CodeDisplay code="------" />196-                <p class="peach-pairing-status">197-                  <Spinner />198-                  Waiting for peer...199-                </p>200-                <p class="peach-pairing-subline peach-pairing-subline--center">201-                  Make sure the other device is searching for peers.202-                </p>203-              </Show>204-              <button205-                class="peach-btn peach-btn--ghost peach-btn--md"206-                onClick={() => void handleCancel()}207-              >208-                Cancel209-              </button>210-            </div>211-          </Show>149+            </Show>150+            <Show when={!discoveryError() && discoveredPeers().length === 0}>151+              <p class="peach-pairing-status">152+                <Spinner />153+                Searching for Peach on your local network...154+              </p>155+            </Show>156+            <For each={discoveredPeers()}>157+              {(peer) => (158+                <div class="peach-pairing-peer-card">159+                  <div class="peach-pairing-peer-info">160+                    <span class="peach-pairing-peer-name">{peer.deviceName}</span>161+                    <span class="peach-pairing-peer-meta">162+                      {peer.alreadyPaired ? "Already paired ✓" : "Found on network"}163+                    </span>164+                  </div>165+                  <Show when={!peer.alreadyPaired}>166+                    <button167+                      class="peach-btn peach-btn--accent peach-btn--sm"168+                      onClick={() => void handlePairWithPeer(peer)}169+                      disabled={pairingPeerId() !== null}170+                    >171+                      {pairingPeerId() === peer.deviceId ? "..." : "Pair →"}172+                    </button>173+                  </Show>174+                </div>175+              )}176+            </For>177+            <p class="peach-pairing-subline peach-pairing-subline--center">178+              Make sure both devices are on the same Wi-Fi network.179+            </p>180+          </div>212181         </Show>213182 214183         <Show when={props.mode === "accept"}>

view file

src/shared/sync-types.ts+16 −0
@@ -86,3 +86,19 @@8686   receivedAt: number;8787   expiresAt: number;8888 }89+90+/**91+ * A peer the native sync host has seen on mDNS. The popup pairing flow lists92+ * these and routes a tap to `INITIATE_PAIR_WITH_PEER` with the same shape.93+ *94+ * `alreadyPaired` is computed against `getPairedDevices()` at broadcast time95+ * so the popup can render "Already paired ✓" without a second round-trip.96+ */97+export interface DiscoveredPeer {98+  deviceId: string;99+  deviceName: string;100+  host: string;101+  port: number;102+  x25519PublicKey: string;103+  alreadyPaired: boolean;104+}

view file

src/shared/types.ts+3 −0
@@ -250,6 +250,9 @@250250   | "GET_INCOMING_PAIRING_REQUEST"251251   | "RESPOND_INCOMING_PAIRING"252252   | "DEBUG_INJECT_INCOMING_PAIRING"253+  | "START_PEER_DISCOVERY"254+  | "STOP_PEER_DISCOVERY"255+  | "INITIATE_PAIR_WITH_PEER"253256   | "GET_PENDING_PASSKEY"254257   | "APPROVE_PASSKEY"255258   | "DENY_PASSKEY"

view file