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
↓ peach-browser-f6a46c70.tar.gzatlas at this save
files changed8 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 −0src/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");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,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" };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);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" &&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 });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 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"