086afa1eb15a

harden immutable device identity, atomic revocations, and decoy sync teardown (v5.2.31)

time
2026-09-10 08:10 UTC · 1 month ago
machine
koh2
agent
camel-stream/auto
session
identity trust remediation
parent
93638f8f0f13
16 files package.json+1 −1src/background/alarms.ts+2 −2src/background/entry.ts+7 −2src/background/locked-sync-spool.ts+6 −0src/background/mesh-relay-listener.ts+31 −6src/background/messages.test.ts+48 −1src/background/messages.ts+14 −0src/background/paired-devices.test.ts+46 −8src/background/paired-devices.ts+177 −25src/background/relay-adoption-bg.test.ts+1 −0src/background/relay-adoption-bg.ts+4 −6src/background/sync-runner.ts+12 −1src/background/sync-trigger.ts+24 −9src/background/sync-vault-adapter.test.ts+37 −72src/background/sync-vault-adapter.ts+60 −118src/background/vault.ts+11 −2
package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.30",3+  "version": "5.2.31",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/alarms.ts+2 −2
@@ -70,7 +70,7 @@7070   }7171 }7272 73-if (chrome.alarms?.onAlarm?.addListener) {73+if (typeof chrome !== "undefined" && chrome.alarms?.onAlarm?.addListener) {7474   chrome.alarms.onAlarm.addListener(async (alarm) => {7575     if (alarm.name !== AUTO_LOCK_ALARM) return;7676     if (rescheduleDeferredAutoLock()) return;@@ -80,7 +80,7 @@8080   });8181 }8282 83-if (chrome.idle?.onStateChanged) {83+if (typeof chrome !== "undefined" && chrome.idle?.onStateChanged) {8484   chrome.idle.onStateChanged.addListener(async (state) => {8585     if (resolveAutoLockMinutes() <= 0) return;8686     if (state !== "idle" && state !== "locked") return;

view file

src/background/entry.ts+7 −2
@@ -14,7 +14,7 @@1414   setSyncRunner,1515   triggerSyncIfReachable,1616 } from "./sync-trigger";17-import { productionSyncRunner } from "./sync-runner";17+import { productionSyncRunner, abortSyncRunnerSessions } from "./sync-runner";1818 import {1919   drainAndRefreshLockedSyncSpool,2020   refreshLockedSyncSpool,@@ -23,8 +23,9 @@2323 import { repairPairedRevocationOverlaps } from "./paired-devices";2424 import {2525   requestMeshRelayReconcile,26-  selectProductionDialTargets,2726   setupMeshRelayListener,27+  abortMeshRelaySessions,28+  selectProductionDialTargets,2829 } from "./mesh-relay-listener";2930 import { hardenExtensionStorage } from "./storage-access";3031 import { recordInstalledVersion } from "../shared/release-notes";@@ -69,6 +70,10 @@6970   setLockedSyncHooks({7071     refresh: refreshLockedSyncSpool,7172     afterUnlock: drainAndRefreshLockedSyncSpool,73+    onDecoy: () => {74+      abortMeshRelaySessions();75+      abortSyncRunnerSessions();76+    },7277   });7378   // MV3: the chrome.runtime.onMessage listener MUST be registered during the7479   // worker's initial SYNCHRONOUS evaluation. If registration is deferred behind

view file

src/background/locked-sync-spool.ts+6 −0
@@ -2,7 +2,9 @@22 import type { SyncPeerIdentity } from "../shared/sync-transport";33 import { SYNC_LIMITS, clampSyncTimestamp } from "../shared/sync-limits";44 import type { DeviceType } from "../shared/sync-types";5+import { isDecoyMode } from "./vault";56 7+68 const STORAGE_KEY = "peach_locked_sync_spool_v1";79 810 /**@@ -129,9 +131,11 @@129131       return out;130132     },131133     async applyEntries(entries, remoteManifest = {}) {134+      if (isDecoyMode()) throw new Error("vault_locked");132135       const list = Object.entries(entries);133136       if (list.length > SYNC_LIMITS.MAX_ENTRY_COUNT) throw new Error("sync_entry_count_exceeded");134137       await mutateSpool(async (current) => {138+        if (isDecoyMode()) throw new Error("vault_locked");135139         if (!current || current.vaultId !== snapshot.vaultId) return;136140         const inbox = { ...current.inboxEntries };137141         const now = Date.now();@@ -153,9 +157,11 @@153157       });154158     },155159     async applyTombstones(tombstones) {160+      if (isDecoyMode()) throw new Error("vault_locked");156161       const list = Object.entries(tombstones);157162       if (list.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) throw new Error("sync_tombstone_count_exceeded");158163       await mutateSpool(async (current) => {164+        if (isDecoyMode()) throw new Error("vault_locked");159165         if (!current || current.vaultId !== snapshot.vaultId) return;160166         const inbox = { ...current.inboxTombstones };161167         const now = Date.now();

view file

src/background/mesh-relay-listener.ts+31 −6
@@ -27,7 +27,7 @@2727   markLastSyncedAt,2828 } from "./paired-devices";2929 import { getSyncSettings, relayInForce } from "./sync-settings";30-import { isUnlocked, getRootKeyHex } from "./vault";30+import { isUnlocked, isDecoyMode, getRootKeyHex } from "./vault";3131 import { getPairSecret } from "./pair-secrets";3232 3333 const RECONCILE_INTERVAL_MS = 60_000;@@ -41,6 +41,7 @@4141   reconcileQueued: boolean;4242   timer: ReturnType<typeof setTimeout> | null;4343   waiters: Map<string, WaiterHandle>;44+  activeSessions: Set<{ close(): Promise<void> }>;4445 }4546 4647 function state(): MeshListenerState {@@ -53,6 +54,7 @@5354       reconcileQueued: false,5455       timer: null,5556       waiters: new Map(),57+      activeSessions: new Set(),5658     };5759   }5860   return global.__peachMeshListenerState;@@ -82,6 +84,13 @@8284   requestMeshRelayReconcile();8385 }8486 87+export function abortMeshRelaySessions(): void {88+  const listener = state();89+  abortAll(listener);90+  for (const session of listener.activeSessions) void session.close().catch(() => undefined);91+  listener.activeSessions.clear();92+}93+8594 export function requestMeshRelayReconcile(): void {8695   const listener = state();8796   if (listener.reconcileQueued) return;@@ -94,6 +103,10 @@94103 95104 async function reconcile(): Promise<void> {96105   const listener = state();106+  if (isDecoyMode()) {107+    abortAll(listener);108+    return;109+  }97110   if (listener.timer !== null) clearTimeout(listener.timer);98111   listener.timer = setTimeout(requestMeshRelayReconcile, RECONCILE_INTERVAL_MS);99112 @@ -106,9 +119,11 @@106119   }>();107120   try {108121     const settings = await getSyncSettings();122+    if (isDecoyMode()) { abortAll(listener); return; }109123     const fallbackRelay = relayInForce(settings);110124     const nowSeconds = Date.now() / 1000;111125     for (const peer of await listPairedDevices()) {126+      if (isDecoyMode()) { abortAll(listener); return; }112127       if (113128         !isRouteCapableMeshPeer(peer) ||114129         weDialMeshPeer(@@ -159,10 +174,10 @@159174   signal: AbortSignal,160175 ): Promise<void> {161176   let retryDelayMs = MESH_WAITER_RETRY_DELAY_MS;162-  while (!signal.aborted) {177+  while (!signal.aborted && !isDecoyMode()) {163178     const current = await findPairedDeviceById(target.peer.deviceId);179+    if (isDecoyMode()) return;164180     if (!current || !isRouteCapableMeshPeer(current)) return;165-166181     let session: Awaited<ReturnType<typeof openRelaySyncResponderSession>> | null = null;167182     let local: Awaited<ReturnType<typeof getLocalPairingIdentity>> | null = null;168183     let succeeded = false;@@ -180,19 +195,27 @@180195         target.rendezvousId,181196         { signal },182197       );198+      state().activeSessions.add(session);199+      if (isDecoyMode()) {200+        state().activeSessions.delete(session);201+        await session.close().catch(() => undefined);202+        return;203+      }183204       const adapter = isUnlocked()184205         ? createSyncVaultAdapter({ deviceName: current.deviceName })185206         : await createLockedSyncVaultAdapter();186-      if (!adapter) return;207+      if (!adapter || isDecoyMode()) return;187208       const outcome = await runSyncResponder(session, adapter);209+      if (isDecoyMode()) return;188210       if (outcome.status === "completed") {211+        if (isDecoyMode()) return;189212         await markLastSyncedAt(current.deviceId, Date.now());190213         succeeded = true;191214       }192-      requestMeshRelayReconcile();215+      if (!isDecoyMode()) requestMeshRelayReconcile();193216     } catch {194-      if (signal.aborted) return;195217     } finally {218+      if (session) state().activeSessions.delete(session);196219       local?.privateKey.fill(0);197220       local?.publicKey.fill(0);198221       await session?.close().catch(() => undefined);@@ -246,4 +269,6 @@246269 function abortAll(listener: MeshListenerState): void {247270   for (const handle of listener.waiters.values()) handle.abort.abort();248271   listener.waiters.clear();272+  for (const session of listener.activeSessions) void session.close().catch(() => undefined);273+  listener.activeSessions.clear();249274 }

view file

src/background/messages.test.ts+48 −1
@@ -1573,7 +1573,54 @@15731573     expect(data.map((d) => d.title)).toEqual(["GitHub"]);15741574   });15751575 1576-  // --- MV-7: vouched device confirmation handlers ---1576+  it("PB-26-104: decoy pairing messages hide devices and prior sync outcome", async () => {1577+    const vault = await import("./vault");1578+    const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");1579+    const { setSyncRunner, triggerSyncIfReachable } = await import("./sync-trigger");1580+    await vault.createVault("correct-pass-12345");1581+    await vault.setupDuress("decoy-pass-67890");1582+    await upsertPairedDevice({1583+      deviceId: "android-real",1584+      deviceName: "Real Pixel",1585+      deviceType: "android",1586+      longTermPublicKeyHex: "a".repeat(64),1587+      trustSource: "direct",1588+      pairedAt: 1_700_000_000_000,1589+    });1590+    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));1591+    await triggerSyncIfReachable({ reason: "manual" });1592+1593+    await vault.lock();1594+    expect(await vault.unlock("decoy-pass-67890")).toBe(true);1595+    await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toEqual({1596+      ok: true,1597+      data: { devices: [] },1598+    });1599+    await expect(dispatch({ type: "GET_SYNC_STATE" })).resolves.toEqual({1600+      ok: true,1601+      data: { status: "idle" },1602+    });1603+    expect((await listPairedDevices()).map((device) => device.deviceName)).toEqual(["Real Pixel"]);1604+  });1605+1606+  it("PB-26-104: real unlocked pairing messages retain the device list", async () => {1607+    const vault = await import("./vault");1608+    const { upsertPairedDevice } = await import("./paired-devices");1609+    await vault.createVault("correct-pass-12345");1610+    await upsertPairedDevice({1611+      deviceId: "android-real",1612+      deviceName: "Real Pixel",1613+      deviceType: "android",1614+      longTermPublicKeyHex: "a".repeat(64),1615+      trustSource: "direct",1616+      pairedAt: 1_700_000_000_000,1617+    });1618+1619+    await expect(dispatch({ type: "LIST_PAIRED_DEVICES" })).resolves.toMatchObject({1620+      ok: true,1621+      data: { devices: [{ deviceId: "android-real", deviceName: "Real Pixel" }] },1622+    });1623+  });15771624 15781625   async function seedPendingVouched(deviceId: string) {15791626     await chrome.storage.local.set({

view file

src/background/messages.ts+14 −0
@@ -1615,11 +1615,13 @@16151615       }16161616 16171617       case "LIST_PAIRED_DEVICES": {1618+        if (vault.isDecoyMode()) return { ok: true, data: { devices: [] } };16181619         const devices = await listPairedDevicesV3();16191620         return { ok: true, data: { devices } };16201621       }16211622 16221623       case "RENAME_PAIRED_DEVICE": {1624+        if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };16231625         const { deviceId, name } = (msg.payload ?? {}) as { deviceId?: string; name?: string };16241626         if (typeof deviceId !== "string" || !deviceId || typeof name !== "string") {16251627           return { ok: false, error: "invalid_payload" };@@ -1629,6 +1631,7 @@16291631       }16301632 16311633       case "UNPAIR_DEVICE": {1634+        if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };16321635         const { deviceId } = (msg.payload ?? {}) as { deviceId?: string };16331636         if (typeof deviceId !== "string" || !deviceId) {16341637           return { ok: false, error: "invalid_payload" };@@ -1658,6 +1661,7 @@16581661       }16591662 16601663       case "RENAME_LOCAL_DEVICE": {1664+        if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };16611665         const { name } = (msg.payload ?? {}) as { name?: string };16621666         if (typeof name !== "string") return { ok: false, error: "invalid_payload" };16631667         await setDeviceNameV3(name);@@ -1730,6 +1734,7 @@17301734       }17311735 17321736       case "TRIGGER_SYNC": {1737+        if (vault.isDecoyMode()) return { ok: true };17331738         const payload = (msg.payload ?? {}) as {17341739           reason?: "vault_change" | "foreground" | "manual";17351740           force?: boolean;@@ -1741,14 +1746,17 @@17411746       }17421747 17431748       case "GET_SYNC_STATE": {1749+        if (vault.isDecoyMode()) return { ok: true, data: { status: "idle" } };17441750         return { ok: true, data: getSyncState() };17451751       }17461752 17471753       case "GET_SYNC_SETTINGS": {1754+        if (vault.isDecoyMode()) return { ok: true, data: { mode: "off", relayUrl: "" } };17481755         return { ok: true, data: await getSyncSettings() };17491756       }17501757 17511758       case "SET_SYNC_SETTINGS": {1759+        if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };17521760         const payload = (msg.payload ?? {}) as {17531761           mode?: unknown;17541762           relayUrl?: unknown;@@ -1777,7 +1785,9 @@17771785         }17781786       }17791787 1788+17801789       case "GET_SYNC_ALERTS": {1790+        if (vault.isDecoyMode()) return { ok: true, data: [] };17811791         const { consume } = (msg.payload ?? {}) as { consume?: boolean };17821792         const alerts = await getPendingSyncAlerts();17831793         // The popup reads on open and acknowledges with consume:true after@@ -1788,9 +1798,11 @@17881798       }17891799 17901800       case "GET_PENDING_VOUCHED_DEVICES": {1801+        if (vault.isDecoyMode()) return { ok: true, data: [] };17911802         return { ok: true, data: await listPendingVouchedDevices() };17921803       }17931804       case "CONFIRM_VOUCHED_DEVICE": {1805+        if (vault.isDecoyMode()) return { ok: false, error: "vault_locked" };17941806         const { deviceId } = msg.payload as { deviceId: string };17951807         const pending = await getPendingVouchedDevice(deviceId);17961808         if (!pending) return { ok: false, error: "not_pending" };@@ -1819,10 +1831,12 @@18191831         return { ok: true };18201832       }18211833       case "REJECT_VOUCHED_DEVICE": {1834+        if (vault.isDecoyMode()) return { ok: true };18221835         const { deviceId } = msg.payload as { deviceId: string };18231836         await removePendingVouchedDevice(deviceId);18241837         return { ok: true };18251838       }1839+18261840       case "ROTATE_SYNC_IDENTITY": {18271841         if (!vault.isUnlocked()) return { ok: false, error: "vault_locked" };18281842         const result = await vault.rotateSyncIdentity();

view file

src/background/paired-devices.test.ts+46 −8
@@ -17,6 +17,7 @@1717   setLastSyncAt,1818   setRelayUrlForAllPairedDevices,1919   upsertPairedDevice,20+  validatePairedDeviceIdentities,2021 } from "./paired-devices";2122 2223 function makeStorageStub() {@@ -55,6 +56,7 @@5556   deviceName: "June's Pixel 9",5657   deviceType: "android",5758   longTermPublicKeyHex: "0".repeat(64),59+  signingPublicKeyHex: "1".repeat(64),5860   pairedAt: 1700000000000,5961 };6062 @@ -66,12 +68,17 @@6668     expect(await listPairedDevices()).toEqual([SAMPLE]);6769   });6870 69-  it("upsert is idempotent on the same deviceId — updates in place, no duplicate", async () => {71+  it("upsert is idempotent on the same deviceId — updates metadata in place, no duplicate", async () => {7072     await upsertPairedDevice(SAMPLE);71-    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel" });73+    await upsertPairedDevice({ ...SAMPLE, deviceName: "Renamed Pixel", pairedAt: 1700000001000 });7274     const all = await listPairedDevices();7375     expect(all).toHaveLength(1);74-    expect(all[0].deviceName).toBe("Renamed Pixel");76+    expect(all[0]).toMatchObject({77+      deviceName: "Renamed Pixel",78+      pairedAt: 1700000001000,79+      longTermPublicKeyHex: SAMPLE.longTermPublicKeyHex,80+      signingPublicKeyHex: SAMPLE.signingPublicKeyHex,81+    });7582   });7683 7784   it("stores a local alias without changing the peer-reported name or identity", async () => {@@ -92,14 +99,45 @@9299     await expect(renamePairedDevice("missing", "Phone")).rejects.toThrow(/not_found/);93100   });94101 95-  it("upsert with a new longTermPublicKeyHex on re-pair replaces the old key", async () => {102+  it("rejects a same-deviceId long-term key replacement and leaves the stored row unchanged", async () => {96103     await upsertPairedDevice(SAMPLE);97-    const newPubkey = "f".repeat(64);98-    await upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: newPubkey });99-    const found = await findPairedDeviceById(SAMPLE.deviceId);100-    expect(found?.longTermPublicKeyHex).toBe(newPubkey);104+    const before = await findPairedDeviceById(SAMPLE.deviceId);105+    await expect(106+      upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: "f".repeat(64) }),107+    ).rejects.toThrow("device_identity_immutable");108+    expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);101109   });102110 111+  it("rejects a same-deviceId signing key replacement and leaves the stored row unchanged", async () => {112+    await upsertPairedDevice(SAMPLE);113+    const before = await findPairedDeviceById(SAMPLE.deviceId);114+    await expect(115+      upsertPairedDevice({ ...SAMPLE, signingPublicKeyHex: "2".repeat(64) }),116+    ).rejects.toThrow("device_identity_immutable");117+    expect(await findPairedDeviceById(SAMPLE.deviceId)).toEqual(before);118+  });119+  it("preserves an existing signing key when an incoming row omits or nulls it", async () => {120+    await upsertPairedDevice(SAMPLE);121+    await upsertPairedDevice({ ...SAMPLE, deviceName: "Updated Pixel", signingPublicKeyHex: undefined });122+    await upsertPairedDevice({123+      ...SAMPLE,124+      deviceName: "Updated Again",125+      signingPublicKeyHex: null as unknown as string,126+    });127+    expect((await findPairedDeviceById(SAMPLE.deviceId))?.signingPublicKeyHex).toBe(128+      SAMPLE.signingPublicKeyHex,129+    );130+  });131+132+  it("rejects duplicate manifest identities before any registry mutation", async () => {133+    await expect(134+      validatePairedDeviceIdentities([135+        { ...SAMPLE, deviceName: "First" },136+        { ...SAMPLE, deviceName: "Second", longTermPublicKeyHex: "f".repeat(64) },137+      ]),138+    ).rejects.toThrow("device_identity_immutable");139+    expect(await listPairedDevices()).toEqual([]);140+  });103141   it("rejects upsert with missing fields", async () => {104142     await expect(upsertPairedDevice({ ...SAMPLE, deviceId: "" })).rejects.toThrow(/deviceId/);105143     await expect(upsertPairedDevice({ ...SAMPLE, longTermPublicKeyHex: undefined })).rejects.toThrow(/longTermPublicKeyHex/);

view file

src/background/paired-devices.ts+177 −25
@@ -6,9 +6,8 @@66 // Google's or Mozilla's sync back-end violates the local-only trust model.77 //88 // `deviceId` is the canonical primary key (UUID v4, stable for the9-// lifetime of the installation). `longTermPublicKeyHex` is also unique10-// per device but rotates if the user reinstalls before re-pairing, so we11-// key on deviceId and update the key in place during re-pair.9+// lifetime of the installation). Its cryptographic identity keys are10+// immutable once persisted; a direct re-pair must use a fresh deviceId.1211 //1312 // The registry has only ever lived in `chrome.storage.local` for released1413 // builds, so no cross-profile evacuation path is needed — the public keys@@ -90,33 +89,111 @@9089 }9190 9291 /**93- * Add or update a device by `deviceId`. On re-pair the new pubkey94- * silently replaces the old one; the deviceId is stable across the95- * other device's reinstall.92+ * Add or update a device by `deviceId`. Existing cryptographic identity keys93+ * are immutable; metadata-only updates merge into the stored row.9694  */9795 export async function upsertPairedDevice(device: PairedDevice): Promise<void> {9896   if (!device.deviceId) throw new Error("device.deviceId is required");9997   if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");10098   if (!device.deviceName) throw new Error("device.deviceName is required");10199   if (!device.deviceType) throw new Error("device.deviceType is required");100+  await upsertPairedDevices([device]);101+}102102 103-  // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile104-  // peer cannot grow the registry without limit; the local name already105-  // obeys the same bound at the Settings write path.103+export async function upsertPairedDevices(devices: PairedDevice[]): Promise<void> {106104   await enqueueDeviceMutation(async () => {107-    const stored = { ...device, deviceName: capDeviceName(device.deviceName!) };108-    const devices = await listPairedDevicesRaw();109-    const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);110-    if (idx >= 0) devices[idx] = { ...devices[idx], ...stored };111-    else devices.push(stored);112-    await writePairedDevicesRaw(devices);105+    const stored = await listPairedDevicesRaw();106+    const incomingById = new Map<string, PairedDevice>();107+    for (const device of devices) {108+      if (!device.deviceId) throw new Error("device.deviceId is required");109+      if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");110+      if (!device.deviceName) throw new Error("device.deviceName is required");111+      if (!device.deviceType) throw new Error("device.deviceType is required");112+      const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };113+      const previous = incomingById.get(normalized.deviceId);114+      if (previous) {115+        assertIdentityBinding(previous, normalized);116+        throw new Error("device_identity_duplicate");117+      }118+      incomingById.set(normalized.deviceId, normalized);119+      const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);120+      if (existing) assertIdentityBinding(existing, normalized);121+    }122+    for (const device of incomingById.values()) {123+      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);124+      if (index >= 0) {125+        const existing = stored[index];126+        const merged = { ...existing, ...device };127+        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {128+          merged.signingPublicKeyHex = existing.signingPublicKeyHex;129+        }130+        stored[index] = merged;131+      } else {132+        stored.push(device);133+      }134+    }135+    await writePairedDevicesRaw(stored);113136   });114-  // The device-list-changed sync trigger fires from the messages.ts handlers115-  // (IMPORT_PAIRED_DEVICE / CONFIRM_VOUCHED_DEVICE / UNPAIR_DEVICE) — they116-  // own the post-mutation contract and have the full payload available. Firing117-  // here would race with the test setup pattern (setSyncRunner AFTER upsert).118137 }119138 139+/** Validate identity bindings without changing the registry. */140+export async function validatePairedDeviceIdentities(devices: PairedDevice[]): Promise<void> {141+  await enqueueDeviceMutation(async () => {142+    const stored = await listPairedDevicesRaw();143+    const incomingById = new Map<string, PairedDevice>();144+    for (const device of devices) {145+      const previous = incomingById.get(device.deviceId);146+      if (previous) {147+        assertIdentityBinding(previous, device);148+        throw new Error("device_identity_duplicate");149+      }150+      incomingById.set(device.deviceId, device);151+      const existing = stored.find((candidate) => candidate.deviceId === device.deviceId);152+      if (existing) assertIdentityBinding(existing, device);153+    }154+  });155+}156+157+function validateAndIndexDevices(158+  stored: PairedDevice[],159+  devices: PairedDevice[],160+): Map<string, PairedDevice> {161+  const incomingById = new Map<string, PairedDevice>();162+  for (const device of devices) {163+    if (!device.deviceId) throw new Error("device.deviceId is required");164+    if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");165+    if (!device.deviceName) throw new Error("device.deviceName is required");166+    if (!device.deviceType) throw new Error("device.deviceType is required");167+    const normalized = { ...device, deviceName: capDeviceName(device.deviceName) };168+    const previous = incomingById.get(normalized.deviceId);169+    if (previous) {170+      assertIdentityBinding(previous, normalized);171+      throw new Error("device_identity_duplicate");172+    }173+    incomingById.set(normalized.deviceId, normalized);174+    const existing = stored.find((candidate) => candidate.deviceId === normalized.deviceId);175+    if (existing) assertIdentityBinding(existing, normalized);176+  }177+  return incomingById;178+}179+180+function assertIdentityBinding(existing: PairedDevice, incoming: PairedDevice): void {181+  if (182+    typeof existing.longTermPublicKeyHex === "string" &&183+    typeof incoming.longTermPublicKeyHex === "string" &&184+    !constantTimeEqualHex(existing.longTermPublicKeyHex, incoming.longTermPublicKeyHex)185+  ) {186+    throw new Error("device_identity_immutable");187+  }188+  if (189+    typeof existing.signingPublicKeyHex === "string" &&190+    typeof incoming.signingPublicKeyHex === "string" &&191+    !constantTimeEqualHex(existing.signingPublicKeyHex, incoming.signingPublicKeyHex)192+  ) {193+    throw new Error("device_identity_immutable");194+  }195+}196+120197 export async function renamePairedDevice(deviceId: string, name: string): Promise<void> {121198   const alias = name.trim();122199   if (!alias) throw new Error("device name must not be empty");@@ -269,13 +346,88 @@269346  * re-pair — a physically present user on both devices. Never call this from270347  * a path a remote peer can reach.271348  */272-export async function clearDeviceRevocation(deviceId: string): Promise<void> {349+export async function upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void> {350+  if (!device.deviceId) throw new Error("device.deviceId is required");351+  if (!device.longTermPublicKeyHex) throw new Error("device.longTermPublicKeyHex is required");352+  if (!device.deviceName) throw new Error("device.deviceName is required");353+  if (!device.deviceType) throw new Error("device.deviceType is required");273354   await enqueueDeviceMutation(async () => {274-  const existing = await listDeviceRevocationsRaw();275-  const filtered = existing.filter((r) => r.deviceId !== deviceId);276-  if (filtered.length !== existing.length) {277-    await storeRevocationsRaw(filtered);278-  }355+    const devices = await listPairedDevicesRaw();356+    const stored = devices.find((candidate) => candidate.deviceId === device.deviceId);357+    const incoming = { ...device, deviceName: capDeviceName(device.deviceName!) };358+    if (stored) assertIdentityBinding(stored, incoming);359+    const index = devices.findIndex((candidate) => candidate.deviceId === device.deviceId);360+    if (index >= 0) {361+      const merged = { ...devices[index], ...incoming };362+      if (typeof devices[index].signingPublicKeyHex === "string" && incoming.signingPublicKeyHex == null) {363+        merged.signingPublicKeyHex = devices[index].signingPublicKeyHex;364+      }365+      devices[index] = merged;366+    } else {367+      devices.push(incoming);368+    }369+    const revocations = await listDeviceRevocationsRaw();370+    const filtered = revocations.filter((record) => record.deviceId !== device.deviceId);371+    await chrome.storage.local.set({372+      [REGISTRY_KEY]: scrubRegistryRows(devices),373+      [REVOCATIONS_KEY]: capRevocations(filtered),374+    });375+  });376+}377+378+export async function upsertPairedDevicesAndMergeRevocations(379+  devices: PairedDevice[],380+  incomingRevocations: DeviceRevocation[],381+): Promise<string[]> {382+  return enqueueDeviceMutation(async () => {383+    const stored = await listPairedDevicesRaw();384+    const incomingById = validateAndIndexDevices(stored, devices);385+    const existingRevocations = await listDeviceRevocationsRaw();386+    const knownById = new Set(existingRevocations.map((record) => record.deviceId));387+    const fresh = incomingRevocations388+      .filter((record) => !knownById.has(record.deviceId))389+      .map((record) => ({ ...record, revokedAt: clampSyncTimestamp(record.revokedAt) }));390+    const allRevocations = [...existingRevocations, ...fresh];391+    const revokedIds = new Set(allRevocations.map((record) => record.deviceId));392+    for (const device of incomingById.values()) {393+      const index = stored.findIndex((candidate) => candidate.deviceId === device.deviceId);394+      if (index >= 0) {395+        const existing = stored[index];396+        const merged = { ...existing, ...device };397+        if (typeof existing.signingPublicKeyHex === "string" && device.signingPublicKeyHex == null) {398+          merged.signingPublicKeyHex = existing.signingPublicKeyHex;399+        }400+        stored[index] = merged;401+      } else {402+        stored.push(device);403+      }404+    }405+    const revokedKeys = new Set(406+      allRevocations407+        .map((record) => record.longTermPublicKeyHex.toLowerCase())408+        .filter((key) => key !== "0".repeat(64)),409+    );410+    const registry = scrubRegistryRows(411+      stored.filter(412+        (device) =>413+          !revokedIds.has(device.deviceId) &&414+          !revokedKeys.has(device.longTermPublicKeyHex?.toLowerCase() ?? ""),415+      ),416+    );417+    await chrome.storage.local.set({418+      [REGISTRY_KEY]: registry,419+      [REVOCATIONS_KEY]: capRevocations(allRevocations),420+    });421+    return fresh.map((record) => record.deviceId);422+  });423+}424+425+function scrubRegistryRows(devices: PairedDevice[]): PairedDevice[] {426+  return devices.map((entry) => {427+    if (!isValidSecret(entry.rendezvousSecretHex)) return entry;428+    const scrubbed = { ...entry };429+    delete scrubbed.rendezvousSecretHex;430+    return scrubbed;279431   });280432 }281433 

