93e4146ae02e

add initiator role to chirp orchestrator — runChirpPairing(deps, role); new openPairingServer dep; Capacitor adapter src/popup/chirp-pair-capacitor.ts bridges native SyncPlugin events to PairingTransport; ChirpSync auto-picks role via isNativePlatform; tests cover both role-mismatch and missing-dep paths (v3.4.0)

time
2026-05-18 13:43 UTC · 5 months ago
machine
koh2
parent
7eb90f1f2155
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/chirp-pair-capacitor.ts+134 −0src/popup/chirp-pair.test.ts+36 −3src/popup/chirp-pair.ts+105 −13src/popup/views/ChirpSync.tsx+7 −1
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach",5-  "version": "3.3.7",5+  "version": "3.4.0",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "3.3.7",4+  "version": "3.4.0",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": "3.3.7",3+  "version": "3.4.0",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/chirp-pair-capacitor.ts+134 −0
@@ -0,0 +1,134 @@1+// Capacitor adapter for the initiator-role pairing server.2+//3+// The native SyncPlugin on Android (and eventually iOS) owns the4+// WebSocket server on CHIRP_PAIRING_PORT. JS does not have raw socket5+// listeners. This adapter wraps the plugin's start/stop calls and its6+// event stream into the PairingTransport shape the orchestrator expects.7+//8+// Protocol with native:9+//   - startPairingServer({port}) opens the server and returns immediately10+//   - The plugin emits 'peach:pair-connected' when the first peer connects:11+//       { connectionId: string, peerIp: string }12+//   - The plugin emits 'peach:pair-frame' for each inbound binary frame:13+//       { connectionId: string, frameB64: string }14+//   - The plugin emits 'peach:pair-closed' when the connection drops:15+//       { connectionId: string }16+//   - sendFrame({connectionId, frameB64}) sends a binary frame out17+//   - stopPairingServer() closes the server and any open connection18+//19+// The 90 s accept timeout matches the chirp window. If no peer connects20+// in that window the adapter rejects with a `pair_timeout` error so the21+// orchestrator surfaces a `handshake_failed` phase.22+23+import type { PairingTransport } from "./chirp-pair";24+import { CHIRP_PAIRING_PORT } from "./chirp-pair";25+26+const ACCEPT_TIMEOUT_MS = 90_000;27+28+interface SyncPluginShape {29+  startPairingServer(opts: { port: number }): Promise<void>;30+  stopPairingServer(): Promise<void>;31+  sendFrame(opts: { connectionId: string; frameB64: string }): Promise<void>;32+  addListener(event: string, handler: (data: unknown) => void): { remove: () => void };33+}34+35+function getSyncPlugin(): SyncPluginShape | null {36+  const cap = (globalThis as { Capacitor?: { Plugins?: Record<string, unknown> } }).Capacitor;37+  const plugin = cap?.Plugins?.SyncPlugin as SyncPluginShape | undefined;38+  return plugin ?? null;39+}40+41+export async function openCapacitorPairingServer(): Promise<{42+  transport: PairingTransport;43+  peerIp: string;44+}> {45+  const plugin = getSyncPlugin();46+  if (!plugin) throw new Error("SyncPlugin unavailable — not running on Capacitor");47+48+  await plugin.startPairingServer({ port: CHIRP_PAIRING_PORT });49+50+  let connectionId: string | null = null;51+  const inbox: Uint8Array[] = [];52+  const waiters: Array<(b: Uint8Array | null) => void> = [];53+  let closed = false;54+55+  const onConnected = plugin.addListener("peach:pair-connected", (data) => {56+    const d = data as { connectionId: string; peerIp: string };57+    if (connectionId !== null) return; // ignore extra connections — first wins58+    connectionId = d.connectionId;59+    acceptResolve?.({ connectionId: d.connectionId, peerIp: d.peerIp });60+  });61+62+  const onFrame = plugin.addListener("peach:pair-frame", (data) => {63+    const d = data as { connectionId: string; frameB64: string };64+    if (d.connectionId !== connectionId) return;65+    const bytes = base64ToBytes(d.frameB64);66+    const w = waiters.shift();67+    if (w) w(bytes); else inbox.push(bytes);68+  });69+70+  const onClosed = plugin.addListener("peach:pair-closed", (data) => {71+    const d = data as { connectionId: string };72+    if (d.connectionId !== connectionId) return;73+    closed = true;74+    while (waiters.length > 0) waiters.shift()!(null);75+  });76+77+  let acceptResolve: ((v: { connectionId: string; peerIp: string }) => void) | null = null;78+  let acceptReject: ((e: Error) => void) | null = null;79+  const acceptPromise = new Promise<{ connectionId: string; peerIp: string }>((res, rej) => {80+    acceptResolve = res;81+    acceptReject = rej;82+  });83+  const timer = setTimeout(() => acceptReject?.(new Error("pair_timeout")), ACCEPT_TIMEOUT_MS);84+85+  let accepted: { connectionId: string; peerIp: string };86+  try {87+    accepted = await acceptPromise;88+  } catch (err) {89+    clearTimeout(timer);90+    onConnected.remove(); onFrame.remove(); onClosed.remove();91+    await plugin.stopPairingServer().catch(() => undefined);92+    throw err;93+  }94+  clearTimeout(timer);95+96+  const transport: PairingTransport = {97+    async send(msg: Uint8Array) {98+      await plugin.sendFrame({99+        connectionId: accepted.connectionId,100+        frameB64: bytesToBase64(msg),101+      });102+    },103+    async receive(): Promise<Uint8Array> {104+      if (closed) throw new Error("pair_transport_closed");105+      const v = inbox.shift();106+      if (v) return v;107+      return new Promise<Uint8Array>((resolve, reject) => {108+        waiters.push((bytes) => {109+          if (bytes === null) reject(new Error("pair_transport_closed"));110+          else resolve(bytes);111+        });112+      });113+    },114+    async close() {115+      onConnected.remove(); onFrame.remove(); onClosed.remove();116+      await plugin.stopPairingServer().catch(() => undefined);117+    },118+  };119+120+  return { transport, peerIp: accepted.peerIp };121+}122+123+function base64ToBytes(b64: string): Uint8Array {124+  const bin = atob(b64);125+  const out = new Uint8Array(bin.length);126+  for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);127+  return out;128+}129+130+function bytesToBase64(bytes: Uint8Array): string {131+  let bin = "";132+  for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);133+  return btoa(bin);134+}

