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
4 files manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/shared/recovery-words.cross-platform.test.ts+62 −0
manifest.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": {

view file

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

view file

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

view file

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

view file