noise-xx.ts

10.1 KB · raw

1// Noise XX (mutual auth) — used for every Phase-2 WebSocket connection2// after the audio chirp pairing has stamped each side into the other's3// paired-device registry. Both peers hold a long-term X25519 static key4// (the sync identity); XX is the canonical pattern when both parties want5// to authenticate each other.6//7// Pattern: Noise_XX_25519_ChaChaPoly_BLAKE2s — same primitives as the8// NK pattern in `noise-js.ts`, so the helpers are duplicated minimally9// (initHash / mixHash / hkdf2) to keep this file self-contained.10//11//   -> e12//   <- e, ee, s, es13//   -> s, se14//15// After msg 3, the symmetric `hkdf2(ck, "")` splits into two ChaCha20-16// Poly1305 transport keys, one per direction. Counter nonces start at 017// and increment per frame.1819import { generateKeyPair, sharedKey } from "@stablelib/x25519";20import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";21import { BLAKE2s } from "@stablelib/blake2s";22import { HMAC } from "@stablelib/hmac";2324const PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s";2526export function initHash(): Uint8Array {27  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);28  if (nameBytes.length <= 32) {29    const h = new Uint8Array(32);30    h.set(nameBytes);31    return h;32  }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();37}3839function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {40  const blake = new BLAKE2s(32);41  blake.update(h);42  blake.update(data);43  return blake.digest();44}4546export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {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];58}5960function nonceBytes(n: bigint): Uint8Array {61  const buf = new Uint8Array(12);62  new DataView(buf.buffer).setBigUint64(4, n, true);63  return buf;64}6566export interface NoiseXXSession {67  encrypt(plaintext: Uint8Array): Uint8Array;68  decrypt(ciphertext: Uint8Array): Uint8Array;69  remoteStaticPublicKey: Uint8Array;70  handshakeHash: Uint8Array;71}7273/**74 * Drive the XX initiator. The responder's static key is learned in75 * message 2; we hand it back so the caller can verify it against the76 * paired-device registry.77 */78export async function noiseXXInitiator(79  ourStaticPrivateKey: Uint8Array,80  ourStaticPublicKey: Uint8Array,81  send: (msg: Uint8Array) => Promise<void>,82  receive: () => Promise<Uint8Array>,83  // PCH-01-003: called once the responder's static is authenticated (end of84  // msg2) and BEFORE msg3 — which carries OUR static — is sent. Throwing here85  // aborts the handshake without disclosing our long-term identity to an86  // unexpected peer. Honest callers pass a pin check; omitting it preserves the87  // old behaviour.88  verifyRemoteStatic?: (remoteStaticPublicKey: Uint8Array) => void,89): Promise<NoiseXXSession> {90  let h: Uint8Array = initHash();91  let ck: Uint8Array = h.slice();9293  // --- Message 1: -> e ---94  const e = generateKeyPair();95  h = mixHash(h, e.publicKey);96  // PCH-02-002: canonical Noise still runs EncryptAndHash(empty) for msg1.97  // No bytes are added to the wire; the transcript hash moves in lockstep with98  // peach-android.99  h = mixHash(h, new Uint8Array(0));100  await send(e.publicKey);101102  // --- Message 2: <- e, ee, s, es ---103  const msg2 = await receive();104  if (msg2.length < 32 + 32 + 16 + 16) {105    throw new Error("Noise XX: msg2 too short");106  }107  const remoteEphemeral = msg2.slice(0, 32);108  const encryptedStatic = msg2.slice(32, 32 + 32 + 16);109  const encryptedPayload = msg2.slice(32 + 32 + 16);110111  h = mixHash(h, remoteEphemeral);112  // PCH-02-009: reject all-zero DH outputs from low-order peer points before113  // they enter the key schedule. Honest peers are unaffected.114  const ee = sharedKey(e.secretKey, remoteEphemeral, true);115  const [ck1, k1] = hkdf2(ck, ee);116  ck = ck1;117118  const cipherStatic = new ChaCha20Poly1305(k1);119  const remoteStaticPlain = cipherStatic.open(nonceBytes(0n), encryptedStatic, h);120  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {121    throw new Error("Noise XX: msg2 static auth failed");122  }123  h = mixHash(h, encryptedStatic);124125  const es = sharedKey(e.secretKey, remoteStaticPlain, true);126  const [ck2, k2] = hkdf2(ck, es);127  ck = ck2;128129  const cipherPayload = new ChaCha20Poly1305(k2);130  const payloadPlain = cipherPayload.open(nonceBytes(0n), encryptedPayload, h);131  if (!payloadPlain) {132    throw new Error("Noise XX: msg2 payload auth failed");133  }134  h = mixHash(h, encryptedPayload);135136  // PCH-01-003: pin the responder static BEFORE we transmit our own static in137  // msg3. `remoteStaticPlain` was authenticated above (msg2). If the caller's138  // check throws, we return without sending msg3 — our identity stays private.139  if (verifyRemoteStatic) verifyRemoteStatic(remoteStaticPlain);140141  // --- Message 3: -> s, se ---142  const cipherOurStatic = new ChaCha20Poly1305(k2);143  const encryptedOurStatic = cipherOurStatic.seal(nonceBytes(1n), ourStaticPublicKey, h);144  h = mixHash(h, encryptedOurStatic);145146  const se = sharedKey(ourStaticPrivateKey, remoteEphemeral, true);147  const [ck3, k3] = hkdf2(ck, se);148  ck = ck3;149150  const cipherFinal = new ChaCha20Poly1305(k3);151  const encryptedFinalPayload = cipherFinal.seal(nonceBytes(0n), new Uint8Array(0), h);152  h = mixHash(h, encryptedFinalPayload);153154  const msg3 = new Uint8Array(encryptedOurStatic.length + encryptedFinalPayload.length);155  msg3.set(encryptedOurStatic, 0);156  msg3.set(encryptedFinalPayload, encryptedOurStatic.length);157  await send(msg3);158159  // --- Transport keys: split (k_init_send, k_resp_send) ---160  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));161  const sendCipher = new ChaCha20Poly1305(initSend);162  const recvCipher = new ChaCha20Poly1305(respSend);163  let sendNonce = 0n;164  let recvNonce = 0n;165166  return {167    encrypt(plaintext) {168      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));169    },170    decrypt(ciphertext) {171      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));172      if (!result) throw new Error("Noise XX: decryption failed");173      return result;174    },175    remoteStaticPublicKey: remoteStaticPlain,176    handshakeHash: h.slice(),177  };178}179180/**181 * Drive the XX responder. The initiator's static key is learned in182 * message 3; we hand it back on success.183 */184export async function noiseXXResponder(185  ourStaticPrivateKey: Uint8Array,186  ourStaticPublicKey: Uint8Array,187  send: (msg: Uint8Array) => Promise<void>,188  receive: () => Promise<Uint8Array>,189): Promise<NoiseXXSession> {190  let h: Uint8Array = initHash();191  let ck: Uint8Array = h.slice();192193  // --- Message 1: <- e ---194  const msg1 = await receive();195  if (msg1.length < 32) throw new Error("Noise XX: msg1 too short");196  const remoteEphemeral = msg1.slice(0, 32);197  h = mixHash(h, remoteEphemeral);198  // PCH-02-002: mirror the initiator's empty-payload MixHash for msg1.199  h = mixHash(h, new Uint8Array(0));200201  // --- Message 2: -> e, ee, s, es ---202  const e = generateKeyPair();203  h = mixHash(h, e.publicKey);204  const ee = sharedKey(e.secretKey, remoteEphemeral, true);205  const [ck1, k1] = hkdf2(ck, ee);206  ck = ck1;207208  const cipherStatic = new ChaCha20Poly1305(k1);209  const encryptedOurStatic = cipherStatic.seal(nonceBytes(0n), ourStaticPublicKey, h);210  h = mixHash(h, encryptedOurStatic);211212  const es = sharedKey(ourStaticPrivateKey, remoteEphemeral, true);213  const [ck2, k2] = hkdf2(ck, es);214  ck = ck2;215216  const cipherPayload = new ChaCha20Poly1305(k2);217  const encryptedPayload = cipherPayload.seal(nonceBytes(0n), new Uint8Array(0), h);218  h = mixHash(h, encryptedPayload);219220  const msg2 = new Uint8Array(e.publicKey.length + encryptedOurStatic.length + encryptedPayload.length);221  msg2.set(e.publicKey, 0);222  msg2.set(encryptedOurStatic, e.publicKey.length);223  msg2.set(encryptedPayload, e.publicKey.length + encryptedOurStatic.length);224  await send(msg2);225226  // --- Message 3: <- s, se ---227  const msg3 = await receive();228  if (msg3.length < 32 + 16 + 16) throw new Error("Noise XX: msg3 too short");229  const encryptedRemoteStatic = msg3.slice(0, 32 + 16);230  const encryptedFinalPayload = msg3.slice(32 + 16);231232  const cipherTheirStatic = new ChaCha20Poly1305(k2);233  const remoteStaticPlain = cipherTheirStatic.open(nonceBytes(1n), encryptedRemoteStatic, h);234  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {235    throw new Error("Noise XX: msg3 static auth failed");236  }237  h = mixHash(h, encryptedRemoteStatic);238239  const se = sharedKey(e.secretKey, remoteStaticPlain, true);240  const [ck3, k3] = hkdf2(ck, se);241  ck = ck3;242243  const cipherFinal = new ChaCha20Poly1305(k3);244  const finalPayloadPlain = cipherFinal.open(nonceBytes(0n), encryptedFinalPayload, h);245  if (!finalPayloadPlain) {246    throw new Error("Noise XX: msg3 final payload auth failed");247  }248  h = mixHash(h, encryptedFinalPayload);249250  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));251  const sendCipher = new ChaCha20Poly1305(respSend);252  const recvCipher = new ChaCha20Poly1305(initSend);253  let sendNonce = 0n;254  let recvNonce = 0n;255256  return {257    encrypt(plaintext) {258      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));259    },260    decrypt(ciphertext) {261      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));262      if (!result) throw new Error("Noise XX: decryption failed");263      return result;264    },265    remoteStaticPublicKey: remoteStaticPlain,266    handshakeHash: h.slice(),267  };268}