987624578e20

fix: Serialize encrypted pair-secret updates (v5.2.15)

time
2026-09-07 16:44 UTC · 1 month ago
machine
koh2
parent
689c06ca307b
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/pair-secrets.test.ts+14 −0src/background/pair-secrets.ts+81 −63
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.2.14",4+  "version": "5.2.15",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -2,7 +2,7 @@22   "manifest_version": 3,33   "minimum_chrome_version": "116",44   "name": "Peach Password Manager",5-  "version": "5.2.14",5+  "version": "5.2.15",66   "description": "Zero-knowledge password manager",77   "action": {88     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "5.2.14",3+  "version": "5.2.15",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/pair-secrets.test.ts+14 −0
@@ -78,6 +78,20 @@7878     expect(await getPairSecret("pixel-1", null)).toBe(SECRET_A);7979   });8080 81+  it("concurrent pair-secret writes preserve both devices", async () => {82+    const { setPairSecret, getPairSecret } = await import("./pair-secrets");83+84+    await Promise.all([85+      setPairSecret("pixel-1", SECRET_A, ROOT_KEY),86+      setPairSecret("pixel-2", SECRET_B, ROOT_KEY),87+    ]);88+89+    for (const key of Object.keys(session)) delete session[key];90+    const rootHex = Array.from(ROOT_KEY, (byte) => byte.toString(16).padStart(2, "0")).join("");91+    expect(await getPairSecret("pixel-1", rootHex)).toBe(SECRET_A);92+    expect(await getPairSecret("pixel-2", rootHex)).toBe(SECRET_B);93+  });94+8195   it("upsertPairedDevice never persists a rendezvous secret into the public registry", async () => {8296     const { listPairedDevices, upsertPairedDevice } = await import("./paired-devices");8397 

view file

src/background/pair-secrets.ts+81 −63
@@ -31,7 +31,17 @@3131 // so direct imports keep working; vault.applyStorageHardening sets the real3232 // value before the first restore.3333 let sessionAllowed = true;34+let mutationTail: Promise<void> = Promise.resolve();3435 36+function serializeMutation<T>(operation: () => Promise<T>): Promise<T> {37+  const result = mutationTail.then(operation, operation);38+  mutationTail = result.then(39+    () => undefined,40+    () => undefined,41+  );42+  return result;43+}44+3545 export function setSessionAllowed(allowed: boolean): void {3646   sessionAllowed = allowed;3747 }@@ -170,12 +180,14 @@170180   rootKey: Uint8Array,171181 ): Promise<void> {172182   if (!deviceId || !isValidPairSecret(secretHex)) throw new Error("pair_secret_invalid");173-  const map = await readEncryptedMap(rootKey);174-  map[deviceId] = secretHex;175-  await writeEncryptedMap(rootKey, map);176-  const cached = await readSessionCache();177-  cached[deviceId] = secretHex;178-  await writeSessionCache(cached);183+  await serializeMutation(async () => {184+    const map = await readEncryptedMap(rootKey);185+    map[deviceId] = secretHex;186+    await writeEncryptedMap(rootKey, map);187+    const cached = await readSessionCache();188+    cached[deviceId] = secretHex;189+    await writeSessionCache(cached);190+  });179191 }180192 181193 /**@@ -189,63 +201,67 @@189201 export async function reconcilePairSecrets(190202   rootKey: Uint8Array,191203 ): Promise<{ migrated: number }> {192-  const devices = await listPairedDevices();193-  const registryIds = new Set(devices.map((d) => d.deviceId));194-  const legacy = new Map<string, string>();195-  for (const device of devices) {196-    if (isValidPairSecret(device.rendezvousSecretHex)) {197-      legacy.set(device.deviceId, device.rendezvousSecretHex);204+  return serializeMutation(async () => {205+    const devices = await listPairedDevices();206+    const registryIds = new Set(devices.map((d) => d.deviceId));207+    const legacy = new Map<string, string>();208+    for (const device of devices) {209+      if (isValidPairSecret(device.rendezvousSecretHex)) {210+        legacy.set(device.deviceId, device.rendezvousSecretHex);211+      }198212     }199-  }200213 201-  const map = await readEncryptedMap(rootKey);202-  let migrated = 0;203-  for (const [deviceId, secret] of legacy) {204-    if (map[deviceId] !== secret) {205-      map[deviceId] = secret;206-      migrated += 1;214+    const map = await readEncryptedMap(rootKey);215+    let migrated = 0;216+    for (const [deviceId, secret] of legacy) {217+      if (map[deviceId] !== secret) {218+        map[deviceId] = secret;219+        migrated += 1;220+      }207221     }208-  }209-  let pruned = false;210-  for (const deviceId of Object.keys(map)) {211-    if (!registryIds.has(deviceId)) {212-      delete map[deviceId];213-      pruned = true;222+    let pruned = false;223+    for (const deviceId of Object.keys(map)) {224+      if (!registryIds.has(deviceId)) {225+        delete map[deviceId];226+        pruned = true;227+      }214228     }215-  }216229 217-  // Persist only when something actually changed; a fresh vault with no218-  // pairs writes nothing.219-  if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {220-    await writeEncryptedMap(rootKey, map);221-    // Crash safety: re-read and verify before touching the plaintext source.222-    const verified = await readEncryptedMap(rootKey);223-    for (const [deviceId, secret] of legacy) {224-      if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");230+    // Persist only when something actually changed; a fresh vault with no231+    // pairs writes nothing.232+    if (migrated > 0 || pruned || legacy.size > 0 || Object.keys(map).length > 0) {233+      await writeEncryptedMap(rootKey, map);234+      // Crash safety: re-read and verify before touching the plaintext source.235+      const verified = await readEncryptedMap(rootKey);236+      for (const [deviceId, secret] of legacy) {237+        if (verified[deviceId] !== secret) throw new Error("pair_secrets_verify_failed");238+      }239+      if (legacy.size > 0) {240+        await writePairedDevices(241+          devices.map((device) => {242+            if (!isValidPairSecret(device.rendezvousSecretHex)) return device;243+            const { rendezvousSecretHex: _dropped, ...pub } = device;244+            return pub;245+          }),246+        );247+      }225248     }226-    if (legacy.size > 0) {227-      await writePairedDevices(228-        devices.map((device) => {229-          if (!isValidPairSecret(device.rendezvousSecretHex)) return device;230-          const { rendezvousSecretHex: _dropped, ...pub } = device;231-          return pub;232-        }),233-      );234-    }235-  }236249 237-  await writeSessionCache(map);238-  return { migrated };250+    await writeSessionCache(map);251+    return { migrated };252+  });239253 }240254 241255 /** Rotation drops all peers, so the secret map is cleared with the registry. */242256 export async function clearPairSecrets(): Promise<void> {243-  try {244-    await chrome.storage.local.remove(RECORD_KEY);245-  } catch {246-    // Best effort alongside the registry clear it accompanies.247-  }248-  await clearSessionCache();257+  await serializeMutation(async () => {258+    try {259+      await chrome.storage.local.remove(RECORD_KEY);260+    } catch {261+      // Best effort alongside the registry clear it accompanies.262+    }263+    await clearSessionCache();264+  });249265 }250266 251267 /**@@ -254,7 +270,7 @@254270  * real pair-secret map, and the next real unlock re-populates the copy.255271  */256272 export async function dropSessionCache(): Promise<void> {257-  await clearSessionCache();273+  await serializeMutation(clearSessionCache);258274 }259275 260276 /**@@ -267,14 +283,16 @@267283   deviceId: string,268284   rootKey: Uint8Array | null,269285 ): Promise<void> {270-  const cached = await readSessionCache();271-  if (cached[deviceId] !== undefined) {272-    delete cached[deviceId];273-    await writeSessionCache(cached);274-  }275-  if (!rootKey) return;276-  const map = await readEncryptedMap(rootKey);277-  if (map[deviceId] === undefined) return;278-  delete map[deviceId];279-  await writeEncryptedMap(rootKey, map);286+  await serializeMutation(async () => {287+    const cached = await readSessionCache();288+    if (cached[deviceId] !== undefined) {289+      delete cached[deviceId];290+      await writeSessionCache(cached);291+    }292+    if (!rootKey) return;293+    const map = await readEncryptedMap(rootKey);294+    if (map[deviceId] === undefined) return;295+    delete map[deviceId];296+    await writeEncryptedMap(rootKey, map);297+  });280298 }

view file