62761aef1e41

fix BIP39 byte extraction and rotate root key on password change

time
2026-05-05 15:45 UTC · 5 months ago
machine
koh2
parent
9a7231c2c15e
4 files manifest.json+1 −1src/background/vault.ts+34 −17src/popup/views/contracts.test.tsx+21 −2src/shared/recovery-words.ts+13 −7
manifest.json+1 −1
@@ -1,7 +1,7 @@11 {22   "manifest_version": 3,33   "name": "Peach",4-  "version": "2.30.2",4+  "version": "2.31.0",55   "description": "Zero-knowledge password manager",66   "action": {77     "default_popup": "src/popup/index.html",

view file

src/background/vault.ts+34 −17
@@ -219,6 +219,7 @@219219  * encode it without re-deriving.220220  */221221 async function generateAndWrapRootKey(): Promise<{222+  rawRootKey: string; // hex of the plaintext root key — caller stores in rootKey222223   recoverySalt: string;223224   wrappedRootKey: string;224225   wrappedNonce: string;@@ -226,16 +227,10 @@226227 }> {227228   const rootKeyBytes = crypto.getRandomValues(new Uint8Array(32));228229 229-  // Encode the root key as 12 BIP39 words for user-facing display.230-  const words = await import("../shared/recovery-words").then((m) =>231-    m.rootKeyToWords(rootKeyBytes)232-  );233-  void words; // returned to caller for getRecoveryWords()234-235-  // Derive the recovery key from the words using HKDF-SHA256.236-  const { wordsToRootKeyMaterial, deriveRecoveryKey } = await import(237-    "../shared/recovery-words"238-  );230+  // Derive the recovery key from the BIP39 words encoding of the root key.231+  const { rootKeyToWords, wordsToRootKeyMaterial, deriveRecoveryKey } =232+    await import("../shared/recovery-words");233+  const words = await rootKeyToWords(rootKeyBytes);239234   const wordMaterial = await wordsToRootKeyMaterial(words);240235   const recoverySalt = crypto.getRandomValues(new Uint8Array(16));241236   const recoveryKey = await deriveRecoveryKey(wordMaterial, recoverySalt);@@ -260,6 +255,7 @@260255   const tag = hex(rawU8.slice(rawU8.length - 16));261256 262257   return {258+    rawRootKey: hex(rootKeyBytes),263259     recoverySalt: hex(recoverySalt),264260     wrappedRootKey: ciphertext,265261     wrappedNonce: hex(nonce),@@ -423,15 +419,17 @@423419   if (!slotA) throw new Error("missing slot A");424420   if (slotA.recovery_salt && slotA.recovery_wrapped_root_key) return; // already done425421 426-  const { recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =422+  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =427423     await generateAndWrapRootKey();428424 429-  rootKey = unhex(wrappedRootKey); // keep for getRecoveryWords()425+  rootKey = unhex(rawRootKey);430426 431427   const nextSlotA: StoredVault = {432428     ...slotA,433429     recovery_salt: recoverySalt,434430     recovery_wrapped_root_key: wrappedRootKey,431+    recovery_nonce: wrappedNonce,432+    recovery_tag: wrappedTag,435433   };436434   await writeSlot(SLOT_A, nextSlotA);437435 }@@ -761,9 +759,9 @@761759   buildDomainIndexSync(entries);762760 763761   // Generate and wrap the root key for recovery word escrow.764-  const { recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =762+  const { rawRootKey, recoverySalt, wrappedRootKey, wrappedNonce, wrappedTag } =765763     await generateAndWrapRootKey();766-  rootKey = unhex(wrappedRootKey); // keep for getRecoveryWords()764+  rootKey = unhex(rawRootKey);767765 768766   const plaintext = encodePayload(entries, duressConfigured);769767   const { nonce, ciphertext, tag } = await encrypt(derivedKey, plaintext);@@ -776,6 +774,8 @@776774     tag,777775     recovery_salt: recoverySalt,778776     recovery_wrapped_root_key: wrappedRootKey,777+    recovery_nonce: wrappedNonce,778+    recovery_tag: wrappedTag,779779   };780780   await writeSlot(SLOT_A, slotA);781781 @@ -1921,9 +1921,26 @@19211921   zeroKey(derivedKey);19221922   derivedKey = newKey;19231923 1924-  // Re-wrap the root key under the new derived key so recovery words1925-  // continue to work after the password change.1926-  await rewrapRootKeyForPassword(newKey, newSalt);1924+  // Rotate the root key so the old recovery words are immediately invalidated.1925+  // After this point getRecoveryWords() returns a fresh 12-word set.1926+  const {1927+    rawRootKey: freshRawKey,1928+    recoverySalt: freshSalt,1929+    wrappedRootKey: freshWrapped,1930+    wrappedNonce: freshNonce,1931+    wrappedTag: freshTag,1932+  } = await generateAndWrapRootKey();1933+  rootKey = unhex(freshRawKey);1934+  const slotAfterRekey = await loadSlot(SLOT_A);1935+  if (slotAfterRekey) {1936+    await writeSlot(SLOT_A, {1937+      ...slotAfterRekey,1938+      recovery_salt: freshSalt,1939+      recovery_wrapped_root_key: freshWrapped,1940+      recovery_nonce: freshNonce,1941+      recovery_tag: freshTag,1942+    });1943+  }19271944 19281945   // Regenerate noise for slot B when duress is not in use. Runs after the19291946   // key swap so a failure here is a disposable noise regeneration, not a

view file

src/popup/views/contracts.test.tsx+21 −2
@@ -206,7 +206,24 @@206206   });207207 208208   it("Lock sends CREATE_VAULT when the onboarding passphrase is strong enough", async () => {209-    sendMessageMock.mockResolvedValue({ ok: true });209+    // Sequence: CREATE_VAULT → show recovery words → GET_RECOVERY_WORDS → user confirms → onUnlock210+    sendMessageMock.mockImplementation(211+      async (msg: unknown) => {212+        const m = msg as { type: string; payload?: { password?: string } };213+        if (m.type === "CREATE_VAULT") return { ok: true };214+        if (m.type === "GET_RECOVERY_WORDS") {215+          // Return 12 fake words — must pass findInvalidWords216+          return {217+            ok: true,218+            data: [219+              "abandon", "ability", "able", "about", "above", "absent",220+              "absorb", "abstract", "absurd", "abuse", "access", "accident",221+            ],222+          };223+        }224+        return { ok: true };225+      }226+    );210227     zxcvbnMock.mockImplementation(() => ({ score: 3 }));211228     const onUnlock = vi.fn(async () => undefined);212229     const container = mount(<LockView hasVault={false} ready={true} onUnlock={onUnlock} />);@@ -227,7 +244,9 @@227244       type: "CREATE_VAULT",228245       payload: { password: "correct horse battery staple" },229246     });230-    expect(onUnlock).toHaveBeenCalled();247+    // After CREATE_VAULT success, the recovery word display is shown.248+    // onUnlock is NOT called yet — user must first confirm their recovery words.249+    expect(onUnlock).not.toHaveBeenCalled();231250   });232251 233252   it("Export menu surfaces the three v2.5.0 backup formats", async () => {

view file

src/shared/recovery-words.ts+13 −7
@@ -26,8 +26,14 @@2626   }2727 2828   // SHA256 of the 16-byte entropy portion gives the checksum (first 4 bits).29+  // Copy to a fresh 16-byte buffer — slice() on a Uint8Array returns a view with30+  // the same underlying ArrayBuffer, so .buffer would still be 32 bytes.31+  const entropy16 = rootKeyBytes.slice(0, 16);2932   const entropyHash = new Uint8Array(30-    await crypto.subtle.digest("SHA-256", rootKeyBytes.buffer as ArrayBuffer)33+    await crypto.subtle.digest(34+      "SHA-256",35+      entropy16 /* Uint8Array — WebCrypto reads its byte content directly */36+    )3137   );3238   const checksum = entropyHash[0] >> 4; // 4-bit checksum3339 @@ -79,20 +85,20 @@7985   }8086 8187   // Extract the first 128 bits as entropy bytes.88+  // Encoding: bitsBig = (entropy_128bits << 4) | checksum_4bits89+  // entropy[i] occupies bits [131-8i .. 124-8i], so shift = 124 - 8i.8290   const entropy = new Uint8Array(16);8391   for (let i = 0; i < 16; i++) {84-    const byteShift = (15 - i) * 8;92+    const byteShift = 124 - i * 8;8593     entropy[i] = Number((bitsBig >> BigInt(byteShift)) & 0xffn);8694   }8795 88-  // Extract the 4-bit checksum from bits 128–131.89-  const storedChecksum = Number(90-    (bitsBig >> BigInt(ENTropy_BITS)) & 0xfn91-  );96+  // Extract the 4-bit checksum from bits 3..0 (LSB side).97+  const storedChecksum = Number(bitsBig & 0xfn);9298 9399   // Verify checksum94100   const entropyHash = new Uint8Array(95-    await crypto.subtle.digest("SHA-256", entropy.buffer as ArrayBuffer)101+    await crypto.subtle.digest("SHA-256", entropy)96102   );97103   const computedChecksum = entropyHash[0] >> 4;98104 

view file