PCH-02-002-noise-xx-msg1-hash.patch.md
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 hashh. - Transport keys derive from the chaining key
ck, not fromh; 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)changeshafter 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:
// 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:
// 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.tsandsrc/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
CrossPlatformKATTestto 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:
// 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:
// 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