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
↓ peach-browser-93e4146a.tar.gzatlas at this save
files changed7 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 −1manifest.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": {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",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": {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+}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 });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 }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) {