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
↓ peach-browser-ccf8a297.tar.gzatlas at this save
files changed7 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 −0manifest.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": {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",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": {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 }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,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 }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 /**