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
3 files src/shared/noise-blake2s-kat.test.ts+100 −0src/shared/noise-js.ts+26 −14src/shared/noise-xx.ts+16 −7
src/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+});

view file

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,

view file

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 {

view file