view file

src/background/relay-adoption-bg.test.ts+1 −0
@@ -107,6 +107,7 @@107107 108108 vi.mock("./paired-devices", () => ({109109   upsertPairedDevice: mocks.upsert,110+  upsertPairedDeviceAndClearRevocation: mocks.upsert,110111   clearDeviceRevocation: mocks.clearRevocation,111112   hasAdoptionIdentityConflict: mocks.hasIdentityConflict,112113 }));

view file

src/background/relay-adoption-bg.ts+4 −6
@@ -47,7 +47,7 @@4747 import { computeVaultId } from "../shared/sync-identity";4848 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";4949 import { storageRemove, storageSet } from "../shared/storage";50-import { markLastSyncedAt, upsertPairedDevice, clearDeviceRevocation, hasAdoptionIdentityConflict } from "./paired-devices";50+import { markLastSyncedAt, upsertPairedDeviceAndClearRevocation, hasAdoptionIdentityConflict } from "./paired-devices";5151 import { isValidPairSecret, setPairSecret } from "./pair-secrets";5252 import { createSyncVaultAdapter } from "./sync-vault-adapter";5353 import { triggerSyncIfReachable } from "./sync-trigger";@@ -708,14 +708,12 @@708708  * cannot recover this secret — re-pair is the fail-closed path.709709  */710710 async function storeAdoptedDevice(paired: PairedDevice): Promise<void> {711-  // SAS-confirmed direct adoption — the one consent signal strong enough to712-  // lift a terminal revocation. Clear before upsert so storage never exposes713-  // the device as both paired and revoked, even transiently.714-  await clearDeviceRevocation(paired.deviceId);715711   const secret = paired.rendezvousSecretHex;716712   const pub = { ...paired };717713   delete pub.rendezvousSecretHex;718-  await upsertPairedDevice(pub);714+  // Validate, persist, and clear the terminal tombstone under one registry715+  // mutation so a concurrent revoke cannot recreate or lose the tombstone.716+  await upsertPairedDeviceAndClearRevocation(pub);719717   if (!isValidPairSecret(secret)) return;720718   const masterKeyHex = getRootKeyHex();721719   if (!masterKeyHex) return;

