# PCH-02-002 — Noise XX message 1 empty-payload mixHash **Severity:** Informational **Target file:** `src/shared/noise-xx.ts:93-97` (initiator), `184-191` (responder) **Also touched:** `src/shared/noise-js.ts:11-14` (doc comment correction — landable NOW) **Report:** peach-code-review.md, Finding PCH-02-002 ## Decision: DEFER the wire change to the Android coordination window Per the fix brief's own escape hatch ("if both sides already skip it consistently and the practical risk is only interop … defer the wire change to the Android coordination window. Do not implement a half-fix.") — that is exactly the situation, verified by a complete read of `noise-xx.ts`: - Both the initiator (msg 1 send, lines 93–96) and the responder (msg 1 receive, lines 188–191) skip the canonical `MixHash(empty payload)` — **consistently**. Every Peach↔Peach and Peach↔Android XX handshake computes an identical transcript hash `h`. - Transport keys derive from the chaining key `ck`, not from `h`; the handshake hash still binds every byte both sides actually exchanged. There is no confidentiality/authenticity weakening — only non-interop with stock Noise stacks (e.g. `snow`), which Peach explicitly does not rely on (`noise-js.ts:5-9`: "interop holds by porting discipline, not by a shared library"). - Adding the `mixHash(empty)` changes `h` after message 1, which changes the AAD of every subsequent handshake AEAD → **every ciphertext from msg 2 onward changes**. peach-android must apply the identical change in the same release or all XX sync sessions fail to handshake. This is the same class of coordinated breaking change as PCH-01-002 (BLAKE2s), which shipped in lockstep as browser v3.9.1 / android v2.2.9. Implementing the browser side alone would be the forbidden half-fix. ## Ready-to-apply patch (apply ONLY in the coordinated window) Initiator — `noise-xx.ts`, message 1 block: ```typescript // BEFORE (lines 93-96) // --- Message 1: -> e --- const e = generateKeyPair(); h = mixHash(h, e.publicKey); await send(e.publicKey); // AFTER // --- Message 1: -> e --- const e = generateKeyPair(); h = mixHash(h, e.publicKey); // PCH-02-002: canonical Noise ends every message with EncryptAndHash(payload); // with no symmetric key yet, an empty msg-1 payload is MixHash(empty). The // wire bytes are unchanged (empty payload), only the transcript hash moves. // BREAKING transcript change — ships in lockstep with peach-android. h = mixHash(h, new Uint8Array(0)); await send(e.publicKey); ``` Responder — `noise-xx.ts`, message 1 block: ```typescript // BEFORE (lines 187-191) // --- Message 1: <- e --- const msg1 = await receive(); if (msg1.length < 32) throw new Error("Noise XX: msg1 too short"); const remoteEphemeral = msg1.slice(0, 32); h = mixHash(h, remoteEphemeral); // AFTER // --- Message 1: <- e --- const msg1 = await receive(); if (msg1.length < 32) throw new Error("Noise XX: msg1 too short"); const remoteEphemeral = msg1.slice(0, 32); h = mixHash(h, remoteEphemeral); // PCH-02-002: mirror the initiator's empty-payload MixHash (see above). h = mixHash(h, new Uint8Array(0)); ``` The NK pattern (`noise-js.ts`) is already canonical — its msg-1 payload is a real AEAD seal because `es` establishes a key before the payload step (`noise-js.ts:84-91`); no change there. ### Test impact when the wire change lands - `src/shared/noise-xx.test.ts` and `src/shared/noise-blake2s-kat.test.ts`: any pinned XX handshake-hash / transcript vectors must be regenerated (the NK vectors are unaffected). Initiator↔responder round-trip tests pass unchanged (both sides move together). - Cross-repo: regenerate the XX vectors in peach-android `CrossPlatformKATTest` to the same values, exactly as PCH-01-002 did. ## Landable NOW (no wire impact): doc comment correction in noise-js.ts The header claim at `noise-js.ts:12` — "A stock `snow` stack would interoperate" — is true for NK but over-reads for XX. Correct it so the claim cannot mislead a future maintainer: ```typescript // BEFORE (noise-js.ts:11-14) * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the * named pattern. A stock `snow` stack would interoperate; the Android port must * mirror the same hash choices. Any change to the hash, HKDF, or transcript here * is a BREAKING wire change and requires a coordinated Android release. // AFTER * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the * named pattern. A stock `snow` stack would interoperate with THIS NK pattern; * the XX implementation (noise-xx.ts) omits the canonical msg-1 empty-payload * MixHash (PCH-02-002, deferred to an Android-coordinated release), so stock * stacks would NOT interoperate with Peach XX until that lands. The Android port * must mirror the same hash choices. Any change to the hash, HKDF, or transcript * here is a BREAKING wire change and requires a coordinated Android release. ``` And pin the deferral in `noise-xx.ts` so the omission is visible at the site: ```typescript // Insert after noise-xx.ts:17 (end of the header comment block): // // PCH-02-002 (DEFERRED — Android-coordinated wire change): message 1 omits the // canonical empty-payload MixHash. Both Peach sides skip it identically, so // Peach↔Peach/Android transcripts agree; only interop with stock Noise stacks // is affected. The ready-to-apply fix lives in // src/drafts/PCH-02-FIXES/PCH-02-002-noise-xx-msg1-hash.patch.md and MUST land // in the same release as the matching peach-android change (PCH-01-002 model). ``` ## Save message (integration, comment-only part) `docs: PCH-02-002 pin Noise XX msg1 empty-payload mixHash deferral — wire change requires Android coordination; correct noise-js snow-interop claim` ## Save message (coordinated window, wire part) `fix: PCH-02-002 Noise XX message 1 empty-payload mixHash — NOTE: wire change, ships in lockstep with peach-android`