c0204fa478df

fix(security): CSV/JSON import requires a real master password when no vault exists; CREATE_VAULT handler rejects empty password — no more empty-key vault with zero at-rest protection (HIGH) (v3.6.124)

time
2026-05-28 22:09 UTC · 5 months ago
machine
koh2
parent
819c2b01c71c
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/messages.test.ts+8 −0src/background/messages.ts+7 −0src/popup/views/Lock.tsx+43 −6
manifest.firefox.json+1 −1
@@ -2,7 +2,7 @@22   "_comment_audio_capture": "Firefox does not honor the Chrome 'audioCapture' optional_permission — getUserMedia in WebExtensions is gated solely by the runtime permission prompt. Microphone access for chirp pairing works from the popup under the existing '<all_urls>' host permission; no manifest entry is required or accepted here.",33   "manifest_version": 3,44   "name": "Peach Password Manager",5-  "version": "3.6.123",5+  "version": "3.6.124",66   "description": "Zero-knowledge password manager",77   "browser_specific_settings": {88     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach Password Manager",4-  "version": "3.6.123",4+  "version": "3.6.124",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

package.json+1 −1
@@ -1,6 +1,6 @@11 {22   "name": "peach",3-  "version": "3.6.123",3+  "version": "3.6.124",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/background/messages.test.ts+8 −0
@@ -790,6 +790,14 @@790790     expect(storage.peach_pro_celebrated).toBe(true);791791   });792792 793+  it("CREATE_VAULT rejects an empty master password (audit HIGH — no empty-key vault)", async () => {794+    const res = await dispatch({ type: "CREATE_VAULT", payload: { password: "" } });795+    expect(res.ok).toBe(false);796+    // Nothing was written — no vault exists.797+    const has = await dispatch({ type: "HAS_VAULT" });798+    expect((has as { data?: boolean }).data).toBe(false);799+  });800+793801   it("GET_ENTRY_FOR_FILL sends a 6-digit TOTP code, never the seed (SEV-3-C)", async () => {794802     const vault = await import("./vault");795803     await vault.createVault("correct-pass-12345");

view file

src/background/messages.ts+7 −0
@@ -223,6 +223,13 @@223223     switch (msg.type) {224224       case "CREATE_VAULT": {225225         const { password } = msg.payload as { password: string };226+        // Refuse to create a vault under an empty master password — the derived227+        // key would be publicly known and the at-rest AES-GCM protection void228+        // (audit HIGH; the CSV-import path previously passed ""). Length/strength229+        // policy lives in the onboarding UI; this is the non-negotiable floor.230+        if (typeof password !== "string" || password.length === 0) {231+          return { ok: false, error: "invalid_payload" };232+        }226233         await vault.createVault(password);227234         await setupAutoLock();228235         broadcastToContentScripts({ type: "VAULT_UNLOCKED" });

view file

src/popup/views/Lock.tsx+43 −6
@@ -930,12 +930,31 @@930930 function RestoreCSV(props: { hasVault: boolean; onSuccess: () => void | Promise<void> }) {931931   const [importing, setImporting] = createSignal(false);932932   const [error, setError] = createSignal<string | null>(null);933+  // CSV/JSON imports are plaintext and carry no password, so when there is no934+  // vault yet the user MUST choose a master password here — otherwise the935+  // imported credentials would be encrypted under an empty key and offer zero936+  // at-rest protection (audit HIGH). Mirrors the onboarding MIN_LENGTH policy.937+  const CSV_MIN_PASSWORD_LENGTH = 12;938+  const [newPass, setNewPass] = createSignal("");939+  const [confirmPass, setConfirmPass] = createSignal("");940+  const needsPassword = () => props.hasVault === false;941+  const passwordReady = () =>942+    !needsPassword() ||943+    (newPass().length >= CSV_MIN_PASSWORD_LENGTH && newPass() === confirmPass());933944 934945   async function handleFile(e: Event) {935946     const input = e.currentTarget as HTMLInputElement;936947     const file = input.files?.[0];937948     input.value = "";938949     if (!file) return;950+    if (needsPassword() && newPass().length < CSV_MIN_PASSWORD_LENGTH) {951+      setError(`Set a master password of at least ${CSV_MIN_PASSWORD_LENGTH} characters first.`);952+      return;953+    }954+    if (needsPassword() && newPass() !== confirmPass()) {955+      setError("Passwords do not match.");956+      return;957+    }939958     setImporting(true);940959     setError(null);941960     try {@@ -944,10 +963,9 @@944963       if (parsed.entries.length === 0) throw new Error("NO_ENTRIES");945964 946965       if (props.hasVault === false) {947-        // No vault yet — this path shouldn't normally occur for CSV (no backup password948-        // means no way to create a locked vault). Create an unlocked placeholder vault.949-        // The user can set a master password from settings.950-        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: "" } });966+        // Create the vault under the user-chosen master password — never an967+        // empty one (an empty key derives a publicly-known vault key).968+        const cr = await sendMessage({ type: "CREATE_VAULT", payload: { password: newPass() } });951969         if (!(cr as { ok: boolean }).ok) throw new Error("CREATE_VAULT_FAILED");952970       }953971 @@ -971,14 +989,33 @@971989 972990   return (973991     <div class="peach-rt-csv">974-      <label class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn" classList={{ "is-busy": importing() }}>992+      <Show when={needsPassword()}>993+        <input994+          class="peach-restore-symbols-pass"995+          type="password"996+          placeholder="new master password"997+          value={newPass()}998+          onInput={(e) => setNewPass((e.target as HTMLInputElement).value)}999+        />1000+        <input1001+          class="peach-restore-symbols-pass"1002+          type="password"1003+          placeholder="confirm master password"1004+          value={confirmPass()}1005+          onInput={(e) => setConfirmPass((e.target as HTMLInputElement).value)}1006+        />1007+      </Show>1008+      <label1009+        class="peach-btn peach-btn--accent peach-btn--md peach-rt-csv-btn"1010+        classList={{ "is-busy": importing(), "is-disabled": !passwordReady() }}1011+      >9751012         {importing() ? "importing…" : "open csv file"}9761013         <input9771014           type="file"9781015           accept=".csv,.json,.peach,application/x-peach"9791016           style="display:none"9801017           onChange={handleFile}981-          disabled={importing()}1018+          disabled={importing() || !passwordReady()}9821019         />9831020       </label>9841021       <p class="peach-rt-csv-sources">

view file