cf11fa1a0223

fix: PCH-01-001 NFC-normalize export/restore passphrase at peachPayload deriveKey chokepoint + raw fallback on import (v3.8.1)

time
2026-06-10 19:27 UTC · 4 months ago
machine
koh2
agent
claude-opus-4-8
session
pch-01 fix integration
parent
212e1bdc1142
2 files src/crypto/peachPayload.test.ts+72 −0src/crypto/peachPayload.ts+36 −6
src/crypto/peachPayload.test.ts+72 −0
@@ -381,3 +381,75 @@381381     expect(out.entries[0].email).toBe("[email protected]");382382   });383383 });384+385+describe("PCH-01-001 — NFC passphrase normalization", () => {386+  // "café" composes (U+00E9) under NFC and decomposes (e + U+0301) under NFD.387+  const NFC_PASS = "café".normalize("NFC");388+  const NFD_PASS = "café".normalize("NFD");389+390+  test("NFD/NFC forms genuinely differ (fixture sanity)", () => {391+    expect(NFC_PASS).not.toBe(NFD_PASS);392+  });393+394+  test("export_with_nfd_passphrase_imports_correctly", async () => {395+    // Sealed under NFD form, opened with NFC form — both normalize to one key.396+    const env = await buildPayload({397+      entries: [loginEntry()],398+      settings: {},399+      passphrase: NFD_PASS,400+    });401+    const out = await parsePayload(env, NFC_PASS);402+    expect(out.entries[0].title).toBe("github");403+  });404+405+  test("export_with_nfc_passphrase_imports_correctly", async () => {406+    // Regression: a pure-NFC passphrase still round-trips.407+    const env = await buildPayload({408+      entries: [loginEntry()],409+      settings: {},410+      passphrase: NFC_PASS,411+    });412+    const out = await parsePayload(env, NFC_PASS);413+    expect(out.entries[0].title).toBe("github");414+  });415+416+  test("codex_restore_with_nfd_passphrase_succeeds", async () => {417+    // Lock-restore / unlockFromCodex parse through the same parsePayload418+    // chokepoint: NFC-sealed blob opened with the NFD form must succeed.419+    const env = await buildPayload({420+      entries: [loginEntry()],421+      settings: {},422+      passphrase: NFC_PASS,423+    });424+    const out = await parsePayload(env, NFD_PASS);425+    expect(out.entries[0].title).toBe("github");426+  });427+428+  test("legacy_raw_fallback_opens_old_backup", async () => {429+    // Mint a pre-fix export sealed under the RAW (un-normalized) NFD bytes via430+    // the test seam, then confirm post-fix parsePayload opens it through the431+    // raw fallback (normalized key fails, raw key succeeds).432+    const env = await buildPayload(433+      { entries: [loginEntry()], settings: {}, passphrase: NFD_PASS },434+      { raw: true },435+    );436+    const out = await parsePayload(env, NFD_PASS);437+    expect(out.entries[0].title).toBe("github");438+  });439+440+  test("non_ascii_cjk_combining_round_trip", async () => {441+    // Quality-bar: CJK + combining dakuten. "が" is U+304C (NFC) or442+    // か + U+3099 (NFD); mixed with CJK ideographs.443+    const base = "日本語がpassword";444+    const nfc = base.normalize("NFC");445+    const nfd = base.normalize("NFD");446+    expect(nfc).not.toBe(nfd);447+    const env = await buildPayload({448+      entries: [loginEntry()],449+      settings: {},450+      passphrase: nfd,451+    });452+    const out = await parsePayload(env, nfc);453+    expect(out.entries[0].title).toBe("github");454+  });455+});

view file

src/crypto/peachPayload.ts+36 −6
@@ -153,7 +153,13 @@153153  * `[salt(16) || nonce(12) || ciphertext || tag(16)]` ready to feed into154154  * `encodePeachScript` or the QR matrix encoder.155155  */156-export async function buildPayload(input: BuildPayloadInput): Promise<Uint8Array> {156+export async function buildPayload(157+  input: BuildPayloadInput,158+  // PCH-01-001 test seam only: `raw:true` seals under the un-normalized159+  // passphrase bytes to mint a pre-fix (legacy) envelope. Never used by160+  // production callers — the build path always normalizes.161+  opts: { raw?: boolean } = {},162+): Promise<Uint8Array> {157163   const sections: Uint8Array[] = [];158164 159165   sections.push(await buildSection(SECTION_ENTRIES, encodeEntriesSection(input.entries, FORMAT_V3)));@@ -198,7 +204,7 @@198204 199205   const salt = crypto.getRandomValues(new Uint8Array(SALT_LEN));200206   const nonce = crypto.getRandomValues(new Uint8Array(NONCE_LEN));201-  const key = await deriveKey(input.passphrase, salt);207+  const key = await deriveKey(input.passphrase, salt, opts);202208   try {203209     const { ciphertext, tag } = await aesGcmEncrypt(key, nonce, wrapped);204210     return concat([salt, nonce, ciphertext, tag]);@@ -226,12 +232,26 @@226232   const tag = envelope.subarray(envelope.length - TAG_LEN);227233   const ciphertext = envelope.subarray(SALT_LEN + NONCE_LEN, envelope.length - TAG_LEN);228234 229-  const key = await deriveKey(passphrase, salt);230235   let decrypted: Uint8Array;236+  const key = await deriveKey(passphrase, salt);231237   try {232238     decrypted = await aesGcmDecrypt(key, nonce, ciphertext, tag);233239   } catch {234-    throw new Error("INVALID_PASSPHRASE");240+    // PCH-01-001: an export sealed before this fix under a non-NFC passphrase241+    // derives a different key. Retry once with the raw bytes — mirrors242+    // backup.ts. Only attempt the fallback when normalization actually changed243+    // the string (an all-ASCII passphrase has nothing to fall back to).244+    if (passphrase === passphrase.normalize("NFC")) {245+      throw new Error("INVALID_PASSPHRASE");246+    }247+    const rawKey = await deriveKey(passphrase, salt, { raw: true });248+    try {249+      decrypted = await aesGcmDecrypt(rawKey, nonce, ciphertext, tag);250+    } catch {251+      throw new Error("INVALID_PASSPHRASE");252+    } finally {253+      rawKey.fill(0);254+    }235255   } finally {236256     key.fill(0);237257   }@@ -952,9 +972,19 @@952972 // Crypto helpers953973 // ---------------------------------------------------------------------------954974 955-async function deriveKey(passphrase: string, salt: Uint8Array): Promise<Uint8Array> {975+async function deriveKey(976+  passphrase: string,977+  salt: Uint8Array,978+  opts: { raw?: boolean } = {},979+): Promise<Uint8Array> {980+  // PCH-01-001 / LV-1 parity: NFC-normalize the export passphrase to match the981+  // master-password KDF chokepoint (argon2.ts). A composed-vs-decomposed mismatch982+  // (macOS NFD dead-keys vs. Android NFC) otherwise derives a different key and983+  // makes a valid backup unrestorable. `raw:true` is used ONLY by the parse984+  // fallback to open pre-fix exports sealed under un-normalized bytes.985+  const pw = opts.raw ? passphrase : passphrase.normalize("NFC");956986   return argon2id({957-    password: passphrase,987+    password: pw,958988     salt,959989     parallelism: ARGON2_PARALLELISM,960990     iterations: ARGON2_ITERATIONS,

view file