590192651cb2

fix: MV-7 vouched-manifest trust requires user confirmation — unknown devices queue in pending-vouched store until CONFIRM/REJECT

time
2026-06-10 02:24 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
4620e8e0a3ac
5 files src/background/chirp-sync-vault-adapter.test.ts+13 −7src/background/chirp-sync-vault-adapter.ts+43 −5src/background/messages.test.ts+61 −0src/background/messages.ts+32 −0src/shared/types.ts+4 −0
src/background/chirp-sync-vault-adapter.test.ts+13 −7
@@ -282,7 +282,7 @@282282     });283283   });284284 285-  it("applyIncomingDeviceManifest accepts extension-vouched devices and preserves existing direct trust", async () => {285+  it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {286286     const { adapter } = await setupVault();287287     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");288288     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");@@ -291,6 +291,9 @@291291       listPairedDevices,292292       upsertPairedDevice,293293     } = await import("./paired-devices");294+    const { listPendingVouchedDevices } = await import(295+      "./pending-vouched-devices"296+    );294297 295298     const issuerId = "extension-issuer";296299     const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);@@ -340,14 +343,18 @@340343 341344     await adapter.applyIncomingDeviceManifest?.(manifest);342345 343-    const added = await findPairedDeviceById("android-new");344-    expect(added).toMatchObject({346+    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending347+    // queue for explicit user confirmation.348+    expect(await findPairedDeviceById("android-new")).toBeNull();349+    const pending = await listPendingVouchedDevices();350+    expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);351+    expect(pending[0]).toMatchObject({345352       deviceName: "New Pixel",346-      deviceType: "android",347353       longTermPublicKeyHex: "e".repeat(64),348-      trustSource: "vouched",349-      wsPort: 51234,354+      vouchedByDeviceId: "extension-issuer",350355     });356+357+    // The already-paired device is still updated in place.351358     const existing = await findPairedDeviceById("android-existing");352359     expect(existing).toMatchObject({353360       deviceName: "Existing Pixel renamed",@@ -357,7 +364,6 @@357364     });358365     expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([359366       "android-existing",360-      "android-new",361367       "extension-issuer",362368     ]);363369   });

view file

src/background/chirp-sync-vault-adapter.ts+43 −5
@@ -47,6 +47,7 @@4747   listPairedDevices,4848   upsertPairedDevice,4949 } from "./paired-devices";50+import { addPendingVouchedDevice } from "./pending-vouched-devices";5051 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";5152 import type { VaultEntry } from "../shared/types";5253 import type {@@ -259,18 +260,55 @@259260       continue;260261     }261262     const existing = await findPairedDeviceById(entry.deviceId);263+264+    // MV-7: a device we have NEVER seen does not become a sync target on265+    // the strength of the manifest signature alone. Queue it for explicit266+    // user confirmation; until then it cannot push entries or tombstones267+    // at us (it is absent from the paired-devices registry, so the runner268+    // never opens a session to it and inbound static keys don't match).269+    if (!existing) {270+      await addPendingVouchedDevice({271+        deviceId: entry.deviceId,272+        deviceName: entry.deviceName,273+        deviceType: entry.deviceType,274+        longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),275+        signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),276+        wsPort: entry.wsPort,277+        pairedAt: entry.pairedAt,278+        receivedAt: Date.now(),279+        vouchedByDeviceId: issuer.deviceId,280+      });281+      notifyPopup({282+        type: "VOUCHED_DEVICE_PENDING",283+        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },284+      });285+      continue; // not trusted yet286+    }287+288+    // Existing device — update in place as before. (MV-8 fix 8d adds the289+    // key-rotation notification here; see that patch.)262290     await upsertPairedDevice({263-      ...(existing ?? {}),291+      ...existing,264292       deviceId: entry.deviceId,265293       deviceName: entry.deviceName,266294       deviceType: entry.deviceType,267295       longTermPublicKeyHex: entry.longTermPublicKeyHex.toLowerCase(),268296       signingPublicKeyHex: entry.signingPublicKeyHex.toLowerCase(),269-      pairedAt: existing?.pairedAt ?? entry.pairedAt,270-      trustSource: existing?.trustSource ?? (existing ? "direct" : "vouched"),271-      wsPort: existing?.wsPort ?? entry.wsPort,272-      lastSyncedAt: existing?.lastSyncedAt,297+      pairedAt: existing.pairedAt ?? entry.pairedAt,298+      trustSource: existing.trustSource ?? "direct",299+      wsPort: existing.wsPort ?? entry.wsPort,300+      lastSyncedAt: existing.lastSyncedAt,273301     });302+  }303+}304+305+// MV-7: fire-and-forget broadcast to the popup. The popup may be closed,306+// which is fine — the pending-vouched queue is the durable signal.307+function notifyPopup(msg: { type: string; payload?: unknown }): void {308+  try {309+    void chrome.runtime.sendMessage(msg).catch(() => undefined);310+  } catch {311+    /* popup closed — pending queue is the durable signal */274312   }275313 }276314 

view file

src/background/messages.test.ts+61 −0
@@ -1013,4 +1013,65 @@10131013     const data = res.data as Array<{ title: string }>;10141014     expect(data.map((d) => d.title).sort()).toEqual(["Example", "GitHub"]);10151015   });1016+1017+  // --- MV-7: vouched device confirmation handlers ---1018+1019+  async function seedPendingVouched(deviceId: string) {1020+    const { addPendingVouchedDevice } = await import(1021+      "./pending-vouched-devices"1022+    );1023+    await addPendingVouchedDevice({1024+      deviceId,1025+      deviceName: "New Pixel",1026+      deviceType: "android",1027+      longTermPublicKeyHex: "e".repeat(64),1028+      signingPublicKeyHex: "a".repeat(64),1029+      wsPort: 51234,1030+      pairedAt: 1_700_000_000_020,1031+      receivedAt: 1_700_000_000_050,1032+      vouchedByDeviceId: "extension-issuer",1033+    });1034+  }1035+1036+  it("MV-7: CONFIRM_VOUCHED_DEVICE moves a pending device into the paired registry as vouched", async () => {1037+    const vault = await import("./vault");1038+    await vault.createVault("correct-pass-12345");1039+    await seedPendingVouched("android-new");1040+    const { findPairedDeviceById } = await import("./paired-devices");1041+    const { listPendingVouchedDevices } = await import(1042+      "./pending-vouched-devices"1043+    );1044+1045+    const res = await dispatch({1046+      type: "CONFIRM_VOUCHED_DEVICE",1047+      payload: { deviceId: "android-new" },1048+    });1049+    expect(res).toMatchObject({ ok: true });1050+1051+    expect(await findPairedDeviceById("android-new")).toMatchObject({1052+      deviceName: "New Pixel",1053+      trustSource: "vouched",1054+      longTermPublicKeyHex: "e".repeat(64),1055+    });1056+    expect(await listPendingVouchedDevices()).toHaveLength(0);1057+  });1058+1059+  it("MV-7: REJECT_VOUCHED_DEVICE clears the pending entry and never pairs it", async () => {1060+    const vault = await import("./vault");1061+    await vault.createVault("correct-pass-12345");1062+    await seedPendingVouched("android-new");1063+    const { findPairedDeviceById } = await import("./paired-devices");1064+    const { listPendingVouchedDevices } = await import(1065+      "./pending-vouched-devices"1066+    );1067+1068+    const res = await dispatch({1069+      type: "REJECT_VOUCHED_DEVICE",1070+      payload: { deviceId: "android-new" },1071+    });1072+    expect(res).toMatchObject({ ok: true });1073+1074+    expect(await findPairedDeviceById("android-new")).toBeNull();1075+    expect(await listPendingVouchedDevices()).toHaveLength(0);1076+  });10161077 });

