847806bd2188
feat: cap peer device names at 64 chars on every inbound seam (v4.8.8)
- time
- 2026-08-07 22:27 UTC · 2 months ago
- machine
- koh2
- parent
- c387e8a41f26
↓ peach-browser-847806bd.tar.gzatlas at this save
files changed13 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 −11manifest.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": {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",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",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);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 handlerssrc/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+});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);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+});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);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+});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(),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.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.