ccf8a297e1d5

pairing protocol — native pairing_mode flag + AuthOutcome split + extension wire PAIR_REQUEST/ACCEPT/REJECT (v2.14.2 / native v0.1.13)

time
2026-04-30 20:35 UTC · 6 months ago
machine
koh2
parent
e4d716993d01
7 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.ts+121 −23src/background/sync-coordinator.ts+64 −0src/background/sync.ts+49 −8src/shared/sync-types.ts+7 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.14.1",4+  "version": "2.14.2",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.14.1",4+  "version": "2.14.2",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.14.1",3+  "version": "2.14.2",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.ts+121 −23
@@ -83,6 +83,25 @@8383 }8484 8585 // ---------------------------------------------------------------------------86+// Local device name. Peers see this string in PAIR_REQUEST / PAIR_ACCEPT and87+// next to the device in their paired-devices list. Default to a platform-aware88+// fallback when the user hasn't set one explicitly.89+const DEVICE_NAME_KEY = "peach_device_name";90+91+async function getLocalDeviceName(): Promise<string> {92+  try {93+    const stored = await chrome.storage.local.get(DEVICE_NAME_KEY);94+    const value = (stored as Record<string, string | undefined>)[DEVICE_NAME_KEY];95+    if (typeof value === "string" && value.trim()) return value.trim();96+  } catch {97+    // fall through to default98+  }99+  if (Capacitor.getPlatform() === "android") return "Peach Android";100+  if (Capacitor.getPlatform() === "ios") return "Peach iOS";101+  return "Peach";102+}103+104+// ---------------------------------------------------------------------------86105 // Peer discovery glue (Phase 7 popup pairing flow).87106 //88107 // The popup opens discovery via START_PEER_DISCOVERY. We connect to the@@ -688,22 +707,64 @@688707       }689708       case "RESPOND_INCOMING_PAIRING": {690709         const { accept } = msg.payload as { accept: boolean };691-        const { getIncomingPairingRequest, clearIncomingPairingRequest } = await import('./sync-storage');710+        const { getIncomingPairingRequest, clearIncomingPairingRequest, addPairedDevice } =711+          await import('./sync-storage');692712         const request = await getIncomingPairingRequest();693713         if (!request) return { ok: false, error: "no_pending_request" };714+        if (Date.now() > request.expiresAt) {715+          await clearIncomingPairingRequest();716+          return { ok: false, error: "expired" };717+        }718+719+        const sync = await import('./sync');720+        const { encodeSyncMessage } = await import('../shared/sync-wire');721+722+        // Always clear the pending request — UI must not show the banner twice723+        // even if the wire-level send below fails.694724         await clearIncomingPairingRequest();695-        if (accept) {696-          // Trust-store wiring lives in src/background/sync.ts (`trustDevice`).697-          // It is dormant until the native messaging host installer (Phase 9)698-          // ships and runtime trigger paths come online; the user-side accept699-          // here clears the banner and records intent for Phase 10 E2E.725+726+        if (request.connectionId) {700727           try {701-            const { trustDevice } = await import('./sync');702-            await trustDevice(request.publicKeyHex, request.deviceName);728+            if (accept) {729+              const ourKey = await sync.getIdentityPublicKey();730+              const ourName = await getLocalDeviceName();731+              sync.sendSyncData(732+                request.connectionId,733+                encodeSyncMessage({734+                  type: "PAIR_ACCEPT",735+                  public_key_hex: ourKey,736+                  device_name: ourName,737+                }),738+              );739+              await sync.trustDevice(request.publicKeyHex, request.deviceName);740+              await addPairedDevice({741+                deviceId: request.publicKeyHex,742+                displayName: request.deviceName,743+                x25519PublicKey: request.publicKeyHex,744+                pairedAt: Date.now(),745+                lastSeenAt: Date.now(),746+              });747+            } else {748+              sync.sendSyncData(749+                request.connectionId,750+                encodeSyncMessage({ type: "PAIR_REJECT", reason: "user_declined" }),751+              );752+            }753+          } catch (err) {754+            // Wire failure is non-fatal for the local trust update — the755+            // initiator will time out cleanly on its end.756+            void err;757+          }758+          try { sync.disconnectFromPeer(request.connectionId); } catch { /* best-effort */ }759+        } else if (accept) {760+          // Test-injected request without a real connection: still record local trust.761+          try {762+            await sync.trustDevice(request.publicKeyHex, request.deviceName);703763           } catch {704-            // Native host not installed yet — accept still succeeds locally.764+            // Native host may not be installed in test contexts.705765           }706766         }767+707768         return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };708769       }709770       case "START_PEER_DISCOVERY": {@@ -730,22 +791,59 @@730791         const { peer } = msg.payload as {731792           peer: { deviceId: string; deviceName: string; host: string; port: number; x25519PublicKey: string };732793         };794+795+        const sync = await import('./sync');796+        const { encodeSyncMessage } = await import('../shared/sync-wire');797+        const { waitForPairingResponse } = await import('./sync-coordinator');798+        const { addPairedDevice } = await import('./sync-storage');799+800+        let connectionId: string | undefined;733801         try {734-          const sync = await import('./sync');735-          const connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);736-          // Connection success means the peer accepted the dormant Noise NK737-          // pairing handshake. Trust persistence happens once the user-side738-          // approval banner on the *peer* device confirms — we record local739-          // state here so Settings reflects the new device immediately.740-          const { addPairedDevice } = await import('./sync-storage');741-          await addPairedDevice({742-            deviceId: peer.deviceId,743-            displayName: peer.deviceName,744-            x25519PublicKey: peer.x25519PublicKey,745-            pairedAt: Date.now(),746-          });747-          return { ok: true, data: { connectionId, deviceName: peer.deviceName } };802+          // The responder accepts this connection only because pairing mode is803+          // active on its side — Noise NK still authenticates the *transport*,804+          // and the responder verifies our auth proof, but our public key is805+          // not yet in their trust store. The PAIR_ACCEPT we receive is the806+          // user's confirmation on the peer device that we should be added.807+          connectionId = await sync.connectToPeer(peer.host, peer.port, peer.x25519PublicKey);808+809+          const ourPublicKeyHex = await sync.getIdentityPublicKey();810+          const deviceName = await getLocalDeviceName();811+812+          sync.sendSyncData(813+            connectionId,814+            encodeSyncMessage({815+              type: "PAIR_REQUEST",816+              public_key_hex: ourPublicKeyHex,817+              device_name: deviceName,818+            }),819+          );820+821+          const response = await waitForPairingResponse(connectionId, 30_000);822+823+          if (response.type === "PAIR_ACCEPT") {824+            await sync.trustDevice(response.public_key_hex, response.device_name);825+            await addPairedDevice({826+              deviceId: peer.deviceId,827+              displayName: response.device_name || peer.deviceName,828+              x25519PublicKey: response.public_key_hex,829+              pairedAt: Date.now(),830+              lastSeenAt: Date.now(),831+            });832+            sync.disconnectFromPeer(connectionId);833+            return { ok: true, data: { connectionId, deviceName: response.device_name } };834+          }835+836+          if (response.type === "PAIR_REJECT") {837+            sync.disconnectFromPeer(connectionId);838+            return { ok: false, error: "pair_rejected", data: { reason: response.reason } };839+          }840+841+          sync.disconnectFromPeer(connectionId);842+          return { ok: false, error: "unexpected_response" };748843         } catch (err) {844+          if (connectionId) {845+            try { sync.disconnectFromPeer(connectionId); } catch { /* best-effort */ }846+          }749847           return { ok: false, error: err instanceof Error ? err.message : "pair_failed" };750848         }751849       }

