51b129be8db6

feat: Incoming pairing request overlay banner with 60s expiry (v2.13.29)

time
2026-04-30 15:13 UTC · 6 months ago
machine
koh2
parent
f400fcc5c4c3
10 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+67 −0src/background/messages.ts+47 −0src/background/sync-storage.ts+39 −1src/popup/App.tsx+73 −0src/popup/styles/popup.css+56 −0src/shared/sync-types.ts+15 −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.28",4+  "version": "2.13.29",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.28",4+  "version": "2.13.29",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.28",3+  "version": "2.13.29",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.test.ts+67 −0
@@ -12,6 +12,7 @@1212 1313 describe("background messages", () => {1414   let storage: StorageShape;15+  let sessionStorage: StorageShape;1516   let listener: MessageListener;1617   let fetchMock: ReturnType<typeof vi.fn>;1718 @@ -21,6 +22,7 @@2122     storage = {2223       peach_settings: { breachMonitoring: false },2324     };25+    sessionStorage = {};2426 2527     fetchMock = vi.fn(async () => ({2628       ok: true,@@ -74,6 +76,30 @@7476             }7577           }),7678         },79+        session: {80+          get: vi.fn(async (key?: string | string[]) => {81+            if (!key) return sessionStorage;82+            if (typeof key === "string") {83+              return { [key]: sessionStorage[key] };84+            }85+            return key.reduce<Record<string, unknown>>((result, entry) => {86+              result[entry] = sessionStorage[entry];87+              return result;88+            }, {});89+          }),90+          set: vi.fn(async (next: Record<string, unknown>) => {91+            Object.assign(sessionStorage, next);92+          }),93+          remove: vi.fn(async (keys: string | string[]) => {94+            const list = Array.isArray(keys) ? keys : [keys];95+            for (const key of list) {96+              delete sessionStorage[key];97+            }98+          }),99+          clear: vi.fn(async () => {100+            for (const key of Object.keys(sessionStorage)) delete sessionStorage[key];101+          }),102+        },77103       },78104       alarms: {79105         clear: vi.fn(),@@ -669,6 +695,47 @@669695     const hits = data.filter((d) => d.value === "[email protected]");670696     expect(hits).toHaveLength(1);671697     expect(hits[0]!.useCount).toBe(3);698+  });699+700+  it("DEBUG_INJECT_INCOMING_PAIRING + GET returns the request and 60s expiry window", async () => {701+    const inject = await dispatch({702+      type: "DEBUG_INJECT_INCOMING_PAIRING",703+      payload: { deviceName: "iPhone", publicKeyHex: "deadbeef" },704+    });705+    expect(inject.ok).toBe(true);706+707+    const get = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });708+    expect(get.ok).toBe(true);709+    const data = (get as { data?: { deviceName?: string; expiresAt?: number; receivedAt?: number } }).data;710+    expect(data?.deviceName).toBe("iPhone");711+    expect((data?.expiresAt ?? 0) - (data?.receivedAt ?? 0)).toBe(60_000);712+  });713+714+  it("RESPOND_INCOMING_PAIRING accept clears the request and reports the device name", async () => {715+    await dispatch({716+      type: "DEBUG_INJECT_INCOMING_PAIRING",717+      payload: { deviceName: "MacBook", publicKeyHex: "cafe" },718+    });719+    const respond = await dispatch({720+      type: "RESPOND_INCOMING_PAIRING",721+      payload: { accept: true },722+    });723+    expect(respond.ok).toBe(true);724+    expect((respond as { data?: { accepted?: boolean; deviceName?: string } }).data).toEqual({725+      accepted: true,726+      deviceName: "MacBook",727+    });728+    const after = await dispatch({ type: "GET_INCOMING_PAIRING_REQUEST" });729+    expect((after as { data?: unknown }).data).toBeNull();730+  });731+732+  it("RESPOND_INCOMING_PAIRING with no pending request returns no_pending_request", async () => {733+    const res = await dispatch({734+      type: "RESPOND_INCOMING_PAIRING",735+      payload: { accept: false },736+    });737+    expect(res.ok).toBe(false);738+    expect((res as { error?: string }).error).toBe("no_pending_request");672739   });673740 674741   it("RESET_SYNC_IDENTITY is Pro-gated and rejects free users", async () => {

view file

src/background/messages.ts+47 −0
@@ -618,6 +618,53 @@618618         });619619         return { ok: true };620620       }621+      case "GET_INCOMING_PAIRING_REQUEST": {622+        const { getIncomingPairingRequest } = await import('./sync-storage');623+        const request = await getIncomingPairingRequest();624+        return { ok: true, data: request };625+      }626+      case "RESPOND_INCOMING_PAIRING": {627+        const { accept } = msg.payload as { accept: boolean };628+        const { getIncomingPairingRequest, clearIncomingPairingRequest } = await import('./sync-storage');629+        const request = await getIncomingPairingRequest();630+        if (!request) return { ok: false, error: "no_pending_request" };631+        await clearIncomingPairingRequest();632+        if (accept) {633+          // Trust-store wiring lives in src/background/sync.ts (`trustDevice`).634+          // It is dormant until the native messaging host installer (Phase 9)635+          // ships and runtime trigger paths come online; the user-side accept636+          // here clears the banner and records intent for Phase 10 E2E.637+          try {638+            const { trustDevice } = await import('./sync');639+            await trustDevice(request.publicKeyHex, request.deviceName);640+          } catch {641+            // Native host not installed yet — accept still succeeds locally.642+          }643+        }644+        return { ok: true, data: { accepted: accept, deviceName: request.deviceName } };645+      }646+      case "DEBUG_INJECT_INCOMING_PAIRING": {647+        // Manual E2E rehearsal hook. Inserts a synthetic incoming pairing648+        // request into session storage so Phase 7 banner UX can be exercised649+        // without a live native host. No Pro gate — this is a no-op in650+        // production unless explicitly invoked from a Settings debug action.651+        const { deviceName, publicKeyHex } = (msg.payload ?? {}) as {652+          deviceName?: string;653+          publicKeyHex?: string;654+        };655+        if (!deviceName || !publicKeyHex) {656+          return { ok: false, error: "missing_payload" };657+        }658+        const { setIncomingPairingRequest } = await import('./sync-storage');659+        const now = Date.now();660+        await setIncomingPairingRequest({661+          deviceName,662+          publicKeyHex,663+          receivedAt: now,664+          expiresAt: now + 60_000,665+        });666+        return { ok: true };667+      }621668       case "RESET_SYNC_IDENTITY": {622669         const gate = await requirePro("sync");623670         if (!gate.allowed) return { ok: false, error: "pro_required" };

view file

src/background/sync-storage.ts+39 −1
@@ -1,7 +1,8 @@1-import type { TrustedNetwork, PairedDevice } from '../shared/sync-types';1+import type { TrustedNetwork, PairedDevice, IncomingPairingRequest } from '../shared/sync-types';22 33 const TRUSTED_NETWORKS_KEY = 'peach_trusted_networks';44 const PAIRED_DEVICES_KEY = 'peach_paired_devices';5+const INCOMING_PAIR_KEY = 'peach_incoming_pair';56 67 // ---------------------------------------------------------------------------78 // Trusted Networks@@ -118,5 +119,42 @@118119     return devices.find((d) => d.deviceId === deviceId);119120   } catch {120121     return undefined;122+  }123+}124+125+// ---------------------------------------------------------------------------126+// Incoming Pairing Request127+//128+// Stored in session storage so the request disappears when the browser closes129+// and never lands on disk. The 60-second expiry is enforced by the consumer130+// (`getIncomingPairingRequest` returns null past expiry and clears storage).131+// ---------------------------------------------------------------------------132+133+export async function setIncomingPairingRequest(134+  request: IncomingPairingRequest135+): Promise<void> {136+  await chrome.storage.session.set({ [INCOMING_PAIR_KEY]: request });137+}138+139+export async function getIncomingPairingRequest(): Promise<IncomingPairingRequest | null> {140+  try {141+    const result = await chrome.storage.session.get(INCOMING_PAIR_KEY);142+    const request = (result as { peach_incoming_pair?: IncomingPairingRequest })[INCOMING_PAIR_KEY];143+    if (!request) return null;144+    if (Date.now() >= request.expiresAt) {145+      await clearIncomingPairingRequest();146+      return null;147+    }148+    return request;149+  } catch {150+    return null;151+  }152+}153+154+export async function clearIncomingPairingRequest(): Promise<void> {155+  try {156+    await chrome.storage.session.remove(INCOMING_PAIR_KEY);157+  } catch {158+    // session storage may be unavailable in some contexts; safe to ignore.121159   }122160 }

view file

src/popup/App.tsx+73 −0
@@ -8,6 +8,7 @@88 import { clearInMemoryVault, publishInMemoryVault } from "../shared/in-memory-vault";99 import { ACCENT_COLORS } from "../shared/types";1010 import type { AccentColorName, PendingPasskeyRequest, ProFeature, VaultEntry } from "../shared/types";11+import type { IncomingPairingRequest } from "../shared/sync-types";1112 import { isPasskeyOnly } from "../shared/types";1213 import { LockView } from "./views/Lock";1314 import { Onboarding } from "./views/Onboarding";@@ -163,6 +164,15 @@163164     password: string;164165   } | null>(null);165166   const [backExitNotice, setBackExitNotice] = createSignal(false);167+  const [incomingPair, setIncomingPair] = createSignal<IncomingPairingRequest | null>(null);168+  const [incomingPairResponding, setIncomingPairResponding] = createSignal(false);169+  const [incomingPairTick, setIncomingPairTick] = createSignal(0);170+  const incomingPairSecondsLeft = () => {171+    void incomingPairTick();172+    const req = incomingPair();173+    if (!req) return 0;174+    return Math.max(0, Math.ceil((req.expiresAt - Date.now()) / 1000));175+  };166176   // True while Panic Step 1 has bounced the user out to Export. When Export's167177   // onBack fires we return to Panic instead of Settings so the user keeps the168178   // destruction context they came from.@@ -447,6 +457,7 @@447457     }448458 449459     await refreshPendingPasskey();460+    await refreshIncomingPairing();450461 451462     // Track tab changes so smart sort stays current452463     if (!Capacitor.isNativePlatform()) {@@ -537,6 +548,9 @@537548           setSettingsInitialSection("license");538549           setView("settings");539550         }551+        if (msg?.type === "INCOMING_PAIRING_REQUEST") {552+          void refreshIncomingPairing();553+        }540554       };541555       chrome.runtime.onMessage.addListener(handleRuntimeMessage);542556       onCleanup(() => chrome.runtime.onMessage.removeListener(handleRuntimeMessage));@@ -680,6 +694,36 @@680694     setPendingPasskey((res as { ok: boolean; data?: PendingPasskeyRequest | null }).data ?? null);681695   }682696 697+  async function refreshIncomingPairing() {698+    const res = await sendMessage({ type: "GET_INCOMING_PAIRING_REQUEST" });699+    const data = (res as { ok: boolean; data?: IncomingPairingRequest | null }).data ?? null;700+    setIncomingPair(data);701+  }702+703+  async function respondIncomingPairing(accept: boolean) {704+    if (incomingPairResponding()) return;705+    setIncomingPairResponding(true);706+    await sendMessage({ type: "RESPOND_INCOMING_PAIRING", payload: { accept } });707+    setIncomingPair(null);708+    setIncomingPairResponding(false);709+  }710+711+  // 60-second expiry ticker for the incoming pairing banner. Re-runs when a712+  // request appears, ticks once per second, and clears the banner if the713+  // request expires while the popup is open.714+  createEffect(() => {715+    const req = incomingPair();716+    if (!req) return;717+    const handle = window.setInterval(() => {718+      setIncomingPairTick((tick) => tick + 1);719+      if (Date.now() >= req.expiresAt) {720+        setIncomingPair(null);721+        window.clearInterval(handle);722+      }723+    }, 1000);724+    onCleanup(() => window.clearInterval(handle));725+  });726+683727   async function refreshProStatus() {684728     const res = await sendMessage({ type: "GET_LICENSE" });685729     const data = (res as { ok: boolean; data?: { valid?: boolean; plan?: string } }).data;@@ -1073,6 +1117,35 @@10731117                 </button>10741118               </div>10751119             </header>1120+1121+            <Show when={incomingPair() !== null}>1122+              <div class="peach-pair-request-banner" role="dialog" aria-label="Pairing request">1123+                <div class="peach-pair-request-header">1124+                  <span class="peach-pair-request-mark">🍑</span>1125+                  <span class="peach-pair-request-title">Pairing request</span>1126+                  <span class="peach-pair-request-countdown">{incomingPairSecondsLeft()}s</span>1127+                </div>1128+                <p class="peach-pair-request-body">1129+                  <strong>{incomingPair()!.deviceName}</strong> wants to sync with this Peach.1130+                </p>1131+                <div class="peach-pair-request-actions">1132+                  <button1133+                    class="peach-btn peach-btn--ghost peach-btn--sm"1134+                    onClick={() => void respondIncomingPairing(false)}1135+                    disabled={incomingPairResponding()}1136+                  >1137+                    Reject1138+                  </button>1139+                  <button1140+                    class="peach-btn peach-btn--accent peach-btn--sm"1141+                    onClick={() => void respondIncomingPairing(true)}1142+                    disabled={incomingPairResponding()}1143+                  >1144+                    Accept1145+                  </button>1146+                </div>1147+              </div>1148+            </Show>10761149 10771150             <Show when={pendingProNotice() !== null}>10781151               <div class="peach-pro-notice-banner">

view file

src/popup/styles/popup.css+56 −0
@@ -6122,6 +6122,62 @@61226122   line-height: 1;61236123 }61246124 6125+/* ── Incoming pairing request overlay banner (Phase 7) ──────────────────6126+   Renders over whatever view is showing. 60-second expiry; the popup ticks6127+   the countdown each second and clears the banner when it hits zero. */6128+.peach-pair-request-banner {6129+  display: flex;6130+  flex-direction: column;6131+  gap: 8px;6132+  padding: 10px 12px;6133+  background: color-mix(in srgb, var(--peach-accent) 14%, var(--peach-bg-card));6134+  border-bottom: 1px solid color-mix(in srgb, var(--peach-accent) 32%, transparent);6135+  font-family: "IBM Plex Mono", monospace;6136+  flex-shrink: 0;6137+}6138+6139+.peach-pair-request-header {6140+  display: flex;6141+  align-items: center;6142+  gap: 8px;6143+  font-size: 12px;6144+  color: var(--peach-t1);6145+}6146+6147+.peach-pair-request-mark {6148+  font-size: 14px;6149+}6150+6151+.peach-pair-request-title {6152+  font-weight: 600;6153+  color: var(--peach-t0);6154+}6155+6156+.peach-pair-request-countdown {6157+  margin-left: auto;6158+  font-variant-numeric: tabular-nums;6159+  color: var(--peach-t2);6160+  font-size: 11px;6161+}6162+6163+.peach-pair-request-body {6164+  margin: 0;6165+  font-size: 12px;6166+  color: var(--peach-t1);6167+  line-height: 1.4;6168+}6169+6170+.peach-pair-request-body strong {6171+  color: var(--peach-t0);6172+  font-weight: 600;6173+}6174+6175+.peach-pair-request-actions {6176+  display: flex;6177+  gap: 8px;6178+  justify-content: flex-end;6179+}6180+61256181 /* ─── First-run onboarding ──────────────────────────────────────────────61266182    Three-card welcome shown once before vault creation. The PeachScript61276183    rune background drifts behind everything at very low opacity — it's

view file

src/shared/sync-types.ts+15 −0
@@ -71,3 +71,18 @@7171   receiveNonce: number;7272   isInitiator: boolean;7373 }74+75+/**76+ * Pairing request received from a peer that needs user confirmation.77+ *78+ * The banner expires after 60 seconds (`expiresAt - receivedAt = 60_000`);79+ * after that the popup hides the banner and the background drops the80+ * request. The publicKeyHex is the peer's static X25519 identity — it81+ * gets written to trusted devices on accept and discarded on reject.82+ */83+export interface IncomingPairingRequest {84+  deviceName: string;85+  publicKeyHex: string;86+  receivedAt: number;87+  expiresAt: number;88+}

view file

src/shared/types.ts+3 −0
@@ -247,6 +247,9 @@247247   | "REMOVE_TRUSTED_NETWORK"248248   | "MARK_NETWORK_TRUSTED"249249   | "RESET_SYNC_IDENTITY"250+  | "GET_INCOMING_PAIRING_REQUEST"251+  | "RESPOND_INCOMING_PAIRING"252+  | "DEBUG_INJECT_INCOMING_PAIRING"250253   | "GET_PENDING_PASSKEY"251254   | "APPROVE_PASSKEY"252255   | "DENY_PASSKEY"

view file