847806bd2188

feat: cap peer device names at 64 chars on every inbound seam (v4.8.8)

Local names were already bounded (setDeviceName); peer-supplied names had no bound, letting a hostile peer grow chrome.storage and blow out fixed-width UI. Add MAX_DEVICE_NAME_CHARS/capDeviceName in shared/sync-limits.ts and apply at device-manifest normaliseEntry, paired-devices upsertPairedDevice, pending-vouched addPendingVouchedDevice, and sync-alerts recordSyncAlerts. Cap rather than reject so old peers with long-but-honest names keep syncing. Tests: +7 (paired-devices, device-manifest, new pending-vouched-devices and sync-alerts specs). Also tighten SA01 to assert zero horizontal overflow on the steady-state popup (the earlier ~7px reading was a mid-open tab artifact).

time
2026-08-07 22:27 UTC · 2 months ago
machine
koh2
parent
c387e8a41f26
13 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/paired-devices.test.ts+6 −0src/background/paired-devices.ts+8 −4src/background/pending-vouched-devices.test.ts+61 −0src/background/pending-vouched-devices.ts+5 −0src/background/sync-alerts.test.ts+60 −0src/background/sync-alerts.ts+8 −1src/shared/device-manifest.test.ts+13 −0src/shared/device-manifest.ts+2 −1src/shared/sync-limits.ts+16 −0tests/sync-alerts.spec.ts+13 −11
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "4.8.7",4+  "version": "4.8.8",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": "4.8.7",5+  "version": "4.8.8",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": "4.8.7",3+  "version": "4.8.8",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/background/paired-devices.test.ts+6 −0
@@ -92,6 +92,12 @@9292     await expect(upsertPairedDevice({ ...SAMPLE, deviceType: undefined })).rejects.toThrow(/deviceType/);9393   });9494 95+  it("caps oversized peer device names at the registry seam", async () => {96+    await upsertPairedDevice({ ...SAMPLE, deviceId: "LONG-1", deviceName: "N".repeat(100) });97+    const found = await findPairedDeviceById("LONG-1");98+    expect(found?.deviceName).toBe("N".repeat(64));99+  });100+95101   it("findPairedDeviceByPubkey returns the correct device", async () => {96102     const other: PairedDevice = { ...SAMPLE, deviceId: "BBB-222", longTermPublicKeyHex: "1".repeat(64) };97103     await upsertPairedDevice(SAMPLE);

view file

src/background/paired-devices.ts+8 −4
@@ -22,7 +22,7 @@2222 2323 import type { DeviceRevocation, PairedDevice } from "../shared/sync-types";2424 import { constantTimeEqualHex } from "../shared/constant-time";25-import { clampSyncTimestamp } from "../shared/sync-limits";25+import { capDeviceName, clampSyncTimestamp } from "../shared/sync-limits";2626 import type { SyncOutcome } from "../shared/sync-outcome";2727 2828 const REGISTRY_KEY = "peach_paired_devices_v3";@@ -68,12 +68,16 @@6868   if (!device.deviceName) throw new Error("device.deviceName is required");6969   if (!device.deviceType) throw new Error("device.deviceType is required");7070 71+  // Peer-supplied names arrive capped (MAX_DEVICE_NAME_CHARS) so a hostile72+  // peer cannot grow the registry without limit; the local name already73+  // obeys the same bound at the Settings write path.74+  const stored = { ...device, deviceName: capDeviceName(device.deviceName) };7175   const devices = await listPairedDevices();72-  const idx = devices.findIndex((d) => d.deviceId === device.deviceId);76+  const idx = devices.findIndex((d) => d.deviceId === stored.deviceId);7377   if (idx >= 0) {74-    devices[idx] = { ...devices[idx], ...device };78+    devices[idx] = { ...devices[idx], ...stored };7579   } else {76-    devices.push(device);80+    devices.push(stored);7781   }7882   await chrome.storage.local.set({ [REGISTRY_KEY]: devices });7983   // The device-list-changed sync trigger fires from the messages.ts handlers

view file

src/background/pending-vouched-devices.test.ts+61 −0
@@ -0,0 +1,61 @@1+import { beforeEach, describe, expect, it } from "vitest";2+import {3+  addPendingVouchedDevice,4+  listPendingVouchedDevices,5+  removePendingVouchedDevice,6+} from "./pending-vouched-devices";7+8+function makeStorageStub() {9+  const local = new Map<string, unknown>();10+  (globalThis as unknown as { chrome: unknown }).chrome = {11+    storage: {12+      local: {13+        async get(key?: string | string[] | null) {14+          if (key == null) {15+            const all: Record<string, unknown> = {};16+            for (const [k, v] of local) all[k] = v;17+            return all;18+          }19+          const keys = Array.isArray(key) ? key : [key];20+          const out: Record<string, unknown> = {};21+          for (const k of keys) if (local.has(k)) out[k] = local.get(k);22+          return out;23+        },24+        async set(values: Record<string, unknown>) {25+          for (const [k, v] of Object.entries(values)) local.set(k, v);26+        },27+        async remove(key: string | string[]) {28+          for (const k of Array.isArray(key) ? key : [key]) local.delete(k);29+        },30+      },31+    },32+  };33+}34+35+const SAMPLE = {36+  deviceId: "VVV-1",37+  deviceName: "Vouched Pixel",38+  deviceType: "android",39+  longTermPublicKeyHex: "a".repeat(64),40+  signingPublicKeyHex: "b".repeat(64),41+  pairedAt: 1,42+  receivedAt: 2,43+  vouchedByDeviceId: "issuer-1",44+};45+46+describe("pending vouched devices", () => {47+  beforeEach(() => makeStorageStub());48+49+  it("add + list + remove", async () => {50+    await addPendingVouchedDevice(SAMPLE);51+    expect(await listPendingVouchedDevices()).toEqual([SAMPLE]);52+    await removePendingVouchedDevice(SAMPLE.deviceId);53+    expect(await listPendingVouchedDevices()).toEqual([]);54+  });55+56+  it("caps oversized peer device names", async () => {57+    await addPendingVouchedDevice({ ...SAMPLE, deviceName: "V".repeat(100) });58+    const [d] = await listPendingVouchedDevices();59+    expect(d.deviceName).toBe("V".repeat(64));60+  });61+});

view file

src/background/pending-vouched-devices.ts+5 −0
@@ -12,6 +12,8 @@1212 // these records contain public keys and must not roam through a third-party1313 // sync back-end.1414 15+import { capDeviceName } from "../shared/sync-limits";16+1517 export interface PendingVouchedDevice {1618   deviceId: string;1719   deviceName: string;@@ -44,6 +46,9 @@4446 export async function addPendingVouchedDevice(4547   device: PendingVouchedDevice,4648 ): Promise<void> {49+  // Vouch requests carry a peer-chosen name; bound it like every other50+  // inbound name so the pending queue cannot be grown without limit.51+  device = { ...device, deviceName: capDeviceName(device.deviceName) };4752   const pending = await listPendingVouchedDevices();4853   const next = pending.filter((d) => d.deviceId !== device.deviceId);4954   next.push(device);

view file

src/background/sync-alerts.test.ts+60 −0
@@ -0,0 +1,60 @@1+import { beforeEach, describe, expect, it } from "vitest";2+import {3+  clearPendingSyncAlerts,4+  getPendingSyncAlerts,5+  recordSyncAlerts,6+} from "./sync-alerts";7+8+function makeStorageStub() {9+  const local = new Map<string, unknown>();10+  (globalThis as unknown as { chrome: unknown }).chrome = {11+    storage: {12+      local: {13+        async get(key?: string | string[] | null) {14+          if (key == null) {15+            const all: Record<string, unknown> = {};16+            for (const [k, v] of local) all[k] = v;17+            return all;18+          }19+          const keys = Array.isArray(key) ? key : [key];20+          const out: Record<string, unknown> = {};21+          for (const k of keys) if (local.has(k)) out[k] = local.get(k);22+          return out;23+        },24+        async set(values: Record<string, unknown>) {25+          for (const [k, v] of Object.entries(values)) local.set(k, v);26+        },27+        async remove(key: string | string[]) {28+          for (const k of Array.isArray(key) ? key : [key]) local.delete(k);29+        },30+      },31+    },32+  };33+}34+35+describe("sync alerts", () => {36+  beforeEach(() => makeStorageStub());37+38+  it("records security alerts and caps oversized device names", async () => {39+    await recordSyncAlerts([40+      { type: "DEVICE_REVOKED", entryId: "", entryTitle: "", deviceName: "R".repeat(100), timestamp: 1 },41+    ]);42+    const [a] = await getPendingSyncAlerts();43+    expect(a.deviceName).toBe("R".repeat(64));44+  });45+46+  it("drops non-alert records", async () => {47+    await recordSyncAlerts([48+      { type: "ROUTINE_SYNC", entryId: "", entryTitle: "", deviceName: "x", timestamp: 1 } as never,49+    ]);50+    expect(await getPendingSyncAlerts()).toEqual([]);51+  });52+53+  it("clear empties the queue", async () => {54+    await recordSyncAlerts([55+      { type: "DEVICE_KEY_ROTATED", entryId: "", entryTitle: "", deviceName: "k", timestamp: 1 },56+    ]);57+    await clearPendingSyncAlerts();58+    expect(await getPendingSyncAlerts()).toEqual([]);59+  });60+});

view file

src/background/sync-alerts.ts+8 −1
@@ -9,6 +9,8 @@99 // queue is cleared after the popup acknowledges display (GET_SYNC_ALERTS1010 // with `consume: true`).1111 12+import { capDeviceName } from "../shared/sync-limits";13+1214 export interface SyncAlert {1315   type: "DEVICE_KEY_ROTATED" | "DEVICE_REVOKED";1416   /** VaultEntry.id — a UUID string in this codebase (spec deviation: the@@ -27,7 +29,12 @@2729 const MAX_PENDING_ALERTS = 200;2830 2931 export async function recordSyncAlerts(alerts: SyncAlert[]): Promise<void> {30-  const securityAlerts = alerts.filter(isSyncAlert);32+  // Final display seam: cap whatever name reached the queue (including the33+  // `knownName ?? entry.deviceName` fallback) at the shared bound.34+  const securityAlerts = alerts.filter(isSyncAlert).map((a) => ({35+    ...a,36+    deviceName: capDeviceName(a.deviceName),37+  }));3138   if (securityAlerts.length === 0) return;3239   const existing = await readAlerts();3340   const merged = [...existing, ...securityAlerts].slice(-MAX_PENDING_ALERTS);

view file

src/shared/device-manifest.test.ts+13 −0
@@ -264,3 +264,16 @@264264     expect(body.devices).toHaveLength(1);265265   });266266 });267+268+describe("device name bound", () => {269+  it("caps oversized device names during build/normalise", async () => {270+    const kp = await deriveSyncSigningKeypair(MASTER_KEY, ISSUER_DEVICE_ID);271+    const m = await buildSignedDeviceManifest(272+      ISSUER_DEVICE_ID,273+      [{ ...entry(), deviceName: "D".repeat(200) }],274+      kp.privateKey,275+    );276+    const body = await verifyDeviceManifest(m, kp.publicKeyRaw);277+    expect(body.devices[0].deviceName).toBe("D".repeat(64));278+  });279+});

