99c85210eeb0
fix: Restore recovery password flow (v3.9.3)
- time
- 2026-06-11 16:43 UTC · 4 months ago
- machine
- koh2
- parent
- 4d43c82d69fd
↓ peach-browser-99c85210.tar.gzatlas at this save
files changed6 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 −0manifest.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": {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",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",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+}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+}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 () => {