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
↓ peach-browser-cf11fa1a.tar.gzatlas at this save
files changed2 files
src/crypto/peachPayload.test.ts+72 −0src/crypto/peachPayload.ts+36 −6src/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+});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,