noise-js.ts

15.0 KB · raw

1/**2 * Noise NK initiator for Android / Capacitor.3 * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s4 *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).10 *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.15 */1617import { generateKeyPair, sharedKey } from "@stablelib/x25519";18import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";19import { BLAKE2s } from "@stablelib/blake2s";20import { SHA256 } from "@stablelib/sha256";21import { HMAC } from "@stablelib/hmac";2223const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2425export function initHash(): Uint8Array {26  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);27  if (nameBytes.length <= 32) {28    const h = new Uint8Array(32);29    h.set(nameBytes);30    return h;31  }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();35}3637function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {38  const blake = new BLAKE2s(32);39  blake.update(h);40  blake.update(data);41  return blake.digest();42}4344export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {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];56}5758export interface NoiseNKSession {59  encrypt(plaintext: Uint8Array): Uint8Array;60  decrypt(ciphertext: Uint8Array): Uint8Array;61  handshakeHash: Uint8Array;62}6364/**65 * Run the initiator side of the Noise NK handshake. The caller provides66 * `send` / `receive` for the two transport-side handshake messages and gets67 * back a session object that encrypts/decrypts subsequent transport frames.68 */69export async function noiseNKInitiator(70  responderStaticPublicKey: Uint8Array,71  send: (msg: Uint8Array) => Promise<void>,72  receive: () => Promise<Uint8Array>,73): Promise<NoiseNKSession> {74  let h: Uint8Array = initHash();75  let ck: Uint8Array = h.slice();7677  // NK pre-message: mix responder static public key.78  h = mixHash(h, responderStaticPublicKey);7980  const ephemeral = generateKeyPair();8182  // --- Message 1: e, es ---83  h = mixHash(h, ephemeral.publicKey);84  // PCH-02-009: reject all-zero DH outputs from low-order peer points before85  // they enter the Noise key schedule. Honest peers are unaffected.86  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);87  const [ck1, k1] = hkdf2(ck, es);88  ck = ck1;8990  const cipher1 = new ChaCha20Poly1305(k1);91  const nonce1 = new Uint8Array(12);92  const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);93  h = mixHash(h, encrypted1);9495  const msg1 = new Uint8Array(32 + encrypted1.length);96  msg1.set(ephemeral.publicKey, 0);97  msg1.set(encrypted1, 32);98  await send(msg1);99100  // --- Message 2: e, ee ---101  const msg2 = await receive();102  if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");103104  const remoteEphemeralPublic = msg2.slice(0, 32);105  const encrypted2 = msg2.slice(32);106107  h = mixHash(h, remoteEphemeralPublic);108  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);109  const [ck2, k2] = hkdf2(ck, ee);110  ck = ck2;111112  const cipher2 = new ChaCha20Poly1305(k2);113  const nonce2 = new Uint8Array(12);114  const decrypted2 = cipher2.open(nonce2, encrypted2, h);115  if (!decrypted2) {116    throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");117  }118  h = mixHash(h, encrypted2);119120  // --- Transport phase ---121  const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));122  const handshakeHash = h.slice();123124  let sendNonce = 0n;125  let recvNonce = 0n;126127  function nonceBytes(n: bigint): Uint8Array {128    const buf = new Uint8Array(12);129    new DataView(buf.buffer).setBigUint64(4, n, true);130    return buf;131  }132133  const sendCipher = new ChaCha20Poly1305(sendKey);134  const recvCipher = new ChaCha20Poly1305(recvKey);135136  return {137    encrypt(plaintext) {138      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));139    },140    decrypt(ciphertext) {141      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));142      if (!result) throw new Error("Noise NK: decryption failed");143      return result;144    },145    handshakeHash,146  };147}148149/**150 * Compute the post-handshake auth proof.151 *152 * proof = HMAC-SHA256(153 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared154 *   data  = "peach-sync-v1-initiator-auth"155 *        || initiator_public156 *        || responder_public157 *        || nonce_32158 * )159 *160 * Both sides pass the same DH peer key (their PEER's public key) and the same161 * initiator_public/responder_public ordering so the MAC matches. This helper162 * targets the initiator-side: `local_priv` is the initiator's private key,163 * `remote_pub` is the responder's public key.164 */165const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");166167export function computeAuthProof(168  initiatorPrivate: Uint8Array,169  initiatorPublic: Uint8Array,170  responderPublic: Uint8Array,171  nonce: Uint8Array,172  handshakeHash: Uint8Array,173): Uint8Array {174  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");175  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");176177  const dh = sharedKey(initiatorPrivate, responderPublic, true);178  const mac = new HMAC(SHA256, dh);179  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed180  // onto a different handshake. handshakeHash is prepended; both sides hold the181  // identical value and feed it in the same position.182  mac.update(handshakeHash);183  mac.update(AUTH_DOMAIN);184  mac.update(initiatorPublic);185  mac.update(responderPublic);186  mac.update(nonce);187  return mac.digest();188}189190interface AuthChallengeFrame {191  type: "CHALLENGE";192  nonce_hex: string;193  responder_public_key_hex: string;194}195196interface AuthProofFrame {197  type: "PROOF";198  initiator_public_key_hex: string;199  proof_hex: string;200}201202function bytesToHex(bytes: Uint8Array): string {203  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");204}205206function hexToBytes(hex: string): Uint8Array {207  if (hex.length % 2 !== 0) throw new Error("invalid hex");208  const bytes = new Uint8Array(hex.length / 2);209  for (let i = 0; i < hex.length; i += 2) {210    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);211  }212  return bytes;213}214215/**216 * Drive the initiator side of the post-handshake challenge/proof round. The217 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply218 * with a `Proof` frame. Both frames are encrypted via the post-handshake219 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).220 */221/**222 * Run the responder side of the Noise NK handshake. Symmetric with223 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two224 * handshake messages and gets back a session for transport-phase frames.225 *226 * Symmetric with the Android hand-port; no shared Rust implementation remains.227 * An Android-as-host pairing session can be driven by the desktop initiator and228 * vice versa as long as both derive over BLAKE2s (PCH-01-002).229 */230export async function noiseNKResponder(231  ourStaticPrivateKey: Uint8Array,232  ourStaticPublicKey: Uint8Array,233  send: (msg: Uint8Array) => Promise<void>,234  receive: () => Promise<Uint8Array>,235): Promise<NoiseNKSession> {236  let h: Uint8Array = initHash();237  let ck: Uint8Array = h.slice();238239  // NK pre-message: mix our (responder) static public key. The initiator240  // pre-mixed the same key on its side; both transcripts must match.241  h = mixHash(h, ourStaticPublicKey);242243  // --- Message 1: e, es ---244  const msg1 = await receive();245  if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");246  const remoteEphemeralPublic = msg1.slice(0, 32);247  const encrypted1 = msg1.slice(32);248249  h = mixHash(h, remoteEphemeralPublic);250  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);251  const [ck1, k1] = hkdf2(ck, es);252  ck = ck1;253254  const cipher1 = new ChaCha20Poly1305(k1);255  const nonce1 = new Uint8Array(12);256  const decrypted1 = cipher1.open(nonce1, encrypted1, h);257  if (!decrypted1) {258    throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");259  }260  h = mixHash(h, encrypted1);261262  // --- Message 2: e, ee ---263  const ephemeral = generateKeyPair();264  h = mixHash(h, ephemeral.publicKey);265  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);266  const [ck2, k2] = hkdf2(ck, ee);267  ck = ck2;268269  const cipher2 = new ChaCha20Poly1305(k2);270  const nonce2 = new Uint8Array(12);271  const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);272  h = mixHash(h, encrypted2);273274  const msg2 = new Uint8Array(32 + encrypted2.length);275  msg2.set(ephemeral.publicKey, 0);276  msg2.set(encrypted2, 32);277  await send(msg2);278279  // --- Transport phase ---280  // hkdf2 returns (k1, k2); the initiator uses k1 to send and k2 to receive,281  // so the responder swaps: k1 → recv, k2 → send.282  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));283  const handshakeHash = h.slice();284285  let sendNonce = 0n;286  let recvNonce = 0n;287288  function nonceBytes(n: bigint): Uint8Array {289    const buf = new Uint8Array(12);290    new DataView(buf.buffer).setBigUint64(4, n, true);291    return buf;292  }293294  const sendCipher = new ChaCha20Poly1305(responderSendKey);295  const recvCipher = new ChaCha20Poly1305(initiatorSendKey);296297  return {298    encrypt(plaintext) {299      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));300    },301    decrypt(ciphertext) {302      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));303      if (!result) throw new Error("Noise NK: decryption failed");304      return result;305    },306    handshakeHash,307  };308}309310/**311 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`312 * goes through this so a same-LAN attacker can't time the HMAC compare.313 */314function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {315  if (a.length !== b.length) return false;316  let diff = 0;317  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;318  return diff === 0;319}320321/**322 * Drive the responder side of the post-handshake challenge/proof round. We323 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and324 * verify. Returns the initiator's authenticated public key on success.325 *326 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same327 * domain-separated transcript both sides feed into HMAC-SHA256.328 */329export async function performResponderAuth(330  session: NoiseNKSession,331  ourPrivateKey: Uint8Array,332  ourPublicKey: Uint8Array,333  sendEncryptedJson: (data: Uint8Array) => Promise<void>,334  receiveEncryptedJson: () => Promise<Uint8Array>,335): Promise<{ initiatorPublicKey: Uint8Array }> {336  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.337338  const nonce = new Uint8Array(32);339  crypto.getRandomValues(nonce);340341  const challenge: AuthChallengeFrame = {342    type: "CHALLENGE",343    nonce_hex: bytesToHex(nonce),344    responder_public_key_hex: bytesToHex(ourPublicKey),345  };346  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));347348  const proofBytes = await receiveEncryptedJson();349  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;350  if (proofFrame.type !== "PROOF") {351    throw new Error("expected PROOF frame");352  }353354  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);355  const claimedProof = hexToBytes(proofFrame.proof_hex);356  if (initiatorPublicKey.length !== 32) {357    throw new Error("initiator public key must be 32 bytes");358  }359  if (claimedProof.length !== 32) {360    throw new Error("proof must be 32 bytes");361  }362363  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)364  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);365  const mac = new HMAC(SHA256, dh);366  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the367  // existing domain/keys/nonce transcript. Same value, same position on both ends.368  mac.update(session.handshakeHash);369  mac.update(AUTH_DOMAIN);370  mac.update(initiatorPublicKey);371  mac.update(ourPublicKey);372  mac.update(nonce);373  const expected = mac.digest();374375  if (!constantTimeEq(expected, claimedProof)) {376    throw new Error("auth proof did not verify");377  }378  return { initiatorPublicKey };379}380381export async function performInitiatorAuth(382  session: NoiseNKSession,383  ourPrivateKey: Uint8Array,384  ourPublicKey: Uint8Array,385  responderPublicKey: Uint8Array,386  sendEncryptedJson: (data: Uint8Array) => Promise<void>,387  receiveEncryptedJson: () => Promise<Uint8Array>,388): Promise<void> {389  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding390  // it to this exact session (was previously bound via the DH key only).391392  const challengeBytes = await receiveEncryptedJson();393  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;394  if (challenge.type !== "CHALLENGE") {395    throw new Error("expected CHALLENGE frame");396  }397  const nonce = hexToBytes(challenge.nonce_hex);398  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);399  // Defence against an off-key challenge: the responder's claimed key MUST be400  // the one we Noise-NK-handshook against, otherwise an active MITM that401  // managed to substitute a different key is rerouting the auth round.402  if (403    responderKeyFromChallenge.length !== responderPublicKey.length ||404    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])405  ) {406    throw new Error("responder key in challenge does not match Noise handshake");407  }408409  const proof = computeAuthProof(410    ourPrivateKey,411    ourPublicKey,412    responderPublicKey,413    nonce,414    session.handshakeHash,415  );416  const proofFrame: AuthProofFrame = {417    type: "PROOF",418    initiator_public_key_hex: bytesToHex(ourPublicKey),419    proof_hex: bytesToHex(proof),420  };421  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));422}423424export { bytesToHex, hexToBytes };