view file

src/background/sync-coordinator.ts+64 −0
@@ -268,6 +268,70 @@268268   };269269 }270270 271+/**272+ * Wait for the next sync frame on a specific connection or reject after273+ * `timeoutMs`. Used by the pairing flow to await a single PAIR_ACCEPT /274+ * PAIR_REJECT response. Resolves with the decoded SyncMessage; throws on275+ * timeout, decode failure, or transport disconnect.276+ */277+export async function waitForPairingResponse(278+  connectionId: string,279+  timeoutMs: number,280+  bindings?: NativeSyncTransportBindings,281+): Promise<SyncMessage> {282+  const ready = bindings ?? (await loadNativeSyncBindings());283+  return new Promise<SyncMessage>((resolve, reject) => {284+    let settled = false;285+    const cleanup: Array<() => void> = [];286+    const finish = (action: () => void) => {287+      if (settled) return;288+      settled = true;289+      for (const fn of cleanup) {290+        try {291+          fn();292+        } catch {293+          // unsubscribe must not throw294+        }295+      }296+      action();297+    };298+299+    const handle = setTimeout(300+      () => finish(() => reject(new Error("pairing_response_timeout"))),301+      timeoutMs,302+    );303+    cleanup.push(() => clearTimeout(handle));304+305+    cleanup.push(306+      ready.onSyncMessage((id, frame) => {307+        if (id !== connectionId) return;308+        try {309+          const msg = decodeSyncMessage(frame);310+          finish(() => resolve(msg));311+        } catch (err) {312+          finish(() => reject(err instanceof Error ? err : new Error(String(err))));313+        }314+      }),315+    );316+317+    cleanup.push(318+      ready.onDisconnected((id) => {319+        if (id === undefined || id === connectionId) {320+          finish(() => reject(new Error("pairing_connection_closed")));321+        }322+      }),323+    );324+325+    cleanup.push(326+      ready.onError((message, id) => {327+        if (id === undefined || id === connectionId) {328+          finish(() => reject(new Error(message || "pairing_transport_error")));329+        }330+      }),331+    );332+  });333+}334+271335 export async function createNativeSyncTransport(272336   connectionId: string,273337   bindings?: NativeSyncTransportBindings,

view file

src/background/sync.ts+49 −8
@@ -35,7 +35,9 @@3535   | { type: "DISCONNECT"; connection_id: string }3636   | { type: "SYNC_SEND"; connection_id: string; data: string }3737   | { type: "GET_SESSION_KEY"; connection_id: string }38-  | { type: "STATUS" };38+  | { type: "STATUS" }39+  | { type: "ENTER_PAIRING_MODE" }40+  | { type: "EXIT_PAIRING_MODE" };3941 4042 type HostPeer = {4143   host: string;@@ -66,7 +68,13 @@6668   | { type: "PEER_LOST"; device_id: string }6769   | { type: "STOP_DISCOVERY_OK" }6870   | { type: "CONNECTED"; connection_id: string }69-  | { type: "PEER_CONNECTED"; connection_id: string; peer_addr: string }71+  | {72+      type: "PEER_CONNECTED";73+      connection_id: string;74+      peer_addr: string;75+      is_pairing_only?: boolean;76+      peer_public_key_hex?: string;77+    }7078   | { type: "DISCONNECTED"; connection_id: string }7179   | { type: "SYNC_RECEIVED"; connection_id: string; data: string }7280   | { type: "SESSION_KEY"; connection_id: string; key: string }@@ -77,7 +85,9 @@7785       ws_port?: number | null;7886       connections: string[];7987     }80-  | { type: "ERROR"; connection_id?: string | null; message: string };88+  | { type: "ERROR"; connection_id?: string | null; message: string }89+  | { type: "PAIRING_MODE_ENTERED" }90+  | { type: "PAIRING_MODE_EXITED" };8191 8292 export interface PeerInfo {8393   host: string;@@ -122,7 +132,16 @@122132 const peerDiscoveredHandlers: Array<(peer: PeerInfo) => void> = [];123133 const peerLostHandlers: Array<(deviceId: string) => void> = [];124134 const connectedHandlers: Array<(connectionId: string) => void> = [];125-const incomingConnectionHandlers: Array<(connectionId: string, peerAddr: string) => void> = [];135+export interface IncomingConnectionInfo {136+  connectionId: string;137+  peerAddr: string;138+  /** True when admitted only because pairing mode was active. */139+  isPairingOnly: boolean;140+  /** Hex-encoded x25519 public key the initiator authenticated with. */141+  peerPublicKeyHex?: string;142+}143+144+const incomingConnectionHandlers: Array<(info: IncomingConnectionInfo) => void> = [];126145 const disconnectedHandlers: Array<(connectionId?: string) => void> = [];127146 const syncMessageHandlers: Array<(connectionId: string, data: string) => void> = [];128147 const errorHandlers: Array<(error: string, connectionId?: string) => void> = [];@@ -139,9 +158,13 @@139158       break;140159     }141160     case "PEER_CONNECTED": {142-      for (const handler of incomingConnectionHandlers) {143-        handler(response.connection_id, response.peer_addr);144-      }161+      const info: IncomingConnectionInfo = {162+        connectionId: response.connection_id,163+        peerAddr: response.peer_addr,164+        isPairingOnly: response.is_pairing_only === true,165+        peerPublicKeyHex: response.peer_public_key_hex,166+      };167+      for (const handler of incomingConnectionHandlers) handler(info);145168       break;146169     }147170     case "DISCONNECTED": {@@ -387,6 +410,24 @@387410   return out;388411 }389412 413+export async function enterPairingMode(): Promise<void> {414+  await requestFromHost({ type: "ENTER_PAIRING_MODE" }, ["PAIRING_MODE_ENTERED"]);415+}416+417+export async function exitPairingMode(): Promise<void> {418+  await requestFromHost({ type: "EXIT_PAIRING_MODE" }, ["PAIRING_MODE_EXITED"]);419+}420+421+/**422+ * The native host's static x25519 public key (this device's sync identity).423+ * Initiators include this in PAIR_REQUEST so the responder can record us in424+ * its trust store after the user accepts; responders do likewise in425+ * PAIR_ACCEPT.426+ */427+export async function getIdentityPublicKey(): Promise<string> {428+  return getNativeIdentity();429+}430+390431 export async function getNativeStatus(): Promise<NativeSyncStatus> {391432   const response = await requestFromHost<Extract<HostResponse, { type: "STATUS" }>>(392433     { type: "STATUS" },@@ -413,7 +454,7 @@413454 }414455 415456 export function onIncomingConnection(416-  handler: (connectionId: string, peerAddr: string) => void457+  handler: (info: IncomingConnectionInfo) => void417458 ): () => void {418459   return subscribe(incomingConnectionHandlers, handler);419460 }

view file

src/shared/sync-types.ts+7 −0
@@ -97,6 +97,13 @@9797   publicKeyHex: string;9898   receivedAt: number;9999   expiresAt: number;100+  /**101+   * Native-host connection id of the still-open pairing-only connection. The102+   * background's RESPOND_INCOMING_PAIRING handler sends PAIR_ACCEPT or103+   * PAIR_REJECT over this connection then disconnects. May be absent for104+   * synthetic test injections that do not have a real connection behind them.105+   */106+  connectionId?: string;100107 }101108 102109 /**

view file