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
↓ peach-browser-31cfb64b.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.session-escrow.test.ts+1 −0src/background/vault.ts+70 −6manifest.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": {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",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",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 } {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