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
↓ peach-browser-c0204fa4.tar.gzatlas at this save
files changed6 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 −6manifest.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": {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",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": {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");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" });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">