b7ace1e073ca
naturalise foreign entry ids at the browser restore seam instead of trusting them (v5.0.31)
- time
- 2026-09-01 04:11 UTC · 1 month ago
- machine
- koh2
- agent
- hy4-preview
- session
- define the entry-id namespace at the import seam
- parent
- a33df62c91d3
↓ peach-browser-b7ace1e0.tar.gzatlas at this save
files changed5 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/vault.entry-id-namespace.test.ts+119 −0src/background/vault.ts+25 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.30",4+ "version": "5.0.31",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.30",5+ "version": "5.0.31",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.30",3+ "version": "5.0.31",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/vault.entry-id-namespace.test.ts+119 −0
@@ -0,0 +1,119 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import type { StoredVault } from "../shared/types";3+4+type StorageShape = Record<string, unknown>;5+6+function makeChromeStorageMock(storage: StorageShape) {7+ return {8+ storage,9+ chrome: {10+ storage: {11+ local: {12+ get: vi.fn(async (key?: string | string[]) => {13+ if (!key) return { ...storage };14+ if (typeof key === "string") return { [key]: storage[key] };15+ return key.reduce<StorageShape>((acc, k) => {16+ acc[k] = storage[k];17+ return acc;18+ }, {});19+ }),20+ set: vi.fn(async (next: StorageShape) => {21+ Object.assign(storage, next);22+ }),23+ remove: vi.fn(async (keys: string | string[]) => {24+ for (const key of Array.isArray(keys) ? keys : [keys]) delete storage[key];25+ }),26+ },27+ },28+ alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() } },29+ idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },30+ } as unknown as typeof globalThis.chrome,31+ };32+}33+34+const UUID_SHAPE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;35+36+/**37+ * A Peach entry id is a UUID, but an imported or restored file carries whatever38+ * id its author used and no reader bounds it. One oversized id would ride into39+ * every future sync frame and be rejected there — a permanent, opaque failure40+ * on both platforms, caused by data that was already local.41+ *42+ * The seam is restoreEntries, and only restoreEntries. Sync adopts a peer's id43+ * verbatim, which is the only thing keeping two devices agreed on which entry44+ * they are discussing, so reminting there would fork identity on every device.45+ */46+describe("vault entry-id namespace", () => {47+ let storage: StorageShape;48+ let vault: typeof import("./vault");49+ const password = "entry-id-namespace-pass";50+51+ beforeEach(async () => {52+ vi.resetModules();53+ storage = {};54+ (globalThis as Record<string, unknown>).chrome = makeChromeStorageMock(storage).chrome;55+ vi.stubGlobal("fetch", vi.fn(async () => {56+ throw new Error("network disabled in tests");57+ }));58+ vault = await import("./vault");59+ await vault.createVault(password);60+ });61+62+ function imported(id: string, title: string) {63+ return {64+ id,65+ type: "login" as const,66+ title,67+ password: "pw",68+ createdAt: 1,69+ modifiedAt: Date.now(),70+ };71+ }72+73+ it("keeps a well-formed id the file already carried", async () => {74+ const fromAnotherPeach = "b1f0c8e2-4a6d-4f1a-9c3e-7d5b2a8e1f40";75+ const fromAVendor = "sj4kd9f2q7m1x8zb3nv6ct0h";76+ const summary = await vault.restoreEntries(77+ [imported(fromAnotherPeach, "Bitwarden"), imported(fromAVendor, "OnePassword")],78+ "replace",79+ );80+ expect(summary.added).toBe(2);81+ expect(vault.getEntries().map((e) => e.id).sort()).toEqual(82+ [fromAVendor, fromAnotherPeach].sort(),83+ );84+ });85+86+ it("mints a fresh id for one that is not a Peach id, keeping the entry", async () => {87+ const summary = await vault.restoreEntries(88+ [89+ imported("x".repeat(200), "Too long"),90+ imported('a b"c\n{"injected":true}', "Not an id"),91+ ],92+ "replace",93+ );94+ expect(summary.added).toBe(2);95+ const minted = vault.getEntries();96+ expect(minted.map((e) => e.title).sort()).toEqual(["Not an id", "Too long"]);97+ expect(new Set(minted.map((e) => e.id)).size).toBe(2);98+ for (const entry of minted) expect(entry.id).toMatch(UUID_SHAPE);99+ });100+101+ it("adopts a sync peer's id verbatim", async () => {102+ const peerId = `peer-${"x".repeat(120)}`;103+ const result = await vault.applyMergedRemoteEntries(104+ [{ ...imported(peerId, "From peer") }],105+ {},106+ );107+ expect(result.applied).toBe(1);108+ expect(vault.getEntries().map((e) => e.id)).toEqual([peerId]);109+ });110+111+ it("persists the naturalised ids across a lock and unlock", async () => {112+ await vault.restoreEntries([imported("x".repeat(200), "Too long")], "replace");113+ const minted = vault.getEntries()[0].id;114+ vault.lock();115+ await vault.unlock(password);116+ expect(vault.getEntries().map((e) => e.id)).toEqual([minted]);117+ expect(storage.peach_vault_a as StoredVault).toBeDefined();118+ });119+});src/background/vault.ts+25 −2
@@ -1823,7 +1823,7 @@18231823 entries = [];18241824 tombstones = new Map();18251825 for (const entry of incoming) {1826- const normalized = sanitizeRestoredEntry(entry);1826+ const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));18271827 if (!normalized) {18281828 skipped += 1;18291829 continue;@@ -1839,7 +1839,7 @@18391839 for (const e of entries) byId.set(e.id, e);18401840 18411841 for (const entry of incoming) {1842- const normalized = sanitizeRestoredEntry(entry);1842+ const normalized = naturalizeRestoredId(sanitizeRestoredEntry(entry));18431843 if (!normalized) {18441844 skipped += 1;18451845 continue;@@ -1866,6 +1866,29 @@18661866 18671867 await persistEntries("import");18681868 return { added, updated, skipped };1869+}1870+1871+/**1872+ * A Peach entry id is a UUID. A restored or imported file carries whatever id1873+ * its author used, and no reader bounds it, so one oversized id would ride1874+ * into every future sync frame and be rejected there — a permanent, opaque1875+ * failure on both platforms, caused by data that was already local.1876+ *1877+ * The action is mint, not reject: this is the last point where an id is still1878+ * ours to choose, so the worst outcome is one duplicate if the same vault is1879+ * later restored beside a device still holding the old id. Rejecting would1880+ * lose the user's import.1881+ *1882+ * Deliberately NOT inside sanitizeRestoredEntry, which applyMergedRemoteEntries1883+ * also calls. Sync has to adopt a peer's id verbatim — that is the only thing1884+ * keeping two devices agreed on which entry they are discussing — so reminting1885+ * there would fork identity on every device, on every round.1886+ */1887+const ENTRY_ID_SHAPE = /^[A-Za-z0-9_-]{1,64}$/;1888+1889+function naturalizeRestoredId(entry: VaultEntry | null): VaultEntry | null {1890+ if (!entry || ENTRY_ID_SHAPE.test(entry.id)) return entry;1891+ return { ...entry, id: crypto.randomUUID() };18691892 }18701893 18711894 /**