31cfb64b6d95

guard unlock and password change state publication against lock epoch

time
2026-09-03 22:28 UTC · 1 month ago
machine
koh2
agent
auto
session
PB-26-306 unlock lock race
parent
07bf0e84d1db
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.session-escrow.test.ts+1 −0src/background/vault.ts+70 −6
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.0.73",4+  "version": "5.0.74",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.0.73",5+  "version": "5.0.74",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.0.73",3+  "version": "5.0.74",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/vault.session-escrow.test.ts+1 −0
@@ -6,6 +6,7 @@66 // are closed over by the chrome mock on globalThis, so they survive the77 // vi.resetModules() we use to simulate a service-worker restart mid-test.88 9+910 type StorageShape = Record<string, unknown>;1011 1112 function deferred(): { promise: Promise<void>; resolve: () => void } {

view file

src/background/vault.ts+70 −6
@@ -1420,6 +1420,8 @@14201420   password: string,14211421   derive: typeof deriveKey,14221422 ): Promise<boolean> {1423+  const epoch = vaultEpoch;1424+  const stale = () => epoch !== vaultEpoch;14231425   // Load both current-format slots in parallel. Both exist on a supported14241426   // install; the14251427   // parallel load and the parallel derivation below keep wall-clock time@@ -1451,15 +1453,29 @@14511453   const payloadA = resultA.status === "ok" ? resultA.payload : null;14521454   const payloadB = resultB.status === "ok" ? resultB.payload : null;14531455 1456+  if (stale()) {1457+    if (keyA) zeroKey(keyA);1458+    if (keyB) zeroKey(keyB);1459+    return false;1460+  }1461+14541462   if (payloadA && keyA) {1463+    if (stale()) {1464+      zeroKey(keyA);1465+      if (keyB) zeroKey(keyB);1466+      return false;1467+    }14551468     derivedKey = keyA;1456-    void saveSessionKey(keyA, "real");14571469     vaultMode = "real";14581470     entries = payloadA.entries;14591471     tombstones = payloadA.tombstones;14601472     duressConfigured = payloadA.duressConfigured;14611473     buildDomainIndexSync(entries);14621474     await loadSettings();1475+    if (stale()) {1476+      await lock();1477+      return false;1478+    }14631479     touchAutoLock();14641480     if (keyB) zeroKey(keyB);14651481 @@ -1474,18 +1490,35 @@14741490     // PB-26-213: adopt legacy plaintext pair secrets into the encrypted14751491     // record and refresh the trusted-session operational copy.14761492     await reconcilePairSecrets(rootKey);1477-1493+    if (stale()) {1494+      await lock();1495+      return false;1496+    }14781497     await lockedSyncUnlockHook?.().catch((error) => {14791498       console.warn("[Peach sync] locked inbox drain failed:", error);14801499     });1500+    if (stale()) {1501+      await lock();1502+      return false;1503+    }14811504     await resetPinFailedAttempts();1505+    if (stale()) {1506+      await lock();1507+      return false;1508+    }1509+    await saveSessionKey(keyA, "real");14821510     return true;14831511   }14841512 14851513   if (payloadB && keyB) {1514+    if (stale()) {1515+      zeroKey(keyB);1516+      if (keyA) zeroKey(keyA);1517+      return false;1518+    }14861519     derivedKey = keyB;1487-    void saveSessionKey(keyB, "decoy");14881520     vaultMode = "decoy";1521+    vaultMode = "decoy";14891522     entries = payloadB.entries;14901523     tombstones = payloadB.tombstones;14911524     duressConfigured = false;@@ -1495,6 +1528,11 @@14951528     if (keyA) zeroKey(keyA);14961529     // PB-26-213: a duress session must never serve the real pair-secret map.14971530     await dropSessionCache();1531+    if (stale()) {1532+      await lock();1533+      return false;1534+    }1535+    await saveSessionKey(keyB, "decoy");14981536     return true;14991537   }15001538 @@ -1685,12 +1723,18 @@16851723 export async function unlockWithKey(16861724   masterKeyHex: string,16871725 ): Promise<{ ok: true; entries: VaultEntry[] } | { ok: false; error: string }> {1726+  const epoch = vaultEpoch;1727+  const stale = () => epoch !== vaultEpoch;1728+  let masterKeyBytes: Uint8Array | null = null;16881729   try {1689-    const masterKeyBytes = unhex(masterKeyHex);1730+    masterKeyBytes = unhex(masterKeyHex);16901731     const stored = await loadSlot(SLOT_A);16911732     if (!stored) return { ok: false, error: "invalid_key" };16921733     const payload = await tryDecryptSlot(stored, masterKeyBytes, "slot_a_v1");1693-    if (!payload) return { ok: false, error: "invalid_key" };1734+    if (!payload || stale()) {1735+      zeroKey(masterKeyBytes);1736+      return { ok: false, error: "invalid_key" };1737+    }16941738 16951739     derivedKey = masterKeyBytes;16961740     vaultMode = "real";@@ -1707,8 +1751,14 @@17071751       return { ok: false, error: "invalid_key" };17081752     }17091753     await reconcilePairSecrets(rootKey);1754+    if (stale()) {1755+      await lock();1756+      return { ok: false, error: "invalid_key" };1757+    }1758+    await saveSessionKey(masterKeyBytes, "real");17101759     return { ok: true, entries };17111760   } catch {1761+    if (masterKeyBytes && derivedKey !== masterKeyBytes) zeroKey(masterKeyBytes);17121762     return { ok: false, error: "invalid_key" };17131763   }17141764 }@@ -3243,6 +3293,8 @@32433293   currentPassword: string,32443294   newPassword: string32453295 ): Promise<boolean> {3296+  const epoch = vaultEpoch;3297+  const stale = () => epoch !== vaultEpoch;32463298   if (!derivedKey || vaultMode !== "real") return false;32473299 32483300   // Verify current password before allowing any change@@ -3256,6 +3308,10 @@32563308   // Derive fresh key from new password + new random salt32573309   const newSalt = generateSalt();32583310   const newKey = await deriveKey(newPassword, newSalt);3311+  if (stale()) {3312+    zeroKey(newKey);3313+    return false;3314+  }32593315 32603316   // Re-encrypt the in-memory entries with the new key32613317   const plaintext = encodePayload(entries, duressConfigured, duressConfigured ? DURESS_SLOT_SIZE : 0);@@ -3285,6 +3341,10 @@32853341   // slot intact; after this point everything tied to the new password is32863342   // already on disk together (M6/COR-004).32873343   await writeSlot(SLOT_A, finalSlotA);3344+  if (stale()) {3345+    zeroKey(newKey);3346+    return false;3347+  }32883348 32893349   // Swap the in-memory key synchronously before any further await. If we32903350   // threw between the Slot A write and this swap, the next persistEntries()@@ -3296,7 +3356,11 @@32963356   // the next service-worker restore decrypts the new-key Slot A with the stale32973357   // key, fails tryDecryptSlotResult, and silently drops the user to a lock32983358   // screen. Re-escrow the new key so restore survives a password change.3299-  void saveSessionKey(newKey, "real");3359+  if (stale()) {3360+    await lock();3361+    return false;3362+  }3363+  await saveSessionKey(newKey, "real");33003364 33013365   // Regenerate noise for slot B when duress is not in use. Runs after the33023366   // key swap so a failure here is a disposable noise regeneration, not a

view file