33473d579a29
pin the shared recovery-word vector and the 2048-unique-list invariant on the browser side, so a future divergence fails a test instead of surfacing when someone tries to restore (v4.1.12)
- time
- 2026-07-26 14:22 UTC · 3 months ago
- machine
- koh2
- agent
- claude-opus-5
- session
- recovery word divergence
- parent
- 236066499ad0
↓ peach-browser-33473d57.tar.gzatlas at this save
files changed4 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/recovery-words.cross-platform.test.ts+62 −0manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.11",4+ "version": "4.1.12",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.1.11",4+ "version": "4.1.12",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": "4.1.11",3+ "version": "4.1.12",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/shared/recovery-words.cross-platform.test.ts+62 −0
@@ -0,0 +1,62 @@1+import { describe, it, expect } from "vitest";2+3+import { WORD_LIST } from "./recovery-word-list";4+import { rootKeyToWords, wordsToRootKeyMaterial } from "./recovery-words";5+6+/**7+ * Recovery words are only a human-readable encoding of the root key's first 168+ * bytes, so browser and Android MUST agree on the word list byte for byte.9+ * They did not: Android shipped a list carrying a duplicate "abandon" at index10+ * 1, which made it this list shifted by one from index 1 onward. The same11+ * vault therefore printed different words on each platform, and a phrase12+ * written down on the phone restored nothing here.13+ *14+ * The vector below is pinned identically in Android's15+ * `CrossPlatformRecoveryWordsTest`. If either side changes its list, one of16+ * these two tests fails instead of a user discovering it at restore time.17+ */18+describe("recovery words — cross-platform word list", () => {19+ const VECTOR_ROOT_KEY =20+ "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff";21+22+ const VECTOR_WORDS = [23+ "abandon", "math", "mimic", "master", "filter", "design",24+ "carbon", "crystal", "rookie", "group", "knife", "young",25+ ];26+27+ function hexToBytes(hex: string): Uint8Array {28+ const out = new Uint8Array(hex.length / 2);29+ for (let i = 0; i < out.length; i++) {30+ out[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);31+ }32+ return out;33+ }34+35+ it("is the canonical 2048-word list with no duplicates", () => {36+ expect(WORD_LIST.length).toBe(2048);37+ expect(new Set(WORD_LIST).size).toBe(2048);38+ expect(WORD_LIST[0]).toBe("abandon");39+ expect(WORD_LIST[1]).toBe("ability");40+ expect(WORD_LIST[2047]).toBe("zoo");41+ });42+43+ it("produces the shared vector Android pins", async () => {44+ expect(await rootKeyToWords(hexToBytes(VECTOR_ROOT_KEY))).toEqual(VECTOR_WORDS);45+ });46+47+ it("round-trips every reachable 11-bit index", () => {48+ // The Android defect lived exactly here: index 1 encoded to "abandon" and49+ // decoded back to index 0.50+ for (let index = 0; index < 2048; index++) {51+ expect(WORD_LIST.indexOf(WORD_LIST[index]!)).toBe(index);52+ }53+ });54+55+ it("round-trips random root keys without exception", async () => {56+ for (let i = 0; i < 500; i++) {57+ const key = crypto.getRandomValues(new Uint8Array(32));58+ const material = await wordsToRootKeyMaterial(await rootKeyToWords(key));59+ expect(material.slice(0, 16)).toEqual(key.slice(0, 16));60+ }61+ });62+});