4a07efb9b3a4
fix: PCH-01-002 Noise initHash + hkdf2 derive over BLAKE2s to match the declared pattern name (noise-xx.ts + noise-js.ts) — standard HKDF-Expand via fresh HMAC-BLAKE2s per block (works around @stablelib HKDF/BLAKE2s restoreState bug), byte-identical to peach-android BouncyCastle; deterministic BLAKE2s handshake-hash KAT; fix stale snow comments; HMAC-SHA256 auth proof unchanged (v3.9.0)
- time
- 2026-06-10 20:50 UTC · 4 months ago
- machine
- koh2
- agent
- claude-opus-4-8
- session
- pch-01-015 test + pch-01-002 blake2s
- parent
- 4c7b8f1172f4
↓ peach-browser-4a07efb9.tar.gzatlas at this save
files changed3 files
src/shared/noise-blake2s-kat.test.ts+100 −0src/shared/noise-js.ts+26 −14src/shared/noise-xx.ts+16 −7src/shared/noise-blake2s-kat.test.ts+100 −0
@@ -0,0 +1,100 @@1+import { beforeEach, describe, expect, it, vi } from "vitest";2+import { generateKeyPairFromSeed } from "@stablelib/x25519";3+4+// PCH-01-002: the Noise handshakes derive `h` (protocol-name init hash) and the5+// HKDF in MixKey/Split over BLAKE2s, matching the declared pattern name. These6+// are KNOWN-ANSWER tests over a fully deterministic handshake (fixed statics +7+// seeded ephemerals): a revert to SHA-256 changes every derived byte, so the8+// pinned handshakeHash below stops matching and the test fails. That failure is9+// the intended signal of an accidental hash-algorithm change.10+11+// Mock ONLY ephemeral generation so the handshake is deterministic; sharedKey12+// and the seeded keygen stay real. File-scoped, so other noise tests are13+// unaffected. The counter lives on globalThis and is reset per test.14+vi.mock("@stablelib/x25519", async (importOriginal) => {15+ const actual = await importOriginal<typeof import("@stablelib/x25519")>();16+ return {17+ ...actual,18+ generateKeyPair: () => {19+ const g = globalThis as { __kpCounter?: number };20+ g.__kpCounter = (g.__kpCounter ?? 0) + 1;21+ return actual.generateKeyPairFromSeed(new Uint8Array(32).fill(g.__kpCounter));22+ },23+ };24+});25+26+function memoryPair() {27+ const a: Uint8Array[] = [];28+ const b: Uint8Array[] = [];29+ const aR: Array<(v: Uint8Array) => void> = [];30+ const bR: Array<(v: Uint8Array) => void> = [];31+ const tA = {32+ async send(m: Uint8Array) { const r = bR.shift(); if (r) r(m); else b.push(m); },33+ receive(): Promise<Uint8Array> {34+ const v = a.shift();35+ if (v) return Promise.resolve(v);36+ return new Promise<Uint8Array>((resolve) => aR.push(resolve));37+ },38+ };39+ const tB = {40+ async send(m: Uint8Array) { const r = aR.shift(); if (r) r(m); else a.push(m); },41+ receive(): Promise<Uint8Array> {42+ const v = b.shift();43+ if (v) return Promise.resolve(v);44+ return new Promise<Uint8Array>((resolve) => bR.push(resolve));45+ },46+ };47+ return [tA, tB] as const;48+}49+50+function hex(b: Uint8Array): string {51+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");52+}53+54+describe("PCH-01-002 — Noise handshakes derive over BLAKE2s", () => {55+ beforeEach(() => {56+ (globalThis as { __kpCounter?: number }).__kpCounter = 0;57+ });58+59+ it("noise_xx_handshake_hash_is_blake2s_derived", async () => {60+ const { noiseXXInitiator, noiseXXResponder } = await import("./noise-xx");61+ const alice = generateKeyPairFromSeed(new Uint8Array(32).fill(0xa1));62+ const bob = generateKeyPairFromSeed(new Uint8Array(32).fill(0xb2));63+ const [tA, tB] = memoryPair();64+65+ const [a, b] = await Promise.all([66+ noiseXXInitiator(alice.secretKey, alice.publicKey, tA.send, tA.receive),67+ noiseXXResponder(bob.secretKey, bob.publicKey, tB.send, tB.receive),68+ ]);69+70+ // Both directions agree (consistency) ...71+ expect(hex(a.handshakeHash)).toBe(hex(b.handshakeHash));72+ // ... and equal the pinned BLAKE2s vector (a SHA-256 revert changes this).73+ expect(hex(a.handshakeHash)).toBe(74+ "11c8bf7fcefad1f00ff8573c7bca66891626d053addae6d2b78b2f195a9ffd8a",75+ );76+77+ // Transport keys are also BLAKE2s-derived; a frame must still round-trip.78+ const ct = a.encrypt(new TextEncoder().encode("hello"));79+ expect(new TextDecoder().decode(b.decrypt(ct))).toBe("hello");80+ });81+82+ it("noise_nk_handshake_hash_is_blake2s_derived", async () => {83+ const { noiseNKInitiator, noiseNKResponder } = await import("./noise-js");84+ const responder = generateKeyPairFromSeed(new Uint8Array(32).fill(0xc3));85+ const [tA, tB] = memoryPair();86+87+ const [a, b] = await Promise.all([88+ noiseNKInitiator(responder.publicKey, tA.send, tA.receive),89+ noiseNKResponder(responder.secretKey, responder.publicKey, tB.send, tB.receive),90+ ]);91+92+ expect(hex(a.handshakeHash)).toBe(hex(b.handshakeHash));93+ expect(hex(a.handshakeHash)).toBe(94+ "0fbab8cd1782bf4b17d69beb69f5b80b0bf24d93da4924959c76ee701c32e655",95+ );96+97+ const ct = a.encrypt(new TextEncoder().encode("ping"));98+ expect(new TextDecoder().decode(b.decrypt(ct))).toBe("ping");99+ });100+});src/shared/noise-js.ts+26 −14
@@ -2,19 +2,21 @@22 * Noise NK initiator for Android / Capacitor.33 * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s44 *5- * The native Rust host runs `snow` to drive the same pattern; this JS6- * implementation produces wire-identical bytes so a Capacitor WebView client7- * can connect to the desktop host over WebSocket.5+ * The Android client is a hand-port of this code and must derive byte-for-byte6+ * the same handshake — interop holds by porting discipline, not by a shared7+ * library. The earlier Rust `snow` host has been removed from this repo, so8+ * there is no cross-implementation pin; `noise-js.test.ts` pins SELF-derived9+ * vectors only (a regression guard against accidental changes on the JS side).810 *9- * MUST stay in lockstep with the native side. The cross-check test in10- * `noise-js.test.ts` pins exact byte outputs against fixed inputs so a11- * regression on either side fails CI before any device pair gets wedged.11+ * PCH-01-002: this implementation derives `h` and HKDF over BLAKE2s to match the12+ * named pattern. A stock `snow` stack would interoperate; the Android port must13+ * mirror the same hash choices. Any change to the hash, HKDF, or transcript here14+ * is a BREAKING wire change and requires a coordinated Android release.1215 */1316 1417 import { generateKeyPair, sharedKey } from "@stablelib/x25519";1518 import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";1619 import { BLAKE2s } from "@stablelib/blake2s";17-import { HKDF } from "@stablelib/hkdf";1820 import { SHA256 } from "@stablelib/sha256";1921 import { HMAC } from "@stablelib/hmac";2022 @@ -27,9 +29,9 @@2729 h.set(nameBytes);2830 return h;2931 }30- const sha = new SHA256();31- sha.update(nameBytes);32- return sha.digest();32+ // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash33+ // MUST use it too (Noise spec §5.2), not SHA-256.34+ return new BLAKE2s(32).update(nameBytes).digest();3335 }3436 3537 function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {@@ -40,8 +42,17 @@4042 }4143 4244 function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {43- const out = new HKDF(SHA256, ikm, ck).expand(64);44- return [out.slice(0, 32), out.slice(32, 64)];45+ // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.46+ // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState()47+ // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run48+ // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per49+ // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`50+ // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a51+ // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).52+ const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)53+ const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();54+ const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();55+ return [k1, k2];4556 }4657 4758 export interface NoiseNKSession {@@ -210,8 +221,9 @@210221 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two211222 * handshake messages and gets back a session for transport-phase frames.212223 *213- * Wire-identical with the Rust `snow` responder so an Android-as-host214- * pairing session can be driven by the desktop initiator and vice versa.224+ * Symmetric with the Android hand-port; no shared Rust implementation remains.225+ * An Android-as-host pairing session can be driven by the desktop initiator and226+ * vice versa as long as both derive over BLAKE2s (PCH-01-002).215227 */216228 export async function noiseNKResponder(217229 ourStaticPrivateKey: Uint8Array,src/shared/noise-xx.ts+16 −7
@@ -19,8 +19,7 @@1919 import { generateKeyPair, sharedKey } from "@stablelib/x25519";2020 import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";2121 import { BLAKE2s } from "@stablelib/blake2s";22-import { HKDF } from "@stablelib/hkdf";23-import { SHA256 } from "@stablelib/sha256";22+import { HMAC } from "@stablelib/hmac";2423 2524 const PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s";2625 @@ -31,9 +30,10 @@3130 h.set(nameBytes);3231 return h;3332 }34- const sha = new SHA256();35- sha.update(nameBytes);36- return sha.digest();33+ // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash34+ // MUST use it too (Noise spec §5.2), not SHA-256. BLAKE2s digest is 32 bytes,35+ // matching mixHash output width.36+ return new BLAKE2s(32).update(nameBytes).digest();3737 }3838 3939 function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {@@ -44,8 +44,17 @@4444 }4545 4646 function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {47- const out = new HKDF(SHA256, ikm, ck).expand(64);48- return [out.slice(0, 32), out.slice(32, 64)];47+ // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.48+ // @stablelib's HKDF reuses one HMAC across output blocks and BLAKE2s's49+ // restoreState() never clears its `finished` flag, so `new HKDF(BLAKE2s, …)`50+ // throws "can't update because hash was finished". Run standard HKDF-Expand51+ // (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per block instead — this is52+ // byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest(256))` on53+ // the peach-android side, so the two stay in wire lockstep.54+ const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)55+ const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();56+ const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();57+ return [k1, k2];4958 }5059 5160 function nonceBytes(n: bigint): Uint8Array {