view file

src/background/sync-runner.ts+12 −1
@@ -26,7 +26,7 @@2626 import { hexToPubkey } from "../shared/sync-identity";2727 import { buildLocalIdentity, deriveLocalSyncKeypair } from "./device-identity";2828 import { constantTimeEqualHex } from "../shared/constant-time";29-import { getRootKeyHex, isUnlocked } from "./vault";29+import { getRootKeyHex, isUnlocked, isDecoyMode } from "./vault";3030 import { getPairSecret } from "./pair-secrets";3131 import type { PairedDevice } from "../shared/sync-types";3232 import type { SyncRunResult } from "./sync-trigger";@@ -58,6 +58,14 @@5858   createAdapter: (context) => createSyncVaultAdapter(context),5959 };6060 61+const activeInitiatorSessions = new Set<SyncSession>();62+63+/** Close initiator sessions that survived into a decoy-vault transition. */64+export function abortSyncRunnerSessions(): void {65+  for (const session of activeInitiatorSessions) void session.close().catch(() => undefined);66+  activeInitiatorSessions.clear();67+}68+6169 export async function productionSyncRunner(device: PairedDevice): Promise<SyncRunResult> {6270   return runWithDeps(device, productionDeps);6371 }@@ -132,12 +140,14 @@132140       reconnectIds,133141       deps.openRelaySession,134142     );143+    activeInitiatorSessions.add(session);135144   } finally {136145     masterKey?.fill(0);137146     privateKey?.fill(0);138147   }139148 140149   try {150+    if (isDecoyMode()) throw new Error("vault_locked");141151     const outcome = await runSyncInitiator(session, adapter);142152     if (outcome.status !== "completed") {143153       console.info(@@ -161,6 +171,7 @@161171     }162172     throw new Error(outcome.error ?? "sync_errored");163173   } finally {174+    activeInitiatorSessions.delete(session);164175     // The coordinator does NOT close the session (it returns and leaves the165176     // session open). This runner is the sole owner of session lifetime.166177     await session.close().catch(() => undefined);

view file

src/background/sync-trigger.ts+24 −9
@@ -10,6 +10,8 @@1010 // can be unit-tested without WebSocket/Noise transport. Production wires1111 // in `productionSyncRunner` from `./sync-runner` at module load.1212 13+import * as vault from "./vault";14+1315 import {1416   getLastSyncAt,1517   getLastSyncResult,@@ -142,11 +144,11 @@142144     }143145   }144146 }145-146147 export async function triggerSyncIfReachable(opts: {147148   reason: SyncTriggerReason;148149   force?: boolean;149150 }): Promise<void> {151+  if (vault.isDecoyMode()) return;150152   const state = getSyncTriggerState();151153   // Claim the guard synchronously before any await, otherwise two152154   // concurrent triggers could both pass the gate before either reached@@ -234,7 +236,9 @@234236       }235237       entriesReceived += result.entriesReceived;236238       entriesSent += result.entriesSent;239+      if (vault.isDecoyMode()) return;237240       await markLastSyncedAt(host.deviceId, Date.now());241+      if (vault.isDecoyMode()) return;238242       anySucceeded = true;239243     } catch (err) {240244       const message = (err as Error)?.message ?? String(err);@@ -243,6 +247,7 @@243247     }244248   }245249 250+  if (vault.isDecoyMode()) return;246251   if (anySucceeded) {247252     const now = Date.now();248253     finish({@@ -255,7 +260,12 @@255260     });256261     // Persist so the popup's "last synced" indicator survives a SW restart.257262     // Best-effort — storage failures must not surface as sync failures.258-    void setLastSyncAt(now).catch(() => undefined);263+    void Promise.resolve()264+      .then(() => {265+        if (vault.isDecoyMode()) return;266+        return setLastSyncAt(now);267+      })268+      .catch(() => undefined);259269   } else {260270     const error = firstError ?? "no_host_reachable";261271     finish({ outcome: classifySyncError(error), detail: error });@@ -325,13 +335,18 @@325335         ? ` (sent ${entriesSent}, received ${entriesReceived})`326336         : ""),327337   );328-  void setLastSyncResult({329-    outcome: result.outcome,330-    detail: result.detail,331-    at: now,332-    entriesReceived,333-    entriesSent,334-  }).catch(() => undefined);338+  void Promise.resolve()339+    .then(() => {340+      if (vault.isDecoyMode()) return;341+      return setLastSyncResult({342+        outcome: result.outcome,343+        detail: result.detail,344+        at: now,345+        entriesReceived,346+        entriesSent,347+      });348+    })349+    .catch(() => undefined);335350 }336351 337352 /** What the eligibility filter actually saw. Names, not just counts. */

view file

src/background/sync-vault-adapter.test.ts+37 −72
@@ -395,8 +395,8 @@395395           deviceId: "android-existing",396396           deviceName: "Existing Pixel renamed",397397           deviceType: "android",398-          longTermPublicKeyHex: "f".repeat(64),399-          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),398+          longTermPublicKeyHex: "d".repeat(64),399+          signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),400400           wsPort: 52345,401401           relayUrl: MESH_RELAY,402402           pairedAt: 1_700_000_000_030,@@ -425,13 +425,12 @@425425     const pending = await listPendingVouchedDevices();426426     expect(pending).toEqual([]);427427 428-    // The already-paired device is still updated in place — but S5: the429-    // third-party issuer does NOT get to rename it. Keys and ports propagate;430-    // the locally-known name is what the user recognises and it stands.428+    // Immutable identity binding rejects remote key rewrites; metadata and429+    // routing fields from the signed manifest still update.431430     const existing = await findPairedDeviceById("android-existing");432431     expect(existing).toMatchObject({433432       deviceName: "Existing Pixel",434-      longTermPublicKeyHex: "f".repeat(64),433+      longTermPublicKeyHex: "d".repeat(64),435434       trustSource: "direct",436435       wsPort: 52345,437436     });@@ -442,13 +441,11 @@442441     ]);443442   });444443 445-  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {444+  it("rejects manifest key replacement for an existing device", async () => {446445     const { adapter } = await setupVault();447446     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");448447     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");449-    const { upsertPairedDevice } = await import("./paired-devices");450-    const { getPendingSyncAlerts } = await import("./sync-alerts");451-448+    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");452449     const issuerId = "extension-issuer";453450     const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);454451     await upsertPairedDevice({@@ -469,49 +466,32 @@469466       pairedAt: 2,470467       trustSource: "direct",471468     });472-473469     const manifest = await buildSignedDeviceManifest(474470       issuerId,475-      [476-        {477-          deviceId: "android-existing",478-          deviceName: "Pixel",479-          deviceType: "android",480-          longTermPublicKeyHex: "f".repeat(64),481-          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),482-          wsPort: 52345,483-          pairedAt: 3,484-        },485-      ],471+      [{472+        deviceId: "android-existing",473+        deviceName: "Pixel",474+        deviceType: "android",475+        longTermPublicKeyHex: "f".repeat(64),476+        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),477+        wsPort: 52345,478+        pairedAt: 3,479+      }],486480       issuerKp.privateKey,487481     );488-489-    await adapter.applyIncomingDeviceManifest?.(490-      manifest,491-      new Uint8Array(32).fill(0xcc),492-    );493-    // recordSyncAlerts is fire-and-forget — flush microtasks.494-    for (let i = 0; i < 25; i++) await Promise.resolve();495-496-    const alerts = await getPendingSyncAlerts();497-    expect(498-      alerts.some(499-        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",500-      ),501-    ).toBe(true);482+    await expect(483+      adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),484+    ).rejects.toThrow("device_identity_immutable");485+    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({486+      longTermPublicKeyHex: "d".repeat(64),487+    });502488   });503489 504-  it("S5: a rotation alert names the device from local state, not from the manifest", async () => {505-    // A paired-but-hostile issuer relabels a device it is vouching for, hoping506-    // the rotation alert reads as coming from a device the user trusts more.507-    // The alert must show the name the user already knows, and the relabel508-    // must not stick in the registry either.490+  it("rejects manifest replacement even when the device name changes", async () => {509491     const { adapter } = await setupVault();510492     const { buildSignedDeviceManifest } = await import("../shared/device-manifest");511493     const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");512494     const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");513-    const { getPendingSyncAlerts } = await import("./sync-alerts");514-515495     const issuerId = "hostile-issuer";516496     const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);517497     await upsertPairedDevice({@@ -532,40 +512,25 @@532512       pairedAt: 2,533513       trustSource: "direct",534514     });535-536515     const manifest = await buildSignedDeviceManifest(537516       issuerId,538-      [539-        {540-          deviceId: "android-existing",541-          deviceName: "June's Primary Phone",542-          deviceType: "android",543-          longTermPublicKeyHex: "f".repeat(64),544-          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),545-          wsPort: 52345,546-          pairedAt: 3,547-        },548-      ],517+      [{518+        deviceId: "android-existing",519+        deviceName: "June's Primary Phone",520+        deviceType: "android",521+        longTermPublicKeyHex: "f".repeat(64),522+        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),523+        wsPort: 52345,524+        pairedAt: 3,525+      }],549526       issuerKp.privateKey,550527     );551-552-    await adapter.applyIncomingDeviceManifest?.(553-      manifest,554-      new Uint8Array(32).fill(0xcc),555-    );556-    for (let i = 0; i < 25; i++) await Promise.resolve();557-558-    const alerts = await getPendingSyncAlerts();559-    const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");560-    expect(rotation?.deviceName).toBe("Spare Tablet");561-    expect(rotation?.entryTitle).toBe("Spare Tablet");562-    expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);563-564-    // The key rotation itself still lands; only the name is refused.565-    const stored = await findPairedDeviceById("android-existing");566-    expect(stored).toMatchObject({528+    await expect(529+      adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),530+    ).rejects.toThrow("device_identity_immutable");531+    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({567532       deviceName: "Spare Tablet",568-      longTermPublicKeyHex: "f".repeat(64),533+      longTermPublicKeyHex: "d".repeat(64),569534     });570535   });571536 

