99c85210eeb0

fix: Restore recovery password flow (v3.9.3)

time
2026-06-11 16:43 UTC · 4 months ago
machine
koh2
parent
4d43c82d69fd
6 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/components/RecoveryRuneBackground.tsx+6 −3src/popup/views/RecoveryWordEntry.tsx+48 −36src/popup/views/contracts.test.tsx+72 −0
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.9.2",5+  "version": "3.9.3",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.9.2",4+  "version": "3.9.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": "3.9.2",3+  "version": "3.9.3",44   "description": "Zero-knowledge password manager",55   "license": "GPL-3.0-only",66   "type": "module",

view file

src/popup/components/RecoveryRuneBackground.tsx+6 −3
@@ -41,13 +41,14 @@4141 }4242 4343 export function RecoveryRuneBackground(props: RecoveryRuneBackgroundProps) {44-  let canvas!: HTMLCanvasElement;44+  let canvas: HTMLCanvasElement | undefined;4545   let raf = 0;4646   let running = false;4747   // Track per-row accumulated time so we get erratic (non-linear) drift4848   const rowTime: number[] = [];4949 5050   onMount(() => {51+    if (!canvas) return;5152     const ctx = canvas.getContext("2d");5253     if (!ctx) return;5354 @@ -144,11 +145,13 @@144145 145146   return (146147     <canvas147-      ref={canvas}148+      ref={(el) => {149+        canvas = el;150+      }}148151       width={320}149152       height={520}150153       class="peach-rune-background peach-rune-background--recovery"151154       aria-hidden="true"152155     />153156   );154-}\ No newline at end of file157+}

view file

src/popup/views/RecoveryWordEntry.tsx+48 −36
@@ -11,7 +11,7 @@1111 1212 interface RecoveryWordEntryViewProps {1313   onCancel: () => void;14-  onRecoveryComplete: () => void;14+  onRecoveryComplete: () => void | Promise<void>;1515 }1616 1717 export function RecoveryWordEntryView(props: RecoveryWordEntryViewProps) {@@ -135,29 +135,37 @@135135     setLoading(true);136136     setError("");137137 138-    // Validate against the word list139-    const { findInvalidWords } = await import("../../shared/recovery-words");140-    const invalid = findInvalidWords(normalised);141-    if (invalid.length > 0) {142-      setError(`"${invalid[0].word}" is not a valid recovery word`);143-      setLoading(false);144-      return;145-    }138+    try {139+      // Validate against the word list first so we can reject obvious typos140+      // before asking the background to do the checksum/recovery work.141+      const { findInvalidWords } = await import("../../shared/recovery-words");142+      const invalid = findInvalidWords(normalised);143+      if (invalid.length > 0) {144+        setError(`"${invalid[0].word}" is not a valid recovery word`);145+        return;146+      }146147 147-    // Ask the background to validate the checksum + try to unwrap148-    const res = (await sendMessage({149-      type: "VALIDATE_RECOVERY_WORDS",150-      payload: { words: normalised },151-    })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };148+      // Ask the background to validate the checksum + try to unwrap.149+      const res = (await sendMessage({150+        type: "VALIDATE_RECOVERY_WORDS",151+        payload: { words: normalised },152+      })) as { ok: boolean; data?: { valid: boolean; invalid: { index: number }[] } };152153 153-    if (!res.data?.valid) {154-      setError("invalid recovery words — check for typos");154+      if (!res.ok) {155+        setError("could not verify recovery words");156+        return;157+      }158+      if (!res.data?.valid) {159+        setError("invalid recovery words — check for typos");160+        return;161+      }162+163+      setStep("new-password");164+    } catch {165+      setError("could not verify recovery words");166+    } finally {155167       setLoading(false);156-      return;157168     }158-159-    setLoading(false);160-    setStep("new-password");161169   }162170 163171   async function submitNewPassword() {@@ -177,25 +185,29 @@177185     setNewPasswordLoading(true);178186     setNewPasswordError("");179187 180-    const res = (await sendMessage({181-      type: "RECOVER_WITH_WORDS",182-      payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },183-    })) as { ok: boolean; error?: string };188+    try {189+      const res = (await sendMessage({190+        type: "RECOVER_WITH_WORDS",191+        payload: { words: words().map((w) => w.toLowerCase().trim()), newPassword: newPassword() },192+      })) as { ok: boolean; error?: string };184193 185-    if (!res.ok) {186-      if (res.error === "recovery_not_setup") {187-        setNewPasswordError("this vault was created before recovery was available — restore from backup instead");188-      } else if (res.error === "invalid_recovery_words") {189-        setNewPasswordError("invalid recovery words");190-      } else {191-        setNewPasswordError(getDisplayError(res.error, "recovery failed"));194+      if (!res.ok) {195+        if (res.error === "recovery_not_setup") {196+          setNewPasswordError("this vault was created before recovery was available — restore from backup instead");197+        } else if (res.error === "invalid_recovery_words") {198+          setNewPasswordError("invalid recovery words");199+        } else {200+          setNewPasswordError(getDisplayError(res.error, "recovery failed"));201+        }202+        return;192203       }204+205+      await Promise.resolve(props.onRecoveryComplete());206+    } catch {207+      setNewPasswordError("recovery failed");208+    } finally {193209       setNewPasswordLoading(false);194-      return;195210     }196-197-    setNewPasswordLoading(false);198-    props.onRecoveryComplete();199211   }200212 201213   return (@@ -339,4 +351,4 @@339351     default:340352       return fallback;341353   }342-}\ No newline at end of file354+}

view file

src/popup/views/contracts.test.tsx+72 −0
@@ -5,12 +5,14 @@55 66 import type { ImportParseResult } from "../../shared/import";77 import type { VaultEntry } from "../../shared/types";8+import { WORD_LIST } from "../../shared/recovery-word-list";89 import { LockView } from "./Lock";910 import { ExportView } from "./Export";1011 import { SettingsView } from "./Settings";1112 import { DEFAULT_BINDINGS } from "../../shared/shortcuts";1213 import { EntryView } from "./Entry";1314 import { ImportView } from "./Import";15+import { RecoveryWordEntryView } from "./RecoveryWordEntry";1416 import { VaultView } from "./Vault";1517 import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";1618 @@ -93,6 +95,7 @@9395     while (disposers.length > 0) {9496       disposers.pop()?.();9597     }98+    vi.restoreAllMocks();9699     vi.unstubAllGlobals();97100   });98101 @@ -277,6 +280,75 @@277280     // After CREATE_VAULT success, the recovery word display is shown.278281     // onUnlock is NOT called yet — user must first confirm their recovery words.279282     expect(onUnlock).not.toHaveBeenCalled();283+  });284+285+  it("Recovery words flow submits the new master password and completes", async () => {286+    sendMessageMock.mockImplementation(async (msg: unknown) => {287+      const type = (msg as { type?: string }).type;288+      if (type === "VALIDATE_RECOVERY_WORDS") {289+        return { ok: true, data: { valid: true, invalid: [] } };290+      }291+      if (type === "RECOVER_WITH_WORDS") return { ok: true };292+      return { ok: true };293+    });294+    const canvasContext = {295+      clearRect: vi.fn(),296+      fillText: vi.fn(),297+      beginPath: vi.fn(),298+      closePath: vi.fn(),299+      arc: vi.fn(),300+      fill: vi.fn(),301+      stroke: vi.fn(),302+      save: vi.fn(),303+      restore: vi.fn(),304+      translate: vi.fn(),305+      rotate: vi.fn(),306+      scale: vi.fn(),307+      drawImage: vi.fn(),308+      measureText: vi.fn(() => ({ width: 0 })),309+      setTransform: vi.fn(),310+      fillRect: vi.fn(),311+      strokeRect: vi.fn(),312+    } as unknown as CanvasRenderingContext2D;313+    vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => canvasContext);314+315+    const onRecoveryComplete = vi.fn(async () => undefined);316+    const container = mount(317+      <RecoveryWordEntryView318+        onCancel={vi.fn()}319+        onRecoveryComplete={onRecoveryComplete}320+      />321+    );322+323+    const recoveryWords = WORD_LIST.slice(0, 12);324+    for (let i = 0; i < recoveryWords.length; i++) {325+      const input = container.querySelector(`#recovery-word-${i}`) as HTMLInputElement;326+      inputText(input, recoveryWords[i]!);327+    }328+    await flushView();329+330+    findButton(container, "verify words").click();331+    await flushView();332+333+    expect(container.textContent).toContain("new master password");334+335+    const newPassword = container.querySelector("#recovery-new-password") as HTMLInputElement;336+    const confirmPassword = container.querySelector("#recovery-confirm-password") as HTMLInputElement;337+    inputText(newPassword, "fresh-recovery-password");338+    inputText(confirmPassword, "fresh-recovery-password");339+    await flushView();340+341+    findButton(container, "recover vault").click();342+    await flushView();343+344+    expect(sendMessageMock).toHaveBeenCalledWith({345+      type: "RECOVER_WITH_WORDS",346+      payload: {347+        words: recoveryWords,348+        newPassword: "fresh-recovery-password",349+      },350+    });351+    expect(onRecoveryComplete).toHaveBeenCalled();280352   });281353 282354   it("Export menu surfaces both backup formats", async () => {

view file