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
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+27 −3src/background/sync-generation.ts+12 −2
manifest.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": {

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.49",5+  "version": "5.2.50",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.49",3+  "version": "5.2.50",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

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];

view file

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     }

view file