noise-xx.ts

8.8 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 { HKDF } from "@stablelib/hkdf";23import { SHA256 } from "@stablelib/sha256";2425const PROTOCOL_NAME = "Noise_XX_25519_ChaChaPoly_BLAKE2s";2627function initHash(): Uint8Array {28  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);29  if (nameBytes.length <= 32) {30    const h = new Uint8Array(32);31    h.set(nameBytes);32    return h;33  }34  const sha = new SHA256();35  sha.update(nameBytes);36  return sha.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}4546function 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)];49}5051function nonceBytes(n: bigint): Uint8Array {52  const buf = new Uint8Array(12);53  new DataView(buf.buffer).setBigUint64(4, n, true);54  return buf;55}5657export interface NoiseXXSession {58  encrypt(plaintext: Uint8Array): Uint8Array;59  decrypt(ciphertext: Uint8Array): Uint8Array;60  remoteStaticPublicKey: Uint8Array;61  handshakeHash: Uint8Array;62}6364/**65 * Drive the XX initiator. The responder's static key is learned in66 * message 2; we hand it back so the caller can verify it against the67 * paired-device registry.68 */69export async function noiseXXInitiator(70  ourStaticPrivateKey: Uint8Array,71  ourStaticPublicKey: Uint8Array,72  send: (msg: Uint8Array) => Promise<void>,73  receive: () => Promise<Uint8Array>,74  // PCH-01-003: called once the responder's static is authenticated (end of75  // msg2) and BEFORE msg3 — which carries OUR static — is sent. Throwing here76  // aborts the handshake without disclosing our long-term identity to an77  // unexpected peer. Honest callers pass a pin check; omitting it preserves the78  // old behaviour.79  verifyRemoteStatic?: (remoteStaticPublicKey: Uint8Array) => void,80): Promise<NoiseXXSession> {81  let h: Uint8Array = initHash();82  let ck: Uint8Array = h.slice();8384  // --- Message 1: -> e ---85  const e = generateKeyPair();86  h = mixHash(h, e.publicKey);87  await send(e.publicKey);8889  // --- Message 2: <- e, ee, s, es ---90  const msg2 = await receive();91  if (msg2.length < 32 + 32 + 16 + 16) {92    throw new Error("Noise XX: msg2 too short");93  }94  const remoteEphemeral = msg2.slice(0, 32);95  const encryptedStatic = msg2.slice(32, 32 + 32 + 16);96  const encryptedPayload = msg2.slice(32 + 32 + 16);9798  h = mixHash(h, remoteEphemeral);99  const ee = sharedKey(e.secretKey, remoteEphemeral);100  const [ck1, k1] = hkdf2(ck, ee);101  ck = ck1;102103  const cipherStatic = new ChaCha20Poly1305(k1);104  const remoteStaticPlain = cipherStatic.open(nonceBytes(0n), encryptedStatic, h);105  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {106    throw new Error("Noise XX: msg2 static auth failed");107  }108  h = mixHash(h, encryptedStatic);109110  const es = sharedKey(e.secretKey, remoteStaticPlain);111  const [ck2, k2] = hkdf2(ck, es);112  ck = ck2;113114  const cipherPayload = new ChaCha20Poly1305(k2);115  const payloadPlain = cipherPayload.open(nonceBytes(0n), encryptedPayload, h);116  if (!payloadPlain) {117    throw new Error("Noise XX: msg2 payload auth failed");118  }119  h = mixHash(h, encryptedPayload);120121  // PCH-01-003: pin the responder static BEFORE we transmit our own static in122  // msg3. `remoteStaticPlain` was authenticated above (msg2). If the caller's123  // check throws, we return without sending msg3 — our identity stays private.124  if (verifyRemoteStatic) verifyRemoteStatic(remoteStaticPlain);125126  // --- Message 3: -> s, se ---127  const cipherOurStatic = new ChaCha20Poly1305(k2);128  const encryptedOurStatic = cipherOurStatic.seal(nonceBytes(1n), ourStaticPublicKey, h);129  h = mixHash(h, encryptedOurStatic);130131  const se = sharedKey(ourStaticPrivateKey, remoteEphemeral);132  const [ck3, k3] = hkdf2(ck, se);133  ck = ck3;134135  const cipherFinal = new ChaCha20Poly1305(k3);136  const encryptedFinalPayload = cipherFinal.seal(nonceBytes(0n), new Uint8Array(0), h);137  h = mixHash(h, encryptedFinalPayload);138139  const msg3 = new Uint8Array(encryptedOurStatic.length + encryptedFinalPayload.length);140  msg3.set(encryptedOurStatic, 0);141  msg3.set(encryptedFinalPayload, encryptedOurStatic.length);142  await send(msg3);143144  // --- Transport keys: split (k_init_send, k_resp_send) ---145  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));146  const sendCipher = new ChaCha20Poly1305(initSend);147  const recvCipher = new ChaCha20Poly1305(respSend);148  let sendNonce = 0n;149  let recvNonce = 0n;150151  return {152    encrypt(plaintext) {153      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));154    },155    decrypt(ciphertext) {156      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));157      if (!result) throw new Error("Noise XX: decryption failed");158      return result;159    },160    remoteStaticPublicKey: remoteStaticPlain,161    handshakeHash: h.slice(),162  };163}164165/**166 * Drive the XX responder. The initiator's static key is learned in167 * message 3; we hand it back on success.168 */169export async function noiseXXResponder(170  ourStaticPrivateKey: Uint8Array,171  ourStaticPublicKey: Uint8Array,172  send: (msg: Uint8Array) => Promise<void>,173  receive: () => Promise<Uint8Array>,174): Promise<NoiseXXSession> {175  let h: Uint8Array = initHash();176  let ck: Uint8Array = h.slice();177178  // --- Message 1: <- e ---179  const msg1 = await receive();180  if (msg1.length < 32) throw new Error("Noise XX: msg1 too short");181  const remoteEphemeral = msg1.slice(0, 32);182  h = mixHash(h, remoteEphemeral);183184  // --- Message 2: -> e, ee, s, es ---185  const e = generateKeyPair();186  h = mixHash(h, e.publicKey);187  const ee = sharedKey(e.secretKey, remoteEphemeral);188  const [ck1, k1] = hkdf2(ck, ee);189  ck = ck1;190191  const cipherStatic = new ChaCha20Poly1305(k1);192  const encryptedOurStatic = cipherStatic.seal(nonceBytes(0n), ourStaticPublicKey, h);193  h = mixHash(h, encryptedOurStatic);194195  const es = sharedKey(ourStaticPrivateKey, remoteEphemeral);196  const [ck2, k2] = hkdf2(ck, es);197  ck = ck2;198199  const cipherPayload = new ChaCha20Poly1305(k2);200  const encryptedPayload = cipherPayload.seal(nonceBytes(0n), new Uint8Array(0), h);201  h = mixHash(h, encryptedPayload);202203  const msg2 = new Uint8Array(e.publicKey.length + encryptedOurStatic.length + encryptedPayload.length);204  msg2.set(e.publicKey, 0);205  msg2.set(encryptedOurStatic, e.publicKey.length);206  msg2.set(encryptedPayload, e.publicKey.length + encryptedOurStatic.length);207  await send(msg2);208209  // --- Message 3: <- s, se ---210  const msg3 = await receive();211  if (msg3.length < 32 + 16 + 16) throw new Error("Noise XX: msg3 too short");212  const encryptedRemoteStatic = msg3.slice(0, 32 + 16);213  const encryptedFinalPayload = msg3.slice(32 + 16);214215  const cipherTheirStatic = new ChaCha20Poly1305(k2);216  const remoteStaticPlain = cipherTheirStatic.open(nonceBytes(1n), encryptedRemoteStatic, h);217  if (!remoteStaticPlain || remoteStaticPlain.length !== 32) {218    throw new Error("Noise XX: msg3 static auth failed");219  }220  h = mixHash(h, encryptedRemoteStatic);221222  const se = sharedKey(e.secretKey, remoteStaticPlain);223  const [ck3, k3] = hkdf2(ck, se);224  ck = ck3;225226  const cipherFinal = new ChaCha20Poly1305(k3);227  const finalPayloadPlain = cipherFinal.open(nonceBytes(0n), encryptedFinalPayload, h);228  if (!finalPayloadPlain) {229    throw new Error("Noise XX: msg3 final payload auth failed");230  }231  h = mixHash(h, encryptedFinalPayload);232233  const [initSend, respSend] = hkdf2(ck, new Uint8Array(0));234  const sendCipher = new ChaCha20Poly1305(respSend);235  const recvCipher = new ChaCha20Poly1305(initSend);236  let sendNonce = 0n;237  let recvNonce = 0n;238239  return {240    encrypt(plaintext) {241      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));242    },243    decrypt(ciphertext) {244      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));245      if (!result) throw new Error("Noise XX: decryption failed");246      return result;247    },248    remoteStaticPublicKey: remoteStaticPlain,249    handshakeHash: h.slice(),250  };251}