0ab2c6b00e92

flagged no reason given

enforce the PeachScript import ceiling at encode time on encoded UTF-8 bytes (PB-26-113, v5.0.44)

time
2026-09-02 04:16 UTC · 1 month ago
machine
koh2
agent
hy4-preview
session
assessment remediation corrections
parent
cd181fe97194
6 files manifest.firefox.json+0 −110manifest.json+0 −80package.json+0 −77src/crypto/peachScript.test.ts+47 −0src/crypto/peachScript.ts+15 −0src/popup/views/Export.tsx+5 −6
manifest.firefox.json+0 −110
@@ -1,110 +0,0 @@1-{2-  "manifest_version": 3,3-  "name": "Peach Password Manager",4-  "version": "5.0.43",5-  "description": "Zero-knowledge password manager",6-  "browser_specific_settings": {7-    "gecko": {8-      "id": "[email protected]",9-      "data_collection_permissions": {10-        "required": [11-          "none"12-        ],13-        "optional": [14-          "technicalAndInteraction"15-        ]16-      }17-    }18-  },19-  "action": {20-    "default_popup": "src/popup/index.html",21-    "default_icon": {22-      "16": "icons/16.png",23-      "32": "icons/32.png",24-      "48": "icons/48.png"25-    }26-  },27-  "background": {28-    "scripts": [29-      "src/background/entry.ts"30-    ],31-    "type": "module"32-  },33-  "content_scripts": [34-    {35-      "matches": [36-        "<all_urls>"37-      ],38-      "js": [39-        "src/content/index.ts"40-      ],41-      "run_at": "document_idle",42-      "all_frames": true43-    },44-    {45-      "matches": [46-        "<all_urls>"47-      ],48-      "js": [49-        "src/content/passkey-toast/inject.tsx"50-      ],51-      "run_at": "document_idle",52-      "all_frames": false53-    },54-    {55-      "matches": [56-        "<all_urls>"57-      ],58-      "js": [59-        "src/content/passkeys-firefox.ts"60-      ],61-      "run_at": "document_start",62-      "world": "MAIN"63-    },64-    {65-      "matches": [66-        "<all_urls>"67-      ],68-      "js": [69-        "src/content/passkeys-firefox-relay.ts"70-      ],71-      "run_at": "document_start"72-    }73-  ],74-  "host_permissions": [75-    "<all_urls>"76-  ],77-  "commands": {78-    "_execute_action": {79-      "suggested_key": {80-        "default": "Alt+Shift+P",81-        "mac": "Alt+Shift+P"82-      },83-      "description": "Open Peach popup"84-    },85-    "summon-inline": {86-      "suggested_key": {87-        "default": "Ctrl+Shift+Space",88-        "mac": "Command+Shift+Space"89-      },90-      "description": "Summon Peach on this page"91-    }92-  },93-  "permissions": [94-    "storage",95-    "unlimitedStorage",96-    "activeTab",97-    "scripting",98-    "clipboardWrite",99-    "alarms",100-    "idle"101-  ],102-  "content_security_policy": {103-    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'"104-  },105-  "icons": {106-    "16": "icons/16.png",107-    "48": "icons/48.png",108-    "128": "icons/128.png"109-  }110-}

view file

manifest.json+0 −80
@@ -1,80 +0,0 @@1-{2-  "manifest_version": 3,3-  "minimum_chrome_version": "116",4-  "name": "Peach Password Manager",5-  "version": "5.0.43",6-  "description": "Zero-knowledge password manager",7-  "action": {8-    "default_popup": "src/popup/index.html",9-    "default_icon": {10-      "16": "icons/16.png",11-      "32": "icons/32.png",12-      "48": "icons/48.png"13-    }14-  },15-  "background": {16-    "service_worker": "src/background/entry.ts",17-    "type": "module"18-  },19-  "content_scripts": [20-    {21-      "matches": [22-        "<all_urls>"23-      ],24-      "js": [25-        "src/content/index.ts"26-      ],27-      "run_at": "document_idle",28-      "all_frames": true29-    },30-    {31-      "matches": [32-        "<all_urls>"33-      ],34-      "js": [35-        "src/content/passkey-toast/inject.tsx"36-      ],37-      "run_at": "document_idle",38-      "all_frames": false39-    }40-  ],41-  "commands": {42-    "_execute_action": {43-      "suggested_key": {44-        "default": "Alt+Shift+P",45-        "mac": "Alt+Shift+P"46-      },47-      "description": "Open Peach popup"48-    },49-    "summon-inline": {50-      "suggested_key": {51-        "default": "Ctrl+Shift+Space",52-        "mac": "Command+Shift+Space"53-      },54-      "description": "Summon Peach on this page"55-    }56-  },57-  "permissions": [58-    "storage",59-    "unlimitedStorage",60-    "activeTab",61-    "scripting",62-    "clipboardWrite",63-    "alarms",64-    "idle",65-    "favicon",66-    "webAuthenticationProxy",67-    "offscreen"68-  ],69-  "host_permissions": [70-    "<all_urls>"71-  ],72-  "content_security_policy": {73-    "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'none'"74-  },75-  "icons": {76-    "16": "icons/16.png",77-    "48": "icons/48.png",78-    "128": "icons/128.png"79-  }80-}

view file

package.json+0 −77
@@ -1,77 +0,0 @@1-{2-  "name": "peach",3-  "version": "5.0.43",4-  "description": "Zero-knowledge password manager",5-  "license": "GPL-3.0-only",6-  "type": "module",7-  "packageManager": "[email protected]",8-  "scripts": {9-    "dev": "vite",10-    "build:noise-core": "node scripts/build-noise-core.mjs",11-    "build": "npm run build:chrome && npm run build:firefox",12-    "build:chrome": "node scripts/clean-chrome-dist.mjs && BUILD_TARGET=chrome vite build && npm run check:sw",13-    "build:firefox": "node scripts/clean-firefox-artifacts.mjs && BUILD_TARGET=firefox vite build",14-    "build:e2e": "PEACH_E2E_PASSKEYS=1 BUILD_TARGET=chrome vite build",15-    "test:firefox:sender": "node scripts/test-firefox-sender-context.mjs",16-    "package:firefox": "node scripts/clean-firefox-artifacts.mjs && npm run build:firefox && cd dist-firefox && web-ext build --overwrite-dest",17-    "lint": "biome check src/",18-    "lint:fix": "biome check --write src/",19-    "typecheck": "tsc --noEmit",20-    "test": "vitest run",21-    "test:watch": "vitest",22-    "test:e2e": "npm run build:chrome && playwright test tests/vault-lifecycle.spec.ts tests/entry-crud.spec.ts tests/search.spec.ts tests/settings.spec.ts tests/lock-screen.spec.ts tests/vault-security.spec.ts tests/sync-alerts.spec.ts tests/pb26016-sender-context.spec.ts",23-    "test:passkeys": "npm run build:e2e && playwright test tests/passkeys.spec.ts",24-    "ci": "bun run scripts/ci.ts",25-    "package:source": "zip -r peach-source.zip src scripts public manifest.json manifest.firefox.json package.json bun.lock tsconfig.json vite.config.ts --exclude 'node_modules/*' --exclude 'dist-*/*' --exclude '*.zip'",26-    "release": "bun run scripts/release.ts",27-    "release:chrome": "bun run scripts/release.ts --chrome-only",28-    "release:firefox": "bun run scripts/release-firefox.ts",29-    "check:sw": "node scripts/check-sw-static-imports.mjs"30-  },31-  "engines": {32-    "node": ">=22.12.0",33-    "npm": ">=10.0.0"34-  },35-  "dependencies": {36-    "@bokuweb/zstd-wasm": "0.0.27",37-    "@stablelib/blake2s": "2.0.1",38-    "@stablelib/hkdf": "2.0.1",39-    "@stablelib/hmac": "2.0.1",40-    "@stablelib/sha256": "2.0.1",41-    "@stablelib/x25519": "2.0.1",42-    "@subspace/reed-solomon-erasure.wasm": "0.2.5",43-    "@zxing/library": "0.23.0",44-    "bwip-js": "4.11.1",45-    "hash-wasm": "4.12.0",46-    "otpauth": "9.5.0",47-    "pako": "2.1.0",48-    "papaparse": "5.5.3",49-    "pdf-lib": "1.17.1",50-    "pdfjs-dist": "6.2.108",51-    "solid-js": "1.9.12",52-    "tldts": "7.0.28",53-    "@zxcvbn-ts/core": "3.0.4",54-    "@zxcvbn-ts/language-common": "3.0.4",55-    "@zxcvbn-ts/language-en": "3.0.2"56-  },57-  "devDependencies": {58-    "@biomejs/biome": "^2.5.6",59-    "@crxjs/vite-plugin": "^2.4.0",60-    "@playwright/test": "^1.59.1",61-    "@types/chrome": "^0.1.40",62-    "@types/node": "^22.0.0",63-    "@types/pako": "^2.0.4",64-    "@types/papaparse": "^5.5.2",65-    "chrome-webstore-upload-cli": "^4.0.0",66-    "geckodriver": "6.1.1",67-    "jsdom": "^29.0.2",68-    "typescript": "^6.0.3",69-    "vite": "^8.0.8",70-    "vite-plugin-solid": "^2.11.12",71-    "vitest": "^4.1.4",72-    "web-ext": "^10.2.0"73-  },74-  "overrides": {75-    "rollup": "^4.0.0"76-  }77-}

view file

src/crypto/peachScript.test.ts+47 −0
@@ -29,6 +29,53 @@2929   });3030 });3131 32+describe("peachScript encode size bound (PB-26-113)", () => {33+  // Every PeachScript code point is U+E000..U+E0FF, which UTF-8 encodes as34+  // three bytes. The importer bounds the FILE, not the envelope, so exporting35+  // an envelope of N bytes produces a 3N-byte file.36+  const encodedFileBytes = (bytes: Uint8Array) =>37+    new TextEncoder().encode(encodePeachScript(bytes)).length;38+39+  test("an envelope just above the import ceiling fails at export", () => {40+    const overCeiling = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3) + 1;41+    const bytes = new Uint8Array(overCeiling);42+43+    // The old export guard measured envelope bytes and let this through.44+    expect(bytes.length).toBeLessThan(IMPORT_LIMITS.MAX_JSON_BYTES);45+    expect(() => encodePeachScript(bytes)).toThrow("PEACHSCRIPT_TOO_LARGE");46+  });47+48+  test("the encoded file costs three bytes per envelope byte", () => {49+    // Proves the factor the ceiling is computed from, on a payload small50+    // enough to compare byte for byte.51+    const probe = new Uint8Array(1024);52+    for (let i = 0; i < probe.length; i++) probe[i] = (i * 37 + 13) % 256;53+    expect(encodedFileBytes(probe)).toBe(probe.length * 3);54+    expect(decodePeachScript(encodePeachScript(probe))).toEqual(probe);55+  });56+57+  test("an envelope at the ceiling exports to a file the importer accepts", () => {58+    const atCeiling = Math.floor(IMPORT_LIMITS.MAX_JSON_BYTES / 3);59+    const bytes = new Uint8Array(atCeiling);60+    for (let i = 0; i < atCeiling; i++) bytes[i] = (i * 37 + 13) % 256;61+62+    const encoded = encodePeachScript(bytes);63+    // The .peach file's size is what the import guard checks (PB-26-112).64+    expect(encodedFileBytes(bytes)).toBeLessThanOrEqual(IMPORT_LIMITS.MAX_JSON_BYTES);65+    // Spot-check rather than deep-compare: 3.5M elements is not what this66+    // assertion is about.67+    expect(encoded.length).toBe(atCeiling);68+    expect(decodePeachScript(encoded)[atCeiling - 1]).toBe(bytes[atCeiling - 1]);69+  });70+71+  test("a 4 MiB envelope is refused — it would download as ~12 MiB", () => {72+    const fourMiB = 4 * 1024 * 1024;73+    expect(() => encodePeachScript(new Uint8Array(fourMiB))).toThrow(74+      "PEACHSCRIPT_TOO_LARGE",75+    );76+  });77+});78+3279 describe("peachScript", () => {3380   test("byte 0 maps to U+E000", () => {3481     const out = encodePeachScript(new Uint8Array([0]));

view file

src/crypto/peachScript.ts+15 −0
@@ -20,8 +20,23 @@2020 // pasted or loaded string allocates without bound before any validation.2121 import { IMPORT_LIMITS } from "../shared/import-limits";2222 23+/**24+ * Every encoded code point lands in U+E000..U+E0FF, which UTF-8 encodes as25+ * exactly three bytes.26+ *27+ * PB-26-113: the importer bounds the FILE, so the export ceiling has to be28+ * measured in encoded bytes, not envelope bytes. Encoding an N-byte envelope29+ * produced a 3N-byte .peach file, so a 4 MiB vault exported a ~12 MiB backup30+ * that Peach's own importer then rejected outright — a backup that fails only31+ * when it is needed, years later.32+ */33+const UTF8_BYTES_PER_CODE_POINT = 3;34+2335 /** Encode a binary payload to a PeachScript string (one byte per code point). */2436 export function encodePeachScript(bytes: Uint8Array): string {37+  if (bytes.length * UTF8_BYTES_PER_CODE_POINT > IMPORT_LIMITS.MAX_JSON_BYTES) {38+    throw new Error("PEACHSCRIPT_TOO_LARGE");39+  }2540   const out: string[] = [];2641   for (let i = 0; i < bytes.length; i++) {2742     out.push(String.fromCodePoint(BASE + bytes[i]));

view file

src/popup/views/Export.tsx+5 −6
@@ -7,7 +7,6 @@77 import { sendMessage } from "../App";88 import { buildPayload } from "../../crypto/peachPayload";99 import { encodePeachScript } from "../../crypto/peachScript";10-import { IMPORT_LIMITS } from "../../shared/import-limits";1110 import { RuneCanvas } from "../components/RuneCanvas";1211 import type { PeachLicenseExport, VaultEntry } from "../../shared/types";1312 @@ -157,14 +156,14 @@157156       const env = await assembleEnvelope(pass());158157       setEntryCount(env.entryCount);159158       setStats(env.stats);160-      if (env.bytes.length > IMPORT_LIMITS.MAX_JSON_BYTES) {161-        throw new Error("PEACHSCRIPT_EXPORT_TOO_LARGE");162-      }159+      // PB-26-113: the ceiling is enforced by the encoder, which measures the160+      // encoded file's UTF-8 bytes rather than the envelope's — encoding three161+      // bytes per envelope byte is what makes the two differ.163162       setScript(encodePeachScript(env.bytes));164163     } catch (err) {165164       setError(166-        err instanceof Error && err.message === "PEACHSCRIPT_EXPORT_TOO_LARGE"167-          ? "This backup is too large to import later (limit: 10 MiB)."165+        err instanceof Error && err.message === "PEACHSCRIPT_TOO_LARGE"166+          ? "This vault is too large for a PeachScript backup — use Codex export instead."168167           : "Couldn't create PeachScript backup.",169168       );170169     } finally {

view file