view file

src/shared/device-manifest.ts+2 −1
@@ -33,6 +33,7 @@3333 } from "./sync-types";3434 import { signEd25519, verifyEd25519 } from "./sync-identity";3535 import { normalizeRelayUrl } from "./rendezvous";36+import { capDeviceName } from "./sync-limits";3637 3738 export class DeviceManifestError extends Error {3839   readonly kind:@@ -211,7 +212,7 @@211212 function normaliseEntry(d: ManifestDeviceEntry): ManifestDeviceEntry {212213   const normalised: ManifestDeviceEntry = {213214     deviceId: d.deviceId,214-    deviceName: d.deviceName,215+    deviceName: capDeviceName(d.deviceName),215216     deviceType: d.deviceType,216217     longTermPublicKeyHex: d.longTermPublicKeyHex.toLowerCase(),217218     signingPublicKeyHex: d.signingPublicKeyHex.toLowerCase(),

view file

src/shared/sync-limits.ts+16 −0
@@ -25,6 +25,22 @@2525 }2626 2727 /**28+ * Display/storage bound for device names. The LOCAL name already obeys this29+ * bound at the Settings write path (device-identity.ts setDeviceName);30+ * peer-supplied names (manifest devices, vouch requests, alert fallbacks)31+ * historically had none, letting a hostile peer grow chrome.storage without32+ * limit and blow out fixed-width UI. Inbound seams cap rather than reject so33+ * old peers with long-but-honest names keep syncing.34+ */35+export const MAX_DEVICE_NAME_CHARS = 64;36+37+export function capDeviceName(name: string): string {38+  return name.length <= MAX_DEVICE_NAME_CHARS39+    ? name40+    : name.slice(0, MAX_DEVICE_NAME_CHARS);41+}42+43+/**2844  * HOLE 2 coverage audit — every peer-supplied timestamp in the sync protocol,2945  * and what stops it influencing ordering. Re-check this list when adding a3046  * timestamp to any wire type.

view file

tests/sync-alerts.spec.ts+13 −11
@@ -1,5 +1,5 @@11 import { test, expect, BrowserContext } from "@playwright/test";2-import { makeContext, setupVault, waitForSW } from "./helpers/vault";2+import { makeContext, setupVault } from "./helpers/vault";33 44 // Device-trust alerts (DEVICE_REVOKED / DEVICE_KEY_ROTATED) carry peer-supplied55 // device names. A long or unbreakable name must never widen the fixed-width@@ -34,13 +34,12 @@3434     ];3535     await popup.evaluate((a) => chrome.storage.local.set({ peach_pending_sync_alerts_v1: a }), alerts);3636 37-    // A fresh popup boots unlocked and consumes the pending alert queue.38-    const extId = await waitForSW(ctx);39-    const p2 = await ctx.newPage();40-    await p2.goto(`chrome-extension://${extId}/src/popup/index.html`);41-    await expect(p2.locator(".peach-sync-alert")).toHaveCount(2, { timeout: 20000 });37+    // The header sync refresh reloads the pending alert queue on this page,38+    // so the banners render on the same steady-state 320px popup.39+    await popup.locator(".peach-sync-btn").click();40+    await expect(popup.locator(".peach-sync-alert")).toHaveCount(2, { timeout: 20000 });4241 43-    const geo = await p2.evaluate(() => {42+    const geo = await popup.evaluate(() => {4443       const shell = document.querySelector(".peach-shell")!;4544       const shellRect = shell.getBoundingClientRect();4645       const rows = [...document.querySelectorAll(".peach-sync-alert")].map((r) => {@@ -53,12 +52,15 @@5352           textClientW: text.clientWidth,5453         };5554       });56-      return { shellScrollW: shell.scrollWidth, rows };55+      return {56+        shellClientW: shell.clientWidth,57+        shellScrollW: shell.scrollWidth,58+        rows,59+      };5760     });5861 59-    // The shell is designed at 320px; the alert blowout stretched its scroll60-    // surface past 1000px. It must never exceed the design width.61-    expect(geo.shellScrollW).toBeLessThanOrEqual(320);62+    // No horizontal scroll surface: the pre-fix blowout measured 1105 here.63+    expect(geo.shellScrollW).toBeLessThanOrEqual(geo.shellClientW + 1);6264 6365     // Every alert row stays inside the shell, and the nowrap text truncates6466     // (ellipsis) instead of widening its row.

view file