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
6 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 −1
src/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 });

view file

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 distinguish

view file

src/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", () => {

view file

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 at

view file

src/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"]

view file

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]}"`);

view file