view file

src/popup/chirp-pair.test.ts+36 −3
@@ -135,7 +135,7 @@135135     expect(trail.at(-1)?.errorMessage).toBe("no_subnet");136136   });137137 138-  it("throws on a non-extension local identity (Capacitor host-mode misuse)", async () => {138+  it("rejects responder role with non-extension local identity", async () => {139139     await expect(140140       runChirpPairing({141141         async fetchLocalIdentity() {@@ -147,7 +147,40 @@147147         openAudio: fakeAudio(null),148148         async openPairingTransport() { throw new Error("unreached"); },149149         onProgress() { /* noop */ },150-      }),151-    ).rejects.toThrow(/wrong-platform/);150+      }, "responder"),151+    ).rejects.toThrow(/responder requires extension/);152+  });153+154+  it("rejects initiator role with extension local identity", async () => {155+    await expect(156+      runChirpPairing({157+        async fetchLocalIdentity() {158+          return { deviceId: "ext", deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "0".repeat(64) };159+        },160+        async completePairing() { /* unreached */ },161+        async getLocalSubnet() { return null; },162+        chirpTimeoutMs: 200,163+        openAudio: fakeAudio(null),164+        async openPairingTransport() { throw new Error("unreached"); },165+        async openPairingServer() { throw new Error("unreached"); },166+        onProgress() { /* noop */ },167+      }, "initiator"),168+    ).rejects.toThrow(/initiator requires non-extension/);169+  });170+171+  it("rejects initiator role when openPairingServer dep is missing", async () => {172+    await expect(173+      runChirpPairing({174+        async fetchLocalIdentity() {175+          return { deviceId: "android", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "0".repeat(64) };176+        },177+        async completePairing() { /* unreached */ },178+        async getLocalSubnet() { return null; },179+        chirpTimeoutMs: 200,180+        openAudio: fakeAudio(null),181+        async openPairingTransport() { throw new Error("unreached"); },182+        onProgress() { /* noop */ },183+      }, "initiator"),184+    ).rejects.toThrow(/openPairingServer/);152185   });153186 });

view file

