971d0d

fix: require confirm-password in encrypted export before enabling download (v1.5.3)

time
2026-04-23 22:12 UTC · 6 months ago
machine
koh2
parent
aa90db
5 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/views/Export.tsx+20 −4src/popup/views/contracts.test.tsx+21 −0
manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.5.2",4+  "version": "1.5.3",55   "description": "Zero-knowledge password manager",66   "browser_specific_settings": {77     "gecko": {

view file

manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "1.5.2",4+  "version": "1.5.3",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": "1.5.2",3+  "version": "1.5.3",44   "description": "Zero-knowledge password manager",55   "type": "module",66   "scripts": {

view file

src/popup/views/Export.tsx+20 −4
@@ -25,21 +25,23 @@2525 2626 function MainExportView(props: { onBack: () => void; onOpenCjk: () => void }) {2727   const [exportPassword, setExportPassword] = createSignal("");28+  const [exportPasswordConfirm, setExportPasswordConfirm] = createSignal("");2829   const [exporting, setExporting] = createSignal(false);2930   const [result, setResult] = createSignal<string | null>(null);3031   const [error, setError] = createSignal<string | null>(null);3132   const [showCsvWarning, setShowCsvWarning] = createSignal(false);3233 33-  const passwordIsValid = createMemo(34+  const passwordsMatch = createMemo(3435     () => exportPassword().length >= MIN_EXPORT_PASSWORD36+      && exportPassword() === exportPasswordConfirm()3537   );3638 3739   async function handleEncryptedExport() {3840     setError(null);3941     setResult(null);4042 41-    if (exportPassword().length < MIN_EXPORT_PASSWORD) {42-      setError(`Export password must be at least ${MIN_EXPORT_PASSWORD} characters.`);43+    if (!passwordsMatch()) {44+      setError("Passwords do not match or are too short.");4345       return;4446     }4547 @@ -72,6 +74,7 @@7274         `Exported ${countStr}. Filename: ${filename}. Store this file and your export password somewhere safe — neither can be recovered.`7375       );7476       setExportPassword("");77+      setExportPasswordConfirm("");7578     } catch (err) {7679       setError(err instanceof Error ? err.message : "Export failed.");7780     } finally {@@ -181,6 +184,19 @@181184               value={exportPassword()}182185               onInput={(e) => setExportPassword((e.target as HTMLInputElement).value)}183186             />187+            <input188+              id="export-confirm-password"189+              class="peach-settings-input"190+              type="password"191+              placeholder="confirm backup password"192+              value={exportPasswordConfirm()}193+              onInput={(e) => setExportPasswordConfirm((e.target as HTMLInputElement).value)}194+            />195+            <Show when={exportPasswordConfirm().length > 0 && exportPassword() !== exportPasswordConfirm()}>196+              <p class="peach-export-note" style="color:var(--peach-t2)">197+                Passwords don't match198+              </p>199+            </Show>184200             <Show when={exportPassword().length > 0 && exportPassword().length < MIN_EXPORT_PASSWORD}>185201               <p class="peach-export-note" style="color:var(--peach-t2)">186202                 At least {MIN_EXPORT_PASSWORD} characters@@ -190,7 +206,7 @@190206           <button191207             class="peach-btn peach-btn--subtle peach-btn--sm"192208             onClick={() => void handleEncryptedExport()}193-            disabled={exporting() || !passwordIsValid()}209+            disabled={exporting() || !passwordsMatch()}194210           >195211             {exporting() ? "saving…" : "Download backup file"}196212           </button>

view file

src/popup/views/contracts.test.tsx+21 −0
@@ -239,17 +239,36 @@239239     const container = mount(<ExportView onBack={vi.fn()} />);240240 241241     const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;242+    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;242243     const button = Array.from(container.querySelectorAll("button")).find(243244       (candidate) => candidate.textContent?.trim() === "Download backup file"244245     ) as HTMLButtonElement;245246 246247     inputText(password, "too-short");248+    inputText(confirm, "too-short");247249     await flushView();248250 249251     expect(button.disabled).toBe(true);250252     expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();251253   });252254 255+  it("Export keeps encrypted export disabled when passwords do not match", async () => {256+    const container = mount(<ExportView onBack={vi.fn()} />);257+258+    const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;259+    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;260+    const button = Array.from(container.querySelectorAll("button")).find(261+      (candidate) => candidate.textContent?.trim() === "Download backup file"262+    ) as HTMLButtonElement;263+264+    inputText(password, "correct horse battery staple");265+    inputText(confirm, "different horse battery staple");266+    await flushView();267+268+    expect(button.disabled).toBe(true);269+    expect(exportEntriesToPeachJsonMock).not.toHaveBeenCalled();270+  });271+253272   it("Export requests entries and sends the correct payload to the encrypted backup exporter", async () => {254273     sendMessageMock.mockResolvedValue({255274       ok: true,@@ -262,11 +281,13 @@262281     const container = mount(<ExportView onBack={vi.fn()} />);263282 264283     const password = container.querySelector('input[placeholder*="backup password"]') as HTMLInputElement;284+    const confirm = container.querySelector("#export-confirm-password") as HTMLInputElement;265285     const button = Array.from(container.querySelectorAll("button")).find(266286       (candidate) => candidate.textContent?.trim() === "Download backup file"267287     ) as HTMLButtonElement;268288 269289     inputText(password, "correct horse battery staple");290+    inputText(confirm, "correct horse battery staple");270291     await flushView();271292     button.click();272293     await flushView();

view file