fad5dfeb3dd9

M13/SEC-005: verify publicKeyHex against completed handshake in PAIRING_COMPLETED

time
2026-05-16 03:25 UTC · 5 months ago
machine
koh2
parent
31cc6fe0e97a
3 files src/background/messages.ts+58 −2src/background/pairing-completed-gate.test.ts+153 −0src/background/sync-info-sheet.test.ts+7 −0
src/background/messages.ts+58 −2
@@ -168,6 +168,35 @@168168 // Sync helpers169169 // ---------------------------------------------------------------------------170170 171+/**172+ * Short-lived registry of x25519 public keys that just completed a real Noise173+ * NK handshake + initiator-auth proof. The PAIRING_COMPLETED handler must174+ * find the supplied publicKeyHex here before it will call addPairedDevice —175+ * otherwise an attacker could forge the message and pair an arbitrary key176+ * (M13/SEC-005).177+ *178+ * Entries auto-expire after 30s. Each entry is consumed on first successful179+ * PAIRING_COMPLETED so a single handshake cannot pair twice.180+ */181+const PENDING_PAIRS = new Map<string, ReturnType<typeof setTimeout>>();182+const PENDING_PAIR_TTL_MS = 30_000;183+184+export function markPairingHandshakeVerified(publicKeyHex: string): void {185+  if (!publicKeyHex) return;186+  const existing = PENDING_PAIRS.get(publicKeyHex);187+  if (existing) clearTimeout(existing);188+  const t = setTimeout(() => PENDING_PAIRS.delete(publicKeyHex), PENDING_PAIR_TTL_MS);189+  PENDING_PAIRS.set(publicKeyHex, t);190+}191+192+function consumePendingPair(publicKeyHex: string): boolean {193+  const t = PENDING_PAIRS.get(publicKeyHex);194+  if (!t) return false;195+  clearTimeout(t);196+  PENDING_PAIRS.delete(publicKeyHex);197+  return true;198+}199+171200 export function buildSyncDeps(): SyncDeps {172201   return {173202     isVaultUnlocked: () => vault.isUnlocked(),@@ -226,7 +255,14 @@226255       identity.privateKeyBytes,227256       identity.publicKeyBytes,228257     );229-    return await runBlobSyncSession('initiator', transport, buildSyncDeps());258+    const result = await runBlobSyncSession('initiator', transport, buildSyncDeps());259+    // Reaching this point means createWebSocketSyncTransport completed both260+    // the Noise NK handshake and the initiator-auth proof — the remote261+    // proved possession of the matching static key (M13/SEC-005).262+    if (result.status === 'completed') {263+      markPairingHandshakeVerified(publicKeyHex);264+    }265+    return result;230266   } catch (err) {231267     return { status: 'errored', applied: false, error: String(err) };232268   }@@ -335,6 +371,10 @@335371     syncPlug?.addListener?.("peach:pair-probe-answered", (data) => {336372       const deviceName = data.deviceName ?? "Peach";337373       const publicKeyHex = data.publicKeyHex ?? "";374+      // Native handled the pairing handshake — trust this entry into the375+      // verified-handshake set (M13/SEC-005). The subsequent PAIRING_COMPLETED376+      // message will consume it.377+      if (publicKeyHex) markPairingHandshakeVerified(publicKeyHex);338378       void (async () => {339379         const { addPairedDevice, setHasPaired: setPaired } = await import("./sync-storage");340380         await addPairedDevice({@@ -347,7 +387,7 @@347387         await setPaired();348388         chrome.runtime.sendMessage({349389           type: "PAIRING_COMPLETED",350-          payload: { deviceName },390+          payload: { deviceName, publicKeyHex },351391         }).catch(() => {});352392       })();353393     });@@ -623,6 +663,18 @@623663           deviceIp?: string;624664         };625665         if (!publicKeyHex) return { ok: false, error: "publicKeyHex required" };666+        // Only accept publicKeyHex values that just completed a real Noise NK667+        // handshake (initiator or responder side). Without this gate, anyone668+        // who can sendMessage to the background can pair an arbitrary key669+        // (M13/SEC-005). Re-confirming an already-paired device is allowed670+        // so legitimate update-IP retries still work.671+        const alreadyPaired = (await getPairedDevices()).some(672+          d => d.x25519PublicKey === publicKeyHex,673+        );674+        const verified = consumePendingPair(publicKeyHex);675+        if (!verified && !alreadyPaired) {676+          return { ok: false, error: "pairing_not_verified" };677+        }626678         const { setHasPaired: setPaired } = await import("./sync-storage");627679         await setPaired();628680         try {@@ -1375,6 +1427,10 @@13751427             await transport.close();13761428             return { ok: false, error: "sync_rejected_unpaired_initiator" };13771429           }1430+          // Initiator just proved control of the matching static key; mark it1431+          // as a verified handshake so a follow-up PAIRING_COMPLETED message1432+          // can be trusted (M13/SEC-005).1433+          markPairingHandshakeVerified(initiatorHex);13781434         } catch (err) {13791435           await transport.close();13801436           return { ok: false, error: `sync_auth_failed: ${String(err)}` };

