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
↓ peach-browser-0ab2c6b0.tar.gzatlas at this save
files changed6 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 −6manifest.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-}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-}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-}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]));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]));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 {