view file

src/background/sync-vault-adapter.ts+60 −118
@@ -46,6 +46,7 @@4646   getEntryById,4747   getVaultVersion,4848   isUnlocked,49+  isDecoyMode,4950 } from "./vault";5051 import {5152   findPairedDeviceById,@@ -53,9 +54,7 @@5354   isDeviceRevoked,5455   listDeviceRevocations,5556   listPairedDevices,56-  mergeDeviceRevocations,57-  removePairedDeviceById,58-  upsertPairedDevice,57+  upsertPairedDevicesAndMergeRevocations,5958 } from "./paired-devices";6059 import { setPairSecret } from "./pair-secrets";6160 import {@@ -107,7 +106,7 @@107106     },108107 109108     async buildEntryManifest() {110-      requireUnlocked();109+      requireRealUnlocked();111110       const manifest: Record<string, number> = {};112111       for (const entry of getEntries()) {113112         manifest[entry.id] = entry.modifiedAt;@@ -116,12 +115,12 @@116115     },117116 118117     async buildTombstoneManifest() {119-      requireUnlocked();118+      requireRealUnlocked();120119       return getTombstoneManifest();121120     },122121 123122     async getEncryptedEntries(entryIds) {124-      requireUnlocked();123+      requireRealUnlocked();125124       const masterKey = requireMasterKey();126125       const out: Record<string, string> = {};127126       for (const id of entryIds) {@@ -134,9 +133,8 @@134133     },135134 136135     async applyEntries(entries) {137-      requireUnlocked();136+      requireRealUnlocked();138137       const entryList = Object.entries(entries);139-      // MV-3: bound the batch before any per-entry crypto work.140138       if (entryList.length > SYNC_LIMITS.MAX_ENTRY_COUNT) {141139         throw new Error(142140           `sync entry count ${entryList.length} exceeds limit ${SYNC_LIMITS.MAX_ENTRY_COUNT}`,@@ -145,32 +143,23 @@145143       const masterKey = requireMasterKey();146144       const decrypted: VaultEntry[] = [];147145       for (const [id, envelope] of entryList) {148-        // MV-3: per-entry ceiling, checked on the (base64) envelope before149-        // decrypting. Base64 inflates by 4/3, so allow that factor.150-        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) {151-          continue; // oversized envelope — skip, never decrypt152-        }146+        if (envelope.length > (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64) continue;153147         const key = await deriveEntryKey(masterKey, id);154148         const entry = await decryptEntryOrNull(key, envelope);155149         if (!entry) continue;156-        // MV-3: defense in depth — re-measure the decrypted plaintext.157-        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) {158-          continue;159-        }160-        // The map key is the authority on id — refuse mismatched envelopes161-        // so a swapped envelope can't poison a different entry slot.150+        if (JSON.stringify(entry).length > SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES) continue;162151         if (entry.id !== id) continue;163152         decrypted.push(entry);164153       }165154       if (decrypted.length > 0) {155+        requireRealUnlocked();166156         await applyMergedRemoteEntries(decrypted, {});167157       }168158     },169159 170160     async applyTombstones(tombstones) {171-      requireUnlocked();161+      requireRealUnlocked();172162       const tombstoneList = Object.entries(tombstones);173-      // MV-3: bound the tombstone batch before processing.174163       if (tombstoneList.length > SYNC_LIMITS.MAX_TOMBSTONE_COUNT) {175164         throw new Error(176165           `sync tombstone count ${tombstoneList.length} exceeds limit ${SYNC_LIMITS.MAX_TOMBSTONE_COUNT}`,@@ -179,19 +168,17 @@179168       const cleaned: Record<string, number> = {};180169       for (const [id, ts] of tombstoneList) {181170         if (typeof ts === "number" && Number.isFinite(ts) && ts > 0) {182-          // HV-1c: clamp before the value participates in any comparison.183-          // vault.ts clamps again defensively; clamping here keeps the184-          // invariant local to the boundary where peer data enters.185171           cleaned[id] = clampSyncTimestamp(ts);186172         }187173       }188174       if (Object.keys(cleaned).length > 0) {175+        requireRealUnlocked();189176         await applyMergedRemoteEntries([], cleaned);190177       }191178     },192179 193180     async buildOutgoingDeviceManifest() {194-      requireUnlocked();181+      requireRealUnlocked();195182       const masterKey = requireMasterKey();196183       const issuerDeviceId = await getOrCreateDeviceId();197184       const signing = await deriveSyncSigningKeypair(masterKey, issuerDeviceId);@@ -210,7 +197,7 @@210197     },211198 212199     async applyIncomingDeviceManifest(manifest, authenticatedRemoteStaticPublicKey) {213-      requireUnlocked();200+      requireRealUnlocked();214201       await applyVerifiedPeerManifest(215202         manifest,216203         authenticatedRemoteStaticPublicKey,@@ -370,59 +357,19 @@370357   const ourDeviceId = await getOrCreateDeviceId();371358   const masterKey = requireMasterKey();372359   const localKeys = deriveSyncIdentityKeypair(masterKey, ourDeviceId);373-374360   try {375-    // Revocations are absorbed BEFORE any vouching, so a manifest that both376-    // revokes and re-vouches the same device in one payload cannot race itself.377-    // A peer may not revoke US — that would let one compromised device sever the378-    // whole mesh — so our own id is filtered out first.379-    const newlyRevoked = await mergeDeviceRevocations(380-      body.revoked.filter((r) => r.deviceId !== ourDeviceId),381-    );382-    for (const deviceId of newlyRevoked) {383-      await removePairedDeviceById(deviceId);384-      await removePendingVouchedDevice(deviceId);385-      void recordSyncAlerts([386-        {387-          type: "DEVICE_REVOKED",388-          entryId: "",389-          entryTitle: deviceId,390-          deviceName: issuer.deviceName ?? "paired device",391-          timestamp: Date.now(),392-        },393-      ]);394-    }395-361+    const pendingRegistry: PairedDevice[] = [];362+    const pendingSecrets: { deviceId: string; secret: string }[] = [];363+    const pendingVouchedIds: string[] = [];396364     for (const entry of body.devices) {397-      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) {398-        continue;399-      }400-      // Terminal: no manifest re-admits a revoked device. Matched on the key as401-      // well as the id, because the id is the issuer's to choose — checking only402-      // the id let a revoked key walk back in under a fresh one. The check sits403-      // ahead of the existing-device lookup so it also refuses rotating a device404-      // we still trust onto a revoked key.405-      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) {406-        continue;407-      }408-      if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) {409-        continue;410-      }365+      if (entry.deviceId === ourDeviceId || entry.deviceId === issuer.deviceId) continue;366+      if (await isDeviceRevoked(entry.deviceId, entry.longTermPublicKeyHex)) continue;367+      if ((entry.vouchDepth ?? 0) > (issuer.vouchDepth ?? 0) + 1) continue;411368       const existing = await findPairedDeviceById(entry.deviceId);412-      const meshRoute = deriveManifestMeshRoute(413-        masterKey,414-        localKeys.publicKey,415-        entry,416-      );417-418-      // A v3 manifest from an admitted, KK-authenticated issuer proves419-      // same-vault membership and carries enough route material for the two420-      // introduced endpoints to derive their own capability. Admit it421-      // immediately so a six-device vault converges without keeping the422-      // original pairing hub online.369+      const meshRoute = deriveManifestMeshRoute(masterKey, localKeys.publicKey, entry);423370       if (!existing) {424371         if (!meshRoute) continue;425-        await upsertPairedDevice({372+        pendingRegistry.push({426373           deviceId: entry.deviceId,427374           deviceName: entry.deviceName,428375           deviceType: entry.deviceType,@@ -434,50 +381,22 @@434381           trustSource: "vouched",435382           vouchDepth: (issuer.vouchDepth ?? 0) + 1,436383         });437-        // PB-26-213: the secret bypasses the public registry into the438-        // encrypted pair-secret record.439-        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);440-        await removePendingVouchedDevice(entry.deviceId);384+        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });385+        pendingVouchedIds.push(entry.deviceId);441386         continue;442387       }443-444-      // MV-8d: a key changing under a stable deviceId is legitimate after a445-      // reinstall, but it is exactly what a graph-poisoning attack looks like —446-      // signal the user either way, durably (the alert queue survives a closed447-      // popup) and live (the broadcast updates an open one).448-      if (449-        existing.longTermPublicKeyHex &&450-        !constantTimeEqualHex(451-          existing.longTermPublicKeyHex,452-          entry.longTermPublicKeyHex,453-        )454-      ) {455-        // S5: name the device from the LOCAL record, never from the manifest.456-        // The issuer is a third party here (its own entry is skipped above), so457-        // `entry.deviceName` is an unverified claim about someone else — an458-        // attacker who could put it on screen could make its own key rotation459-        // read as a rotation on a device the user trusts more.388+      if (existing.longTermPublicKeyHex && !constantTimeEqualHex(existing.longTermPublicKeyHex, entry.longTermPublicKeyHex)) {460389         const knownName = existing.deviceName ?? entry.deviceName;461-        notifyPopup({390+        notifyPopup({ type: "DEVICE_KEY_ROTATED", payload: { deviceName: knownName, deviceId: entry.deviceId } });391+        void recordSyncAlerts([{462392           type: "DEVICE_KEY_ROTATED",463-          payload: { deviceName: knownName, deviceId: entry.deviceId },464-        });465-        void recordSyncAlerts([466-          {467-            type: "DEVICE_KEY_ROTATED",468-            entryId: "",469-            entryTitle: knownName,470-            deviceName: knownName,471-            timestamp: Date.now(),472-          },473-        ]);393+          entryId: "",394+          entryTitle: knownName,395+          deviceName: knownName,396+          timestamp: Date.now(),397+        }]);474398       }475-476-      // Existing device — update in place as before. The name is deliberately477-      // NOT taken from the manifest: a device's name is set by that device at478-      // pairing, and letting a third-party issuer rewrite it is the rename-then-479-      // impersonate half of S5. Self-renames never arrive here anyway.480-      await upsertPairedDevice({399+      pendingRegistry.push({481400         ...existing,482401         deviceId: entry.deviceId,483402         deviceName: existing.deviceName ?? entry.deviceName,@@ -487,16 +406,35 @@487406         pairedAt: existing.pairedAt ?? entry.pairedAt,488407         trustSource: existing.trustSource,489408         wsPort: existing.wsPort ?? entry.wsPort,490-        relayUrl:491-          existing.trustSource === "vouched" && meshRoute492-            ? meshRoute.relayUrl493-            : existing.relayUrl,409+        relayUrl: existing.trustSource === "vouched" && meshRoute ? meshRoute.relayUrl : existing.relayUrl,494410         lastSyncedAt: existing.lastSyncedAt,495411       });496412       if (existing.trustSource === "vouched" && meshRoute) {497-        await setPairSecret(entry.deviceId, meshRoute.rendezvousSecretHex, masterKey);413+        pendingSecrets.push({ deviceId: entry.deviceId, secret: meshRoute.rendezvousSecretHex });498414       }499415     }416+    const newlyRevoked = await upsertPairedDevicesAndMergeRevocations(417+      pendingRegistry,418+      body.revoked.filter((r) => r.deviceId !== ourDeviceId),419+    );420+    for (const deviceId of newlyRevoked) {421+      await removePendingVouchedDevice(deviceId);422+      void recordSyncAlerts([{423+        type: "DEVICE_REVOKED",424+        entryId: "",425+        entryTitle: deviceId,426+        deviceName: issuer.deviceName ?? "paired device",427+        timestamp: Date.now(),428+      }]);429+    }430+    for (const pending of pendingSecrets) {431+      if (newlyRevoked.includes(pending.deviceId)) continue;432+      await setPairSecret(pending.deviceId, pending.secret, masterKey);433+    }434+    for (const deviceId of pendingVouchedIds) {435+      if (newlyRevoked.includes(deviceId)) continue;436+      await removePendingVouchedDevice(deviceId);437+    }500438   } finally {501439     masterKey.fill(0);502440     localKeys.secretKey.fill(0);@@ -539,6 +477,10 @@539477 540478 function requireUnlocked(): void {541479   if (!isUnlocked()) throw new VaultLockedError();480+}481+482+function requireRealUnlocked(): void {483+  if (!isUnlocked() || isDecoyMode()) throw new VaultLockedError();542484 }543485 544486 // Sync derives everything — vault_id, the Noise static key, the Ed25519

view file

src/background/vault.ts+11 −2
@@ -405,13 +405,16 @@405405 406406 let lockedSyncRefreshHook: (() => Promise<void>) | null = null;407407 let lockedSyncUnlockHook: (() => Promise<void>) | null = null;408+let decoySyncTeardownHook: (() => void) | null = null;408409 409410 export function setLockedSyncHooks(hooks: {410411   refresh: () => Promise<void>;411412   afterUnlock: () => Promise<void>;413+  onDecoy?: () => void;412414 }): void {413415   lockedSyncRefreshHook = hooks.refresh;414416   lockedSyncUnlockHook = hooks.afterUnlock;417+  decoySyncTeardownHook = hooks.onDecoy ?? null;415418 }416419 417420 async function refreshLockedSyncState(): Promise<void> {@@ -1532,6 +1535,7 @@15321535     }15331536     derivedKey = keyB;15341537     vaultMode = "decoy";1538+    decoySyncTeardownHook?.();15351539     entries = payloadB.entries;15361540     tombstones = payloadB.tombstones;15371541     duressConfigured = false;@@ -1702,6 +1706,7 @@17021706   }17031707   derivedKey = key;17041708   vaultMode = entry.mode;1709+  if (entry.mode === "decoy") decoySyncTeardownHook?.();17051710   entries = payload.entries;17061711   tombstones = payload.tombstones;17071712   duressConfigured = entry.mode === "real" ? payload.duressConfigured : false;@@ -1750,6 +1755,10 @@17501755 export function isUnlocked(): boolean {17511756   return derivedKey !== null;17521757 }1758+/** True only while the active session is unlocked through the decoy slot. */1759+export function isDecoyMode(): boolean {1760+  return derivedKey !== null && vaultMode === "decoy";1761+}17531762 17541763 // Opens the vault using a pre-derived AES-256 key (skipping Argon2id). The17551764 // PIN-unlock path unwraps this key from its PIN wrap; the passphrase path@@ -2526,7 +2535,7 @@25262535   // are dropped silently. This is the deliberate trade-off for a simple,25272536   // conflict-free merge — per-field CRDT merge is out of scope. Guidance for25282537   // users: edit a given entry on one device at a time.2529-  if (!derivedKey) throw new Error("vault_locked");2538+  if (!derivedKey || vaultMode === "decoy") throw new Error("vault_locked");25302539 25312540   const received = remoteEntries.length + Object.keys(remoteTombstones).length;25322541   let applied = 0;@@ -2599,7 +2608,7 @@25992608     }26002609   }26012610 2602-  if (applied > 0) {2611+  if (applied > 0 && vaultMode === "real") {26032612     await persistEntries("sync_received");26042613     buildDomainIndexSync(entries);26052614   }

view file