view file

src/background/pairing-completed-gate.test.ts+153 −0
@@ -0,0 +1,153 @@1+/**2+ * pairing-completed-gate.test.ts — M13/SEC-0053+ *4+ * PAIRING_COMPLETED must verify the publicKeyHex came from a real Noise NK5+ * handshake before calling addPairedDevice. Forged messages with an6+ * unverified key must be rejected.7+ */8+9+import { beforeEach, describe, expect, it, vi } from "vitest";10+11+import type { Message, MessageResponse } from "../shared/types";12+13+type StorageShape = Record<string, unknown>;14+type MessageListener = (15+  msg: unknown,16+  sender: chrome.runtime.MessageSender,17+  sendResponse: (response: MessageResponse) => void18+) => boolean;19+20+describe("PAIRING_COMPLETED gate (M13/SEC-005)", () => {21+  let storage: StorageShape;22+  let listener: MessageListener | undefined;23+24+  beforeEach(async () => {25+    vi.resetModules();26+    vi.useRealTimers();27+    storage = { peach_settings: { breachMonitoring: false } };28+    listener = undefined;29+30+    vi.stubGlobal(31+      "fetch",32+      vi.fn(async () => ({ ok: true, status: 200, text: async () => "" })),33+    );34+    vi.stubGlobal("__PEACH_E2E_PASSKEYS__", false);35+36+    const chromeMock = {37+      runtime: {38+        id: "peach-extension",39+        onMessage: {40+          addListener: vi.fn((fn: MessageListener) => {41+            listener = fn;42+          }),43+        },44+        sendMessage: vi.fn(async () => ({ ok: true })),45+      },46+      storage: {47+        local: {48+          get: vi.fn(async (key?: string | string[]) => {49+            if (!key) return storage;50+            if (typeof key === "string") return { [key]: storage[key] };51+            return key.reduce<Record<string, unknown>>((acc, k) => {52+              acc[k] = storage[k];53+              return acc;54+            }, {});55+          }),56+          set: vi.fn(async (next: Record<string, unknown>) => {57+            Object.assign(storage, next);58+          }),59+          remove: vi.fn(async (keys: string | string[]) => {60+            const list = Array.isArray(keys) ? keys : [keys];61+            for (const k of list) delete storage[k];62+          }),63+        },64+        session: {65+          get: vi.fn(async () => ({})),66+          set: vi.fn(async () => {}),67+          remove: vi.fn(async () => {}),68+        },69+      },70+      alarms: {71+        clear: vi.fn(),72+        create: vi.fn(),73+        onAlarm: { addListener: vi.fn() },74+      },75+      idle: {76+        setDetectionInterval: vi.fn(),77+        onStateChanged: { addListener: vi.fn() },78+      },79+      scripting: { executeScript: vi.fn(async () => []) },80+    } as unknown as typeof chrome;81+    (globalThis as { chrome?: typeof chrome }).chrome = chromeMock;82+83+    const messages = await import("./messages");84+    messages.setupMessageHandler();85+  });86+87+  async function dispatch(message: Message): Promise<MessageResponse> {88+    if (!listener) throw new Error("listener not registered");89+    return new Promise<MessageResponse>((resolve) => {90+      listener!(message, { id: "peach-extension" } as chrome.runtime.MessageSender, resolve);91+    });92+  }93+94+  it("rejects PAIRING_COMPLETED for an unverified, unpaired publicKeyHex", async () => {95+    const fakeKey = "deadbeef".repeat(8);96+    const r = await dispatch({97+      type: "PAIRING_COMPLETED",98+      payload: { deviceName: "Attacker", publicKeyHex: fakeKey },99+    });100+    expect(r.ok).toBe(false);101+    expect(r.error).toBe("pairing_not_verified");102+103+    // Did not write to paired devices.104+    const paired = (storage.peach_paired_devices as unknown[] | undefined) ?? [];105+    expect(paired.length).toBe(0);106+  });107+108+  it("accepts PAIRING_COMPLETED for a freshly verified handshake", async () => {109+    const messages = await import("./messages");110+    const goodKey = "abcdef01".repeat(8);111+    messages.markPairingHandshakeVerified(goodKey);112+113+    const r = await dispatch({114+      type: "PAIRING_COMPLETED",115+      payload: { deviceName: "RealDevice", publicKeyHex: goodKey },116+    });117+    expect(r.ok).toBe(true);118+119+    const paired = (storage.peach_paired_devices as Array<Record<string, unknown>> | undefined) ?? [];120+    expect(paired.some(d => d.x25519PublicKey === goodKey)).toBe(true);121+  });122+123+  it("verified marker is single-use — second PAIRING_COMPLETED after the first device is removed is rejected", async () => {124+    const messages = await import("./messages");125+    const goodKey = "12345678".repeat(8);126+    messages.markPairingHandshakeVerified(goodKey);127+128+    const first = await dispatch({129+      type: "PAIRING_COMPLETED",130+      payload: { deviceName: "Dev", publicKeyHex: goodKey },131+    });132+    expect(first.ok).toBe(true);133+134+    // Wipe paired state so alreadyPaired is false.135+    delete storage.peach_paired_devices;136+137+    const second = await dispatch({138+      type: "PAIRING_COMPLETED",139+      payload: { deviceName: "Dev", publicKeyHex: goodKey },140+    });141+    expect(second.ok).toBe(false);142+    expect(second.error).toBe("pairing_not_verified");143+  });144+145+  it("missing publicKeyHex is rejected", async () => {146+    const r = await dispatch({147+      type: "PAIRING_COMPLETED",148+      payload: { deviceName: "X" },149+    });150+    expect(r.ok).toBe(false);151+    expect(r.error).toBe("publicKeyHex required");152+  });153+});

view file

src/background/sync-info-sheet.test.ts+7 −0
@@ -147,6 +147,11 @@147147   }148148 149149   it('flag is set when PAIRING_COMPLETED fires', async () => {150+    // M13/SEC-005 — PAIRING_COMPLETED now requires the publicKeyHex to have151+    // come from a real Noise handshake. Tests must register the verified key152+    // explicitly; production paths do this from doVaultSync / responder auth.153+    const messages = await import('./messages');154+    messages.markPairingHandshakeVerified('abc123');150155     await dispatch({151156       type: 'PAIRING_COMPLETED',152157       payload: {@@ -161,6 +166,8 @@161166   });162167 163168   it('PAIRING_COMPLETED stores device with lastKnownIp and deviceName', async () => {169+    const messages = await import('./messages');170+    messages.markPairingHandshakeVerified('abc123');164171     await dispatch({165172       type: 'PAIRING_COMPLETED',166173       payload: {

view file