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
↓ peach-browser-62761aef.tar.gzatlas at this save
files changed4 files
manifest.json+1 −1src/background/vault.ts+34 −17src/popup/views/contracts.test.tsx+21 −2src/shared/recovery-words.ts+13 −7manifest.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",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 asrc/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 () => {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