b1c0079c5bff
verify the reset archive by field, not serialized text (v5.2.50)
- time
- 2026-09-11 22:50 UTC · 1 month ago
- machine
- koh2
- agent
- deepseek/deepseek-v4-flash
- session
- fix secure reset archive verify
- parent
- 2f80eea29ae0
↓ peach-browser-b1c0079c.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+27 −3src/background/sync-generation.ts+12 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.2.49",4+ "version": "5.2.50",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.49",5+ "version": "5.2.50",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.49",3+ "version": "5.2.50",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/messages.test.ts+27 −3
@@ -18,6 +18,31 @@1818 sendResponse: (response: MessageResponse) => void1919 ) => boolean;2020 21+/**22+ * Chrome re-encodes everything it stores: `undefined` is dropped and object23+ * keys come back in its own order, so a value read back is deep-equal to what24+ * was set but never the same object nor the same JSON text. Storing the value25+ * by reference would let an order-sensitive write-verify pass here and fail on26+ * a real device — exactly how the secure-reset archive check shipped broken.27+ */28+function asChromeStores(value: unknown): unknown {29+ if (Array.isArray(value)) return value.map(asChromeStores);30+ if (value && typeof value === "object") {31+ const source = value as StorageShape;32+ const clone: StorageShape = {};33+ for (const key of Object.keys(source).sort()) clone[key] = asChromeStores(source[key]);34+ return clone;35+ }36+ return value;37+}38+39+function storeWrite(target: StorageShape, next: StorageShape): void {40+ for (const [key, value] of Object.entries(next)) {41+ if (value === undefined) continue;42+ target[key] = asChromeStores(value);43+ }44+}45+2146 describe("background messages", () => {2247 let storage: StorageShape;2348 let sessionStorage: StorageShape;@@ -31,7 +56,6 @@3156 peach_settings: { breachMonitoring: false },3257 };3358 sessionStorage = {};34-3559 fetchMock = vi.fn(async () => ({3660 ok: true,3761 status: 200,@@ -76,7 +100,7 @@76100 }, {});77101 }),78102 set: vi.fn(async (next: Record<string, unknown>) => {79- Object.assign(storage, next);103+ storeWrite(storage, next);80104 }),81105 remove: vi.fn(async (keys: string | string[]) => {82106 const list = Array.isArray(keys) ? keys : [keys];@@ -100,7 +124,7 @@100124 }, {});101125 }),102126 set: vi.fn(async (next: Record<string, unknown>) => {103- Object.assign(sessionStorage, next);127+ storeWrite(sessionStorage, next);104128 }),105129 remove: vi.fn(async (keys: string | string[]) => {106130 const list = Array.isArray(keys) ? keys : [keys];src/background/sync-generation.ts+12 −2
@@ -380,8 +380,18 @@380380 pairSecrets: stored[PAIR_SECRETS_KEY] ?? null,381381 };382382 await chrome.storage.local.set({ [ref]: archive });383- const written = (await chrome.storage.local.get(ref))[ref];384- return JSON.stringify(written) === JSON.stringify(archive) ? ref : null;383+ // Read back to prove the archive is durable, but compare the fields that384+ // identify this write. Chrome re-encodes stored objects and does not385+ // return their keys in insertion order, so comparing the serialized text386+ // never matches on a real device — it failed every reset while the387+ // archive had in fact been written.388+ const written = (await chrome.storage.local.get(ref))[ref] as typeof archive | undefined;389+ if (!written) return null;390+ return written.schemaVersion === archive.schemaVersion &&391+ written.generation === archive.generation &&392+ written.archivedAt === archive.archivedAt393+ ? ref394+ : null;385395 } catch {386396 return null;387397 }