0def51186dd4

fix: package the Chrome archive with the deterministic ZIP writer instead of system zip (audit PB-26-611, v5.1.20)

time
2026-09-06 15:51 UTC · 1 month ago
machine
koh2
agent
openrouter/tencent/hy4-preview
session
PB-26-601..611 audit closeout
parent
261aa512aa67
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1scripts/release-lib.test.ts+44 −3scripts/release-lib.ts+35 −31
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "5.1.19",4+  "version": "5.1.20",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.1.19",5+  "version": "5.1.20",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.1.19",3+  "version": "5.1.20",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

scripts/release-lib.test.ts+44 −3
@@ -1,6 +1,7 @@11 // scripts/release-lib.test.ts22 // PB-26-211: release tool hardening — security tests for the argv-based runner.3-import { readFileSync } from "node:fs";3+import { createHash } from "node:crypto";4+import { existsSync, readFileSync, rmSync } from "node:fs";45 import { fileURLToPath } from "node:url";56 import { dirname, join, resolve } from "node:path";67 import { afterEach, describe, expect, it } from "vitest";@@ -85,11 +86,13 @@8586 8687     for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");8788 88-    packageChromeZip("9.9.9", fake);89+    packageChromeZip("9.9.9");8990     uploadChrome("9.9.9", fake);9091     signFirefox("/tmp/fake-source.zip", fake);9192 92-    expect(captured.length).toBeGreaterThanOrEqual(3);93+    // packageChromeZip no longer shells out at all (PB-26-611), so only the94+    // upload and the AMO sign remain.95+    expect(captured.length).toBeGreaterThanOrEqual(2);9396     for (const spec of captured) {9497       expect(spec.executable).not.toBe("npx");9598       expect(spec.args).not.toContain("npx");@@ -267,3 +270,41 @@267270     }268271   });269272 });273+274+// ─── 7. PB-26-611 reproducible Chrome archive ──────────────────────────────275+276+describe.skipIf(!existsSync(join(ROOT, "dist-chrome", "manifest.json")))(277+  "PB-26-611: Chrome extension archive is reproducible",278+  () => {279+    afterEach(() => {280+      const zipPath = join(ROOT, "artifacts", "peach-chrome-9.9.9.zip");281+      if (existsSync(zipPath)) rmSync(zipPath);282+    });283+284+    it("two archives built from the same dist are byte-identical", () => {285+      const first = readFileSync(packageChromeZip("9.9.9"));286+      const second = readFileSync(packageChromeZip("9.9.9"));287+288+      expect(first.length).toBeGreaterThan(0);289+      expect(createHash("sha256").update(first).digest("hex")).toBe(290+        createHash("sha256").update(second).digest("hex"),291+      );292+    });293+294+    it("embeds no source maps, .DS_Store or __MACOSX entries", () => {295+      const zip = packageChromeZip("9.9.9");296+      const bytes = readFileSync(zip);297+      const text = bytes.toString("latin1");298+299+      expect(text).not.toContain(".map");300+      expect(text).not.toContain(".DS_Store");301+      expect(text).not.toContain("__MACOSX");302+    });303+304+    it("release-lib no longer shells out to the system zip binary", () => {305+      const source = readFileSync(join(ROOT, "scripts", "release-lib.ts"), "utf8");306+      expect(source).not.toContain('"zip"');307+      expect(source).toContain("createDeterministicZip");308+    });309+  },310+);

view file

scripts/release-lib.ts+35 −31
@@ -3,9 +3,11 @@33 // No shell, no online resolution, no inherited store credentials.44 55 import { spawnSync } from "node:child_process";6-import { existsSync, mkdirSync, readFileSync } from "node:fs";7-import { basename, join } from "node:path";6+import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";7+import { basename, join, relative, sep } from "node:path";88 9+import { createDeterministicZip } from "./package-source";10+911 const ROOT = process.cwd();1012 const BIN_DIR = join(ROOT, "node_modules", ".bin");1113 @@ -208,35 +210,37 @@208210   return dir;209211 }210212 211-export function packageChromeZip(212-  version: string,213-  runner: Runner = runCommand,214-): string {215-  const artifactsDir = ensureArtifactsDir();216-  const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);217-  if (existsSync(zipPath)) {218-    runner("trash", [zipPath], {219-      label: "Moving existing Chrome archive to Trash",220-    });221-  }222-  runner(223-    "zip",224-    [225-      "-r",226-      zipPath,227-      ".",228-      "--exclude",229-      "*.map",230-      "--exclude",231-      ".DS_Store",232-      "--exclude",233-      "__MACOSX/*",234-    ],235-    {236-      cwd: join(ROOT, "dist-chrome"),237-      label: `Packaging Chrome extension → peach-chrome-${version}.zip`,238-    },213+/** Matches the exclusions the previous system-`zip` call applied. */214+function isPackagingNoise(relativePath: string): boolean {215+  return (216+    relativePath.endsWith(".map") ||217+    basename(relativePath) === ".DS_Store" ||218+    relativePath.startsWith(`__MACOSX${sep}`) ||219+    relativePath.startsWith("__MACOSX/")239220   );221+}222+223+function collectDirectory(dir: string): Array<{ path: string; data: Buffer }> {224+  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {225+    const full = join(dir, entry.name);226+    if (entry.isDirectory()) return collectDirectory(full);227+    return [{ path: relative(dir, full).split(sep).join("/"), data: readFileSync(full) }];228+  });229+}230+231+/**232+ * PB-26-611: the system `zip` embedded filesystem timestamps, so two builds of233+ * the same commit produced different archives and the shipped Chrome artifact234+ * could not be verified by hash. Reuses the deterministic writer the AMO235+ * source archive already uses — fixed DOS timestamps, deflate level 9, sorted236+ * entries.237+ */238+export function packageChromeZip(version: string): string {239+  const zipPath = join(ensureArtifactsDir(), `peach-chrome-${version}.zip`);240+  const entries = collectDirectory(join(ROOT, "dist-chrome")).filter(241+    (entry) => !isPackagingNoise(entry.path),242+  );243+  writeFileSync(zipPath, createDeterministicZip(entries));240244   return zipPath;241245 }242246 @@ -244,7 +248,7 @@244248   version: string,245249   runner: Runner = runCommand,246250 ): void {247-  const zipPath = packageChromeZip(version, runner);251+  const zipPath = packageChromeZip(version);248252   const extensionId = requireEnv("CWS_EXTENSION_ID");249253   runner(250254     resolveBin("chrome-webstore-upload"),

view file