view file

src/background/messages.ts+32 −0
@@ -12,6 +12,11 @@1212 import { copyToClipboard } from "./clipboard";1313 import { getPendingSyncAlerts, clearPendingSyncAlerts } from "./sync-alerts";1414 import { extractDomain } from "../shared/domain";15+import {16+  getPendingVouchedDevice,17+  listPendingVouchedDevices,18+  removePendingVouchedDevice,19+} from "./pending-vouched-devices";1520 import { setupAutoLock, touchAutoLock } from "./alarms";1621 import { runSelfTest } from "./self-test";1722 import * as vault from "./vault";@@ -1315,6 +1320,33 @@13151320         // the alerts.13161321         if (consume) await clearPendingSyncAlerts();13171322         return { ok: true, data: alerts };1323+      }1324+1325+      case "GET_PENDING_VOUCHED_DEVICES": {1326+        return { ok: true, data: await listPendingVouchedDevices() };1327+      }1328+      case "CONFIRM_VOUCHED_DEVICE": {1329+        const { deviceId } = msg.payload as { deviceId: string };1330+        const pending = await getPendingVouchedDevice(deviceId);1331+        if (!pending) return { ok: false, error: "not_pending" };1332+        await upsertPairedDeviceV3({1333+          deviceId: pending.deviceId,1334+          deviceName: pending.deviceName,1335+          deviceType:1336+            pending.deviceType as import("../shared/sync-types").PairedDevice["deviceType"],1337+          longTermPublicKeyHex: pending.longTermPublicKeyHex,1338+          signingPublicKeyHex: pending.signingPublicKeyHex,1339+          wsPort: pending.wsPort,1340+          pairedAt: pending.pairedAt,1341+          trustSource: "vouched",1342+        });1343+        await removePendingVouchedDevice(deviceId);1344+        return { ok: true };1345+      }1346+      case "REJECT_VOUCHED_DEVICE": {1347+        const { deviceId } = msg.payload as { deviceId: string };1348+        await removePendingVouchedDevice(deviceId);1349+        return { ok: true };13181350       }13191351 13201352       default:

view file

src/shared/types.ts+4 −0
@@ -248,6 +248,10 @@248248 export type MessageType =249249   | "GET_SYNC_ALERTS"250250   | "CLEAR_CLIPBOARD"251+  | "GET_PENDING_VOUCHED_DEVICES"252+  | "CONFIRM_VOUCHED_DEVICE"253+  | "REJECT_VOUCHED_DEVICE"254+  | "VOUCHED_DEVICE_PENDING"251255   | "UNLOCK"252256   | "LOCK"253257   | "IS_UNLOCKED"

view file