src/popup/chirp-pair.ts+105 −13
@@ -25,11 +25,14 @@2525   runChirpExchange,2626 } from "../shared/chirp-pairing";2727 import {28+  runPairingInitiator,2829   runPairingResponder,2930   type LocalIdentity,3031 } from "../shared/chirp-handshake";3132 import {3233   DEVICE_TYPE_EXTENSION,34+  DEVICE_TYPE_ANDROID,35+  DEVICE_TYPE_IOS,3336   type PairedDevice,3437   type PairingProgress,3538 } from "../shared/sync-types";@@ -43,6 +46,12 @@4346  */4447 export const CHIRP_PAIRING_PORT = 49142;4548 49+export interface PairingTransport {50+  send(msg: Uint8Array): Promise<void>;51+  receive(): Promise<Uint8Array>;52+  close(): Promise<void>;53+}54+4655 export interface ChirpPairingDeps {4756   /** Background round-trip — returns the local public identity. */4857   fetchLocalIdentity(): Promise<LocalIdentity>;@@ -50,13 +59,29 @@5059   completePairing(paired: PairedDevice): Promise<void>;5160   /** Optional — cache the peer's IP for next session's fast path. */5261   cacheLastKnownIp?(pubkeyHex: string, ip: string): Promise<void>;53-  /** Returns "192.168.1" for a /24; null if not detectable. */62+  /**63+   * RESPONDER ONLY — returns "192.168.1" for a /24; null if not detectable.64+   * Used to scan the LAN for the initiator's pairing-port WebSocket server.65+   */5466   getLocalSubnet(): Promise<string | null>;55-  /** Inject the WebSocket transport. Override in tests. */56-  openPairingTransport(endpoint: { ip: string; port: number }): Promise<{57-    send(msg: Uint8Array): Promise<void>;58-    receive(): Promise<Uint8Array>;59-    close(): Promise<void>;67+  /**68+   * RESPONDER ONLY — connect to the initiator's WebSocket server at the69+   * given endpoint. Used during the /24 scan and again for the real70+   * handshake transport.71+   */72+  openPairingTransport(endpoint: { ip: string; port: number }): Promise<PairingTransport>;73+  /**74+   * INITIATOR ONLY — open a WebSocket server on CHIRP_PAIRING_PORT and75+   * resolve with a transport when the responder connects. Backed by a76+   * native plugin on Capacitor; not used in the extension build.77+   *78+   * The promise resolves once a peer connection has been accepted. If no79+   * peer arrives before the chirp window expires, reject so the orchestrator80+   * can surface a `handshake_failed` phase. Close the server in `.close()`.81+   */82+  openPairingServer?(): Promise<{83+    transport: PairingTransport;84+    peerIp: string;6085   }>;6186   /** Override the chirp exchange's `AudioIO` for tests. */6287   openAudio?(): Promise<Awaited<ReturnType<typeof openBrowserAudioIO>>>;@@ -65,6 +90,8 @@6590   onProgress(p: PairingProgress): void;6691 }6792 93+export type PairingRole = "initiator" | "responder";94+6895 export class ChirpPairingFailure extends Error {6996   readonly phase: PairingProgress["phase"];7097   constructor(phase: PairingProgress["phase"], message: string) {@@ -73,22 +100,40 @@73100   }74101 }75102 76-export async function runChirpPairing(deps: ChirpPairingDeps): Promise<PairedDevice> {103+export async function runChirpPairing(104+  deps: ChirpPairingDeps,105+  role: PairingRole = "responder",106+): Promise<PairedDevice> {77107   deps.onProgress({ phase: "idle" });78108 79109   const localIdentity = await deps.fetchLocalIdentity();80-  if (localIdentity.deviceType !== "extension") {81-    // The Capacitor build runs the host-side flow; this orchestrator is82-    // for the extension responder side only.83-    throw new ChirpPairingFailure("handshake_failed", "wrong-platform orchestrator");110+111+  // Each role only runs on the matching platform — the extension is always112+  // responder (it scans the LAN), Android/iOS/desktop are always initiator113+  // (they open the WS server). Mismatches indicate a wiring bug upstream.114+  if (role === "responder" && localIdentity.deviceType !== "extension") {115+    throw new ChirpPairingFailure("handshake_failed", "responder requires extension identity");84116   }117+  if (role === "initiator" && localIdentity.deviceType === "extension") {118+    throw new ChirpPairingFailure("handshake_failed", "initiator requires non-extension identity");119+  }120+  if (role === "initiator" && !deps.openPairingServer) {121+    throw new ChirpPairingFailure("handshake_failed", "initiator requires openPairingServer dep");122+  }85123 86124   deps.onProgress({ phase: "listening" });87125 88-  const attempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);126+  const deviceTypeByte =127+    localIdentity.deviceType === "android" ? DEVICE_TYPE_ANDROID :128+    localIdentity.deviceType === "ios" ? DEVICE_TYPE_IOS :129+    DEVICE_TYPE_EXTENSION;130+  const attempt = createPairingAttempt(deviceTypeByte);131+132+  let remoteChirpPublicKey: Uint8Array;89133   try {90134     const audio = await (deps.openAudio ?? openBrowserAudioIO)();91-    await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });135+    const result = await runChirpExchange(audio, { attempt, timeoutMs: deps.chirpTimeoutMs });136+    remoteChirpPublicKey = result.remoteChirp.ephemeralPublicKey;92137   } catch (err) {93138     if (err instanceof ChirpExchangeError) {94139       const phase: PairingProgress["phase"] =@@ -104,6 +149,17 @@104149 105150   deps.onProgress({ phase: "handshake" });106151 152+  if (role === "responder") {153+    return runResponderHandshake(deps, attempt, localIdentity);154+  }155+  return runInitiatorHandshake(deps, remoteChirpPublicKey, localIdentity);156+}157+158+async function runResponderHandshake(159+  deps: ChirpPairingDeps,160+  attempt: ReturnType<typeof createPairingAttempt>,161+  localIdentity: LocalIdentity,162+): Promise<PairedDevice> {107163   // The peer is somewhere on the LAN; we don't know its IP yet because108164   // the chirp payload deliberately omits it. Scan the /24 for the109165   // pairing-mode WebSocket port.@@ -141,6 +197,42 @@141197   // IP is per-network and must not roam over `chrome.storage.sync`.142198   await deps.completePairing(paired);143199   await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", peerEndpoint.ip);200+  deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });201+  return paired;202+}203+204+async function runInitiatorHandshake(205+  deps: ChirpPairingDeps,206+  remoteChirpPublicKey: Uint8Array,207+  localIdentity: LocalIdentity,208+): Promise<PairedDevice> {209+  // Initiator opened a WebSocket server on CHIRP_PAIRING_PORT before the210+  // chirp exchange started; here we wait for the responder (extension)211+  // to finish its /24 scan and connect to us.212+  let server: { transport: PairingTransport; peerIp: string };213+  try {214+    server = await deps.openPairingServer!();215+  } catch (err) {216+    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });217+    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);218+  }219+220+  let paired: PairedDevice;221+  try {222+    paired = await runPairingInitiator(223+      remoteChirpPublicKey,224+      localIdentity,225+      server.transport,226+    );227+  } catch (err) {228+    await server.transport.close();229+    deps.onProgress({ phase: "handshake_failed", errorMessage: (err as Error).message });230+    throw new ChirpPairingFailure("handshake_failed", (err as Error).message);231+  }232+  await server.transport.close();233+234+  await deps.completePairing(paired);235+  await deps.cacheLastKnownIp?.(paired.longTermPublicKeyHex ?? "", server.peerIp);144236   deps.onProgress({ phase: "paired", peerDeviceName: paired.deviceName });145237   return paired;146238 }

view file

src/popup/views/ChirpSync.tsx+7 −1
@@ -34,8 +34,11 @@3434   ChirpPairingFailure,3535   runChirpPairing,3636   type ChirpPairingDeps,37+  type PairingRole,3738 } from "../chirp-pair";3839 import { getLocalSubnet } from "../../shared/local-network";40+import { isNativePlatform } from "../../shared/native";41+import { openCapacitorPairingServer } from "../chirp-pair-capacitor";3942 import { PeachMark } from "../components/PeachMark";4043 4144 export interface ChirpSyncViewProps {@@ -144,8 +147,11 @@144147       },145148     };146149 150+    const role: PairingRole = isNativePlatform() ? "initiator" : "responder";151+    if (role === "initiator") deps.openPairingServer = openCapacitorPairingServer;152+147153     try {148-      await runChirpPairing(deps);154+      await runChirpPairing(deps, role);149155       await refresh();150156     } catch (err) {151157       if (err instanceof ChirpPairingFailure) {

view file