15d513af25f5
randomize recovery-word confirmation positions, un-cram recovery screen wordmark onto the shared 16px grid, update e2e helpers to read positions from labels, bump 4.10.13
- time
- 2026-08-20 14:34 UTC · 2 months ago
- machine
- koh2
- agent
- qwen3.8-max
- session
- peach ui fixes
- parent
- bee089d57362
↓ peach-browser-15d513af.tar.gzatlas at this save
files changed8 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/popup/styles/popup.css+4 −2src/popup/views/RecoveryWordDisplay.tsx+19 −5src/popup/views/contracts.test.tsx+53 −0tests/helpers/vault.ts+12 −5tests/lock-screen.spec.ts+6 −1manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.10.12",4+ "version": "4.10.13",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.10.12",5+ "version": "4.10.13",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.10.12",3+ "version": "4.10.13",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/popup/styles/popup.css+4 −2
@@ -8420,7 +8420,7 @@84208420 .peach-recovery-header {84218421 text-align: center;84228422 margin-bottom: 16px;8423- margin-top: 4px;8423+ margin-top: 12px;84248424 }84258425 .peach-recovery-header .peach-onboarding-headline {84268426 font-size: 1.25rem;@@ -8507,7 +8507,9 @@85078507 display: flex;85088508 flex-direction: column;85098509 align-items: stretch;8510- padding: 0 20px 20px;8510+ /* Same top/left padding as the onboarding screens so the wordmark sits8511+ on the same pixel instead of crowding the popup edge. */8512+ padding: 16px 16px 16px;85118513 min-height: 0;85128514 overflow-y: auto;85138515 }src/popup/views/RecoveryWordDisplay.tsx+19 −5
@@ -8,10 +8,24 @@88 onDone: () => void;99 }1010 11-// After showing the words, user must type the 3 words at these positions12-const CONFIRM_WORD_INDICES = [2, 6, 10]; // 0-indexed → positions 3, 7, 1111+// Three random positions are confirmed each time this screen mounts, so a12+// user can't get away with only memorizing a fixed set of slots.13+function pickConfirmIndices(): number[] {14+ const pool = Array.from({ length: 12 }, (_, i) => i);15+ const rand = new Uint32Array(pool.length);16+ crypto.getRandomValues(rand);17+ for (let i = pool.length - 1; i > 0; i--) {18+ const j = (rand[i] ?? 0) % (i + 1);19+ const tmp = pool[i]!;20+ pool[i] = pool[j]!;21+ pool[j] = tmp;22+ }23+ return pool.slice(0, 3).sort((a, b) => a - b);24+}1325 1426 export function RecoveryWordDisplayView(props: RecoveryWordDisplayViewProps) {27+ // Chosen once per mount; the labels and the validation must agree.28+ const confirmIndices = pickConfirmIndices();1529 const [words, setWords] = createSignal<string[]>([]);1630 const [error, setError] = createSignal("");1731 const [loading, setLoading] = createSignal(true);@@ -71,8 +85,8 @@7185 7286 // Verify against stored words7387 const stored = words();74- for (let i = 0; i < CONFIRM_WORD_INDICES.length; i++) {75- const pos = CONFIRM_WORD_INDICES[i];88+ for (let i = 0; i < confirmIndices.length; i++) {89+ const pos = confirmIndices[i]!;7690 if (confirmed[i] !== stored[pos]) {7791 setConfirmError(`word ${pos + 1} is incorrect`);7892 return;@@ -174,7 +188,7 @@174188 </Show>175189 176190 <div class="peach-recovery-confirm-fields">177- <For each={CONFIRM_WORD_INDICES}>191+ <For each={confirmIndices}>178192 {(pos, i) => (179193 <div class="peach-recovery-confirm-field">180194 <span class="peach-recovery-confirm-num">src/popup/views/contracts.test.tsx+53 −0
@@ -15,6 +15,7 @@1515 import { AddView } from "./Add";1616 import { NotesEditor } from "./NotesEditor";1717 import { RecoveryWordEntryView } from "./RecoveryWordEntry";18+import { RecoveryWordDisplayView } from "./RecoveryWordDisplay";1819 import { VaultView } from "./Vault";1920 import { changeSelect, flushView, inputText, pressEnter, renderView } from "./view-test-utils";2021 @@ -434,6 +435,58 @@434435 // After CREATE_VAULT success, the recovery word display is shown.435436 // onUnlock is NOT called yet — user must first confirm their recovery words.436437 expect(onUnlock).not.toHaveBeenCalled();438+ });439+440+ it("recovery display asks for three distinct randomized word positions and confirms", async () => {441+ const words12 = [442+ "abandon", "ability", "able", "about", "above", "absent",443+ "absorb", "abstract", "absurd", "abuse", "access", "accident",444+ ];445+ sendMessageMock.mockImplementation(async (msg: unknown) => {446+ const type = (msg as { type?: string }).type;447+ if (type === "GET_RECOVERY_WORDS") return { ok: true, data: words12 };448+ return { ok: true };449+ });450+451+ vi.useFakeTimers();452+ const onDone = vi.fn();453+ try {454+ const container = mount(<RecoveryWordDisplayView onDone={onDone} />);455+ // onMount fires GET_RECOVERY_WORDS; let the resolved promise + render settle456+ await vi.advanceTimersByTimeAsync(0);457+458+ const prompts = Array.from(459+ container.querySelectorAll(".peach-recovery-confirm-num")460+ ).map((el) => Number.parseInt((el.textContent ?? "").replace(/\D+/g, ""), 10));461+ expect(prompts).toHaveLength(3);462+ expect(new Set(prompts).size).toBe(3);463+ for (const p of prompts) {464+ expect(p).toBeGreaterThanOrEqual(1);465+ expect(p).toBeLessThanOrEqual(12);466+ }467+468+ // Inputs stay disabled until the 5s countdown expires469+ const inputs = Array.from(470+ container.querySelectorAll(".peach-recovery-confirm-input")471+ ) as HTMLInputElement[];472+ expect(inputs).toHaveLength(3);473+ expect(inputs[0]!.disabled).toBe(true);474+ await vi.advanceTimersByTimeAsync(5000);475+ expect(inputs[0]!.disabled).toBe(false);476+477+ prompts.forEach((pos, i) => {478+ inputText(inputs[i]!, words12[pos - 1]!);479+ });480+481+ const confirmBtn = container.querySelector(".peach-recovery-confirm-btn") as HTMLButtonElement;482+ expect(confirmBtn.disabled).toBe(false);483+ confirmBtn.click();484+ await vi.advanceTimersByTimeAsync(700); // success pause before onDone485+486+ expect(onDone).toHaveBeenCalledTimes(1);487+ } finally {488+ vi.useRealTimers();489+ }437490 });438491 439492 it("Recovery words flow submits the new master password and completes", async () => {tests/helpers/vault.ts+12 −5
@@ -124,11 +124,18 @@124124 await expect(confirmInputs.first()).toBeEnabled({ timeout: 30000 });125125 126126 const words = await recoveryWords.allTextContents();127- const confirmedWords = [words[2], words[6], words[10]];128- for (let i = 0; i < confirmedWords.length; i++) {129- const word = confirmedWords[i]?.trim();130- if (!word) throw new Error(`missing recovery word at position ${i}`);131- await confirmInputs.nth(i).fill(word);127+ // The three positions are randomized per mount, so read each field's128+ // "word N" label rather than assuming fixed slots.129+ const fields = popup.locator(".peach-recovery-confirm-field");130+ const fieldCount = await fields.count();131+ if (fieldCount !== 3) throw new Error(`expected 3 confirm fields, saw ${fieldCount}`);132+ for (let i = 0; i < fieldCount; i++) {133+ const field = fields.nth(i);134+ const numText = await field.locator(".peach-recovery-confirm-num").innerText();135+ const pos = parseInt(numText.replace(/\D/g, ""), 10);136+ const word = words[pos - 1]?.trim();137+ if (!word) throw new Error(`missing recovery word for position ${pos}`);138+ await field.locator(".peach-recovery-confirm-input").fill(word);132139 }133140 134141 await expect(confirmButton).toBeEnabled({ timeout: 2000 });tests/lock-screen.spec.ts+6 −1
@@ -111,7 +111,12 @@111111 112112 await expect(confirmInputs.first()).toBeEnabled({ timeout: 7000 });113113 const words = await recoveryWords.allTextContents();114- const firstWord = words[2]?.trim();114+ // Positions are randomized per mount — read the first field's label.115+ const firstPos = parseInt(116+ (await popup.locator(".peach-recovery-confirm-num").first().innerText()).replace(/\D/g, ""),117+ 10,118+ );119+ const firstWord = words[firstPos - 1]?.trim();115120 if (!firstWord) throw new Error("missing recovery word for close/reopen smoke test");116121 await confirmInputs.first().fill(firstWord);117122