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
↓ peach-browser-fad5dfeb.tar.gzatlas at this save
files changed3 files
src/background/messages.ts+58 −2src/background/pairing-completed-gate.test.ts+153 −0src/background/sync-info-sheet.test.ts+7 −0src/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)}` };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+});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: {