987624578e20
fix: Serialize encrypted pair-secret updates (v5.2.15)
- time
- 2026-09-07 16:44 UTC · 1 month ago
- machine
- koh2
- parent
- 689c06ca307b
↓ peach-browser-98762457.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/pair-secrets.test.ts+14 −0src/background/pair-secrets.ts+81 −63manifest.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": {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",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",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 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 }