b7ace1e073ca

naturalise foreign entry ids at the browser restore seam instead of trusting them (v5.0.31)

A Peach entry id is a UUID, but an imported or restored file carries whatever id its author used and no reader bounds it. One oversized id would ride into every future sync frame and be rejected there — a permanent, opaque failure on both platforms, caused by data that was already local. Mirrors the Android change at v2.14.85. restoreEntries now replaces an id that is not 1-64 chars of [A-Za-z0-9_-] with a freshly minted UUID. It is the single import/restore funnel: every path reaches it through the RESTORE_ENTRIES message. batchAddEntries already mints UUIDs, and restore-from-our-own-backup paths carry ids that are already Peach ids. The rule deliberately does NOT live in sanitizeRestoredEntry, even though that would be the tidier single site, because applyMergedRemoteEntries calls it too. Sync has to adopt a peer's id verbatim — that is the only thing keeping two devices agreed on which entry they are discussing — so reminting there would fork identity on every device, on every round. A test asserts a 124-char peer id survives untouched so the seam cannot drift into the sync path later. The action is mint, not reject: this is the last point where an id is still ours to choose, so the worst outcome is one duplicate if the same vault is later restored beside a device still holding the old id. Rejecting would lose the user's import. Cross-platform surface per AGENTS.md: entry ids, migration behavior. Emitting side is unchanged — Peach-minted ids were already UUIDs, so honest vaults emit nothing new. A vault holding a non-UUID id migrates on its next restore or import, not spontaneously. Verification: new vault.entry-id-namespace.test.ts (4 cases — a UUID and a 22-char vendor id kept, an oversized and an injection-shaped id minted as UUIDs with the entries intact, a sync peer's long id adopted verbatim, and minted ids surviving a lock/unlock round trip); full CI green — lockfile freshness, TypeScript, Biome, 143 Vitest files / 3,713 tests, Noise WASM provenance, Chrome and Firefox builds. Nothing offered or promoted.

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
5 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 −2
manifest.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": {

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

view file

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+});

view file

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 /**

view file