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
↓ peach-browser-59019265.tar.gzatlas at this save
files changed5 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 −0src/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 });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 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 });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: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"