f6a46c70c59f

fix: MV-8 sync revocation — rotateSyncIdentity re-keys root + clears peers + recovery-pending, removePairedDeviceById security note, DEVICE_KEY_ROTATED notify/alert

time
2026-06-10 02:29 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
audit fix integration
parent
590192651cb2
8 files src/background/chirp-sync-vault-adapter.test.ts+56 −0src/background/chirp-sync-vault-adapter.ts+25 −2src/background/messages.ts+5 −0src/background/paired-devices.ts+11 −0src/background/sync-alerts.ts+6 −3src/background/vault.test.ts+60 −0src/background/vault.ts+59 −0src/shared/types.ts+2 −0
src/background/chirp-sync-vault-adapter.test.ts+56 −0
@@ -368,6 +368,62 @@368368     ]);369369   });370370 371+  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {372+    const { adapter } = await setupVault();373+    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");374+    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");375+    const { upsertPairedDevice } = await import("./paired-devices");376+    const { getPendingSyncAlerts } = await import("./sync-alerts");377+378+    const issuerId = "extension-issuer";379+    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);380+    await upsertPairedDevice({381+      deviceId: issuerId,382+      deviceName: "Firefox",383+      deviceType: "extension",384+      longTermPublicKeyHex: "c".repeat(64),385+      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),386+      pairedAt: 1,387+      trustSource: "direct",388+    });389+    await upsertPairedDevice({390+      deviceId: "android-existing",391+      deviceName: "Pixel",392+      deviceType: "android",393+      longTermPublicKeyHex: "d".repeat(64),394+      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),395+      pairedAt: 2,396+      trustSource: "direct",397+    });398+399+    const manifest = await buildSignedDeviceManifest(400+      issuerId,401+      [402+        {403+          deviceId: "android-existing",404+          deviceName: "Pixel",405+          deviceType: "android",406+          longTermPublicKeyHex: "f".repeat(64),407+          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),408+          wsPort: 52345,409+          pairedAt: 3,410+        },411+      ],412+      issuerKp.privateKey,413+    );414+415+    await adapter.applyIncomingDeviceManifest?.(manifest);416+    // recordSyncAlerts is fire-and-forget — flush microtasks.417+    for (let i = 0; i < 25; i++) await Promise.resolve();418+419+    const alerts = await getPendingSyncAlerts();420+    expect(421+      alerts.some(422+        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",423+      ),424+    ).toBe(true);425+  });426+371427   it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {372428     const { adapter } = await setupVault();373429     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");

view file

src/background/chirp-sync-vault-adapter.ts+25 −2
@@ -48,6 +48,7 @@4848   upsertPairedDevice,4949 } from "./paired-devices";5050 import { addPendingVouchedDevice } from "./pending-vouched-devices";51+import { recordSyncAlerts } from "./sync-alerts";5152 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";5253 import type { VaultEntry } from "../shared/types";5354 import type {@@ -285,8 +286,30 @@285286       continue; // not trusted yet286287     }287288 288-    // Existing device — update in place as before. (MV-8 fix 8d adds the289-    // key-rotation notification here; see that patch.)289+    // MV-8d: a key changing under a stable deviceId is legitimate after a290+    // reinstall, but it is exactly what a graph-poisoning attack looks like —291+    // signal the user either way, durably (the alert queue survives a closed292+    // popup) and live (the broadcast updates an open one).293+    if (294+      existing.longTermPublicKeyHex &&295+      existing.longTermPublicKeyHex !== entry.longTermPublicKeyHex.toLowerCase()296+    ) {297+      notifyPopup({298+        type: "DEVICE_KEY_ROTATED",299+        payload: { deviceName: entry.deviceName, deviceId: entry.deviceId },300+      });301+      void recordSyncAlerts([302+        {303+          type: "DEVICE_KEY_ROTATED",304+          entryId: "",305+          entryTitle: entry.deviceName,306+          deviceName: entry.deviceName,307+          timestamp: Date.now(),308+        },309+      ]);310+    }311+312+    // Existing device — update in place as before.290313     await upsertPairedDevice({291314       ...existing,292315       deviceId: entry.deviceId,

view file

src/background/messages.ts+5 −0
@@ -1348,6 +1348,11 @@13481348         await removePendingVouchedDevice(deviceId);13491349         return { ok: true };13501350       }1351+      case "ROTATE_SYNC_IDENTITY": {1352+        if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };1353+        const result = await vault.rotateSyncIdentity();1354+        return result.ok ? { ok: true } : { ok: false, error: result.error };1355+      }13511356 13521357       default:13531358         return { ok: false, error: "unknown_message_type" };

