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
↓ peach-browser-086afa1e.tar.gzatlas at this save
files changed16 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 −2package.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",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;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 behindsrc/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();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 }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({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();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/);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 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 }));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;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);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. */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 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 Ed25519src/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 }