878cf96cfacb
fix: LV-1 NFC-normalize master password at deriveKey chokepoint + deriveKeyRaw legacy-unlock fallback with heal; NFC on export KDF and recovery words
- time
- 2026-06-10 02:37 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- audit fix integration
- parent
- b02250b96462
↓ peach-browser-878cf96c.tar.gzatlas at this save
files changed6 files
src/background/vault.test.ts+35 −0src/background/vault.ts+33 −3src/crypto/argon2.test.ts+28 −1src/crypto/argon2.ts+26 −3src/shared/backup.ts+25 −3src/shared/recovery-words.ts+3 −1src/background/vault.test.ts+35 −0
@@ -1359,4 +1359,39 @@13591359 expect(vidAfter).not.toBe(vidBefore);13601360 });13611361 });1362+1363+ describe("LV-1 NFC master password normalization", () => {1364+ const CAFE_BASE = "café";1365+ const PW_NFD = `${CAFE_BASE.normalize("NFD")}-pw-12345`;1366+ const PW_NFC = `${CAFE_BASE.normalize("NFC")}-pw-12345`;1367+1368+ it("a vault created with the NFD form unlocks with the NFC form", async () => {1369+ const vault = await import("./vault");1370+ expect(PW_NFD).not.toBe(PW_NFC);1371+ await vault.createVault(PW_NFD);1372+ vault.lock();1373+ expect(await vault.unlock(PW_NFC)).toBe(true);1374+ });1375+1376+ it("a legacy vault sealed with raw non-NFC bytes still unlocks and is healed to NFC", async () => {1377+ const vault = await import("./vault");1378+ const { deriveKeyRaw, generateSalt } = await import("../crypto/argon2");1379+ await vault.createVault("base-password-12345");1380+1381+ // Simulate a pre-LV-1 vault: re-seal slot A under the RAW (un-normalized)1382+ // NFD bytes, which the NFC chokepoint would never reproduce.1383+ const rawSalt = generateSalt();1384+ const rawKey = await deriveKeyRaw(PW_NFD, rawSalt);1385+ await vault.changePasswordWithNewKey(rawSalt, rawKey);1386+ vault.lock();1387+1388+ // Normal NFC derive can't open the raw seal — the legacy-raw fallback must.1389+ expect(await vault.unlock(PW_NFD)).toBe(true);1390+1391+ // Healed: the slot is now NFC-sealed, so a second unlock takes the1392+ // normal path (and the NFC form opens it too).1393+ vault.lock();1394+ expect(await vault.unlock(PW_NFC)).toBe(true);1395+ });1396+ });13621397 });src/background/vault.ts+33 −3
@@ -9,7 +9,7 @@99 * Projects.md §3.2–§3.6.1010 */1111 12-import { deriveKey, generateSalt } from "../crypto/argon2";12+import { deriveKey, deriveKeyRaw, generateSalt } from "../crypto/argon2";1313 import { extractDomain } from "../shared/domain";1414 import { clampSyncTimestamp } from "../shared/sync-limits";1515 import { recordSyncAlerts, type SyncAlert } from "./sync-alerts";@@ -1070,6 +1070,36 @@10701070 }10711071 10721072 export async function unlock(password: string): Promise<boolean> {1073+ // LV-1: the normal path NFC-normalizes inside deriveKey. If that fails and1074+ // the supplied password is not already in NFC form, the vault may have been1075+ // sealed (pre-LV-1) with the raw, un-normalized bytes — retry with those.1076+ if (await attemptUnlockWithDerive(password, deriveKey)) return true;1077+1078+ const nfc = password.normalize("NFC");1079+ if (password === nfc) return false; // ASCII/NFC password — no legacy form to try1080+1081+ if (await attemptUnlockWithDerive(password, deriveKeyRaw)) {1082+ // Heal: re-seal the real slot under the NFC-normalized key so every1083+ // future unlock takes the normal path. Only the real vault is re-sealed;1084+ // a non-NFC duress password keeps working through this fallback.1085+ if (vaultMode === "real") {1086+ try {1087+ const newSalt = generateSalt();1088+ const newKey = await deriveKey(password, newSalt);1089+ await changePasswordWithNewKey(newSalt, newKey);1090+ } catch (healErr) {1091+ console.warn("[Peach] LV-1 NFC heal failed:", healErr);1092+ }1093+ }1094+ return true;1095+ }1096+ return false;1097+}1098+1099+async function attemptUnlockWithDerive(1100+ password: string,1101+ derive: typeof deriveKey,1102+): Promise<boolean> {10731103 await ensureMigrated();10741104 10751105 // Load both slots in parallel. Both always exist post-migration; the@@ -1084,8 +1114,8 @@10841114 10851115 // Derive both keys in parallel, unconditionally.10861116 const [keyA, keyB] = await Promise.all([1087- slotA ? deriveKey(password, unhex(slotA.salt)) : Promise.resolve(null),1088- slotB ? deriveKey(password, unhex(slotB.salt)) : Promise.resolve(null),1117+ slotA ? derive(password, unhex(slotA.salt)) : Promise.resolve(null),1118+ slotB ? derive(password, unhex(slotB.salt)) : Promise.resolve(null),10891119 ]);10901120 10911121 // Attempt both decrypts in parallel. The Result variant lets us distinguishsrc/crypto/argon2.test.ts+28 −1
@@ -1,7 +1,14 @@11 import { describe, expect, it } from "vitest";22 3-import { deriveKey, generateSalt } from "./argon2";3+import { deriveKey, deriveKeyRaw, generateSalt } from "./argon2";44 5+// Same glyph "café" decomposed (e + U+0301) and composed (U+00E9). Both are6+// derived from one base literal via normalize(), so the stored byte form of the7+// literal is irrelevant — the two forms are guaranteed distinct.8+const CAFE_BASE = "café";9+const CAFE_NFD = CAFE_BASE.normalize("NFD");10+const CAFE_NFC = CAFE_BASE.normalize("NFC");11+512 describe("crypto argon2", () => {613 it("deriveKey is deterministic for the same password and salt", async () => {714 const salt = generateSalt();@@ -26,6 +33,26 @@2633 const second = await deriveKey("wrong-pass-67890", salt);2734 2835 expect(first).not.toEqual(second);36+ });37+38+ it("LV-1: deriveKey NFC-normalizes so composed and decomposed forms match", async () => {39+ const salt = generateSalt();40+ expect(CAFE_NFC).not.toBe(CAFE_NFD);41+ expect(await deriveKey(CAFE_NFC, salt)).toEqual(await deriveKey(CAFE_NFD, salt));42+ });43+44+ it("LV-1: deriveKeyRaw does NOT normalize (composed vs decomposed differ)", async () => {45+ const salt = generateSalt();46+ expect(await deriveKeyRaw(CAFE_NFC, salt)).not.toEqual(47+ await deriveKeyRaw(CAFE_NFD, salt),48+ );49+ });50+51+ it("LV-1: ASCII passwords are unaffected — deriveKey equals deriveKeyRaw", async () => {52+ const salt = generateSalt();53+ expect(await deriveKey("correct-pass-12345", salt)).toEqual(54+ await deriveKeyRaw("correct-pass-12345", salt),55+ );2956 });3057 3158 it("generateSalt returns 16 bytes", () => {src/crypto/argon2.ts+26 −3
@@ -5,11 +5,11 @@55 // on parallelism. Changing any of these changes the derived key — existing66 // vaults on disk can only be unlocked with the params they were sealed under.77 // Raising them tightens attacker cost; lowering them is a regression.8-export async function deriveKey(8+async function argon2idDerive(99 password: string,1010 salt: Uint8Array1111 ): Promise<Uint8Array> {12- const result = await argon2id({12+ return argon2id({1313 password,1414 salt,1515 parallelism: 4,@@ -18,7 +18,30 @@1818 hashLength: 32,1919 outputType: "binary",2020 });21- return result;21+}22+23+export async function deriveKey(24+ password: string,25+ salt: Uint8Array26+): Promise<Uint8Array> {27+ // LV-1: NFC-normalize before the KDF. The same visually-identical28+ // password typed on a keyboard/IME that emits a different normalization29+ // form (NFD on macOS dead-keys, etc.) must derive the same key — and the30+ // same bytes as the Android/iOS clients, which apply the identical31+ // normalization (cross-platform parity).32+ return argon2idDerive(password.normalize("NFC"), salt);33+}34+35+/**36+ * LV-1 legacy fallback ONLY: derive WITHOUT normalization, for unlocking a37+ * vault sealed before NFC normalization existed with a non-NFC password.38+ * Every normal path uses {@link deriveKey}. Do not use this elsewhere.39+ */40+export async function deriveKeyRaw(41+ password: string,42+ salt: Uint8Array43+): Promise<Uint8Array> {44+ return argon2idDerive(password, salt);2245 }2346 2447 // Per-vault salt: 16 bytes from the OS CSPRNG. A fresh salt is generated atsrc/shared/backup.ts+25 −3
@@ -91,7 +91,24 @@9191 envelope.tag9292 );9393 } catch {94- throw new Error("INVALID_EXPORT_PASSWORD");94+ // LV-1: an export sealed before NFC normalization with a non-NFC95+ // passphrase derives a different key. Retry once with the raw bytes.96+ if (password === password.normalize("NFC")) {97+ throw new Error("INVALID_EXPORT_PASSWORD");98+ }99+ try {100+ const key = await derivePeachExportKey(password, fromHex(envelope.salt), {101+ raw: true,102+ });103+ plaintext = await decrypt(104+ key,105+ envelope.nonce,106+ envelope.ciphertext,107+ envelope.tag108+ );109+ } catch {110+ throw new Error("INVALID_EXPORT_PASSWORD");111+ }95112 }96113 97114 let parsed: unknown;@@ -128,11 +145,16 @@128145 129146 export async function derivePeachExportKey(130147 password: string,131- salt: Uint8Array148+ salt: Uint8Array,149+ opts: { raw?: boolean } = {}132150 ): Promise<Uint8Array> {151+ // LV-1: NFC-normalize the export passphrase to match the master-password152+ // KDF chokepoint. `raw:true` is used only by the import fallback to open153+ // pre-LV-1 exports sealed under un-normalized bytes.154+ const pw = opts.raw ? password : password.normalize("NFC");133155 const material = await crypto.subtle.importKey(134156 "raw",135- new TextEncoder().encode(password),157+ new TextEncoder().encode(pw),136158 "PBKDF2",137159 false,138160 ["deriveBits"]src/shared/recovery-words.ts+3 −1
@@ -68,7 +68,9 @@6868 // Map words to 11-bit indices6969 const indices: number[] = [];7070 for (let i = 0; i < words.length; i++) {71- const word = words[i].toLowerCase().trim();71+ // LV-1: BIP39 words are ASCII so normalize is a no-op today; the call72+ // makes the invariant explicit and future-proofs localized word lists.73+ const word = words[i].normalize("NFC").toLowerCase().trim();7274 const index = WORD_LIST.indexOf(word);7375 if (index === -1) {7476 throw new Error(`unknown word at position ${i + 1}: "${words[i]}"`);