view file

src/background/paired-devices.ts+11 −0
@@ -69,6 +69,17 @@6969   await chrome.storage.local.set({ [REGISTRY_KEY]: devices });7070 }7171 72+/**73+ * Removes the device from the LOCAL paired-devices registry only.74+ *75+ * SECURITY NOTE (MV-8): this does NOT prevent the removed device from76+ * syncing with OTHER peers in the device graph, and the device RETAINS77+ * its copy of the vault root key (syncMasterKey) — it can still decrypt78+ * anything it already holds and anything other peers send it. For a79+ * lost or stolen device, removal is insufficient: use "Rotate sync80+ * identity" (vault.ts rotateSyncIdentity), which re-keys the vault so81+ * the old device's key material is useless.82+ */7283 export async function removePairedDeviceById(deviceId: string): Promise<void> {7384   const devices = await listPairedDevices();7485   const filtered = devices.filter((d) => d.deviceId !== deviceId);

view file

src/background/sync-alerts.ts+6 −3
@@ -12,9 +12,10 @@1212 // with `consume: true`).1313 1414 export interface SyncAlert {15-  type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE";15+  type: "REMOTE_PASSWORD_CHANGE" | "REMOTE_DELETE" | "DEVICE_KEY_ROTATED";1616   /** VaultEntry.id — a UUID string in this codebase (spec deviation: the17-   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`). */17+   *  fix spec typed this `number`, but entry ids are `crypto.randomUUID()`).18+   *  Empty string for DEVICE_KEY_ROTATED (not entry-scoped). */1819   entryId: string;1920   entryTitle: string;2021   /** Peer device name when known; "paired device" when the merge layer@@ -65,7 +66,9 @@6566   if (typeof x !== "object" || x === null) return false;6667   const r = x as Record<string, unknown>;6768   return (68-    (r.type === "REMOTE_PASSWORD_CHANGE" || r.type === "REMOTE_DELETE") &&69+    (r.type === "REMOTE_PASSWORD_CHANGE" ||70+      r.type === "REMOTE_DELETE" ||71+      r.type === "DEVICE_KEY_ROTATED") &&6972     typeof r.entryId === "string" &&7073     typeof r.entryTitle === "string" &&7174     typeof r.deviceName === "string" &&

view file

src/background/vault.test.ts+60 −0
@@ -1299,4 +1299,64 @@12991299       expect((restored as unknown as Record<string, unknown>).credentialId).toBeUndefined();13001300     });13011301   });1302+1303+  describe("MV-8 rotateSyncIdentity", () => {1304+    it("re-keys the root, clears paired devices, and keeps entries unlockable with the same password", async () => {1305+      const vault = await import("./vault");1306+      await vault.createVault("correct horse battery staple");1307+      const e = await vault.addEntry({ type: "login", title: "Bank", password: "pw" });1308+      const { upsertPairedDevice, listPairedDevices } = await import(1309+        "./paired-devices"1310+      );1311+      await upsertPairedDevice({1312+        deviceId: "d1",1313+        deviceName: "Phone",1314+        deviceType: "android",1315+        longTermPublicKeyHex: "a".repeat(64),1316+        signingPublicKeyHex: "b".repeat(64),1317+        pairedAt: 1,1318+        trustSource: "direct",1319+      });1320+1321+      const rootBefore = vault.getRootKeyHex();1322+      const res = await vault.rotateSyncIdentity();1323+      expect(res.ok).toBe(true);1324+      expect(vault.getRootKeyHex()).not.toBe(rootBefore);1325+      expect(await listPairedDevices()).toHaveLength(0);1326+      // Entries survive — they are sealed under the password key, not the root.1327+      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1328+1329+      vault.lock();1330+      expect(await vault.unlock("correct horse battery staple")).toBe(true);1331+      expect(vault.getEntries().find((x) => x.id === e.id)?.title).toBe("Bank");1332+    });1333+1334+    it("invalidates the old recovery words and flags recovery-pending", async () => {1335+      const vault = await import("./vault");1336+      await vault.createVault("correct horse battery staple");1337+      const wordsBefore = (await vault.getRecoveryWords()).join(" ");1338+1339+      await vault.rotateSyncIdentity();1340+1341+      const wordsAfter = (await vault.getRecoveryWords()).join(" ");1342+      expect(wordsAfter).not.toBe(wordsBefore);1343+      const { isRecoveryConfirmPending } = await import("./recovery-pending");1344+      expect(await isRecoveryConfirmPending()).toBe(true);1345+    });1346+1347+    it("changes the derived sync vault_id so an old device fails the vault-id gate", async () => {1348+      const vault = await import("./vault");1349+      await vault.createVault("correct horse battery staple");1350+      const { createChirpSyncVaultAdapter } = await import(1351+        "./chirp-sync-vault-adapter"1352+      );1353+      const adapter = createChirpSyncVaultAdapter();1354+      const vidBefore = await adapter.computeVaultId();1355+1356+      await vault.rotateSyncIdentity();1357+1358+      const vidAfter = await adapter.computeVaultId();1359+      expect(vidAfter).not.toBe(vidBefore);1360+    });1361+  });13021362 });

view file

src/background/vault.ts+59 −0
@@ -32,6 +32,7 @@3232 } from "../shared/types";3333 import { decrypt, encrypt, zeroKey } from "./aes";3434 import { clearAutoLock, touchAutoLock } from "./alarms";35+import { setRecoveryConfirmPending } from "./recovery-pending";3536 3637 type EntryDraft = Omit<3738   VaultEntry,@@ -2639,6 +2640,64 @@26392640   // the lock screen rather than silently holding an inaccessible vault.26402641   lock();26412642 2643+  return { ok: true };2644+}2645+2646+/**2647+ * Rotate the vault's sync identity by generating a FRESH ROOT KEY and2648+ * re-wrapping everything under it (MV-8). Because every sync credential —2649+ * vault_id, the Noise XX static keypair, the Ed25519 manifest signing key,2650+ * and the per-entry envelope keys — derives from the root key, rotation2651+ * makes a lost/stolen device's retained key material useless. All paired2652+ * devices must re-pair afterwards.2653+ *2654+ * CONSEQUENCE THE UI MUST SURFACE: the 12 recovery words also derive from2655+ * the root key, so rotation INVALIDATES the old recovery phrase. The flow2656+ * sets peach_recovery_pending so the popup re-presents the new words for2657+ * confirmation, same as the migrateToRecovery rotate path.2658+ */2659+export async function rotateSyncIdentity(): Promise<{ ok: boolean; error?: string }> {2660+  if (!derivedKey || vaultMode !== "real") {2661+    return { ok: false, error: "vault_locked" };2662+  }2663+  const slotA = await loadSlot(SLOT_A);2664+  if (!slotA) return { ok: false, error: "missing_slot_a" };2665+2666+  // 1. Fresh root key + recovery escrow (new words).2667+  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =2668+    await generateAndWrapRootKey();2669+2670+  // 2. Re-wrap the new root key under the current password-derived key.2671+  const newRootKey = unhex(rawRootKey);2672+  const pwRoot = await wrapRootKeyForDerivedKey(derivedKey, newRootKey);2673+2674+  // 3. Snapshot, then single atomic slot write — both escrow wrappings of2675+  //    the new root key land together (same transactional rule as2676+  //    changePassword). Entries are untouched: they are sealed under the2677+  //    password-derived key, not the root key.2678+  await takeVaultSnapshot("local_save");2679+  const nextSlotA: StoredVault = {2680+    ...slotA,2681+    recovery_salt: recoverySalt,2682+    recovery_wrapped_root_key: wrappedRootKey,2683+    recovery_nonce: wrappedNonce,2684+    recovery_tag: wrappedTag,2685+    ...pwRoot,2686+  };2687+  await writeSlot(SLOT_A, nextSlotA);2688+2689+  // 4. Swap in memory only after disk committed.2690+  if (rootKey) zeroKey(rootKey);2691+  rootKey = newRootKey;2692+2693+  // 5. Old peers' identities derive from the dead root key — drop them all.2694+  const { clearPairedDevices } = await import("./paired-devices");2695+  await clearPairedDevices();2696+2697+  // 6. Force re-presentation of the NEW recovery words on next popup view.2698+  await setRecoveryConfirmPending(); // same flag the create-vault flow uses2699+2700+  await stampLocalVaultMutation();26422701   return { ok: true };26432702 }26442703 

view file

src/shared/types.ts+2 −0
@@ -252,6 +252,8 @@252252   | "CONFIRM_VOUCHED_DEVICE"253253   | "REJECT_VOUCHED_DEVICE"254254   | "VOUCHED_DEVICE_PENDING"255+  | "ROTATE_SYNC_IDENTITY"256+  | "DEVICE_KEY_ROTATED"255257   | "UNLOCK"256258   | "LOCK"257259   | "IS_UNLOCKED"

view file