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
8 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 −1
manifest.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": {

view file

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",

view file

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",

view file

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 }

view file

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">

view file

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 () => {

view file

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 });

view file

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 

view file