noise-js.ts

15.1 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. PCH-02-002 brought the XX hand-port back to the canonical13 * empty-payload msg1 transcript too, so a stock Noise stack should interoperate.14 * The Android port must mirror these hash choices and transcript steps. Any15 * change to the hash, HKDF, or transcript here is a BREAKING wire change and16 * requires a coordinated Android release.17 */1819import { generateKeyPair, sharedKey } from "@stablelib/x25519";20import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";21import { BLAKE2s } from "@stablelib/blake2s";22import { SHA256 } from "@stablelib/sha256";23import { HMAC } from "@stablelib/hmac";2425const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2627export function 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  // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash35  // MUST use it too (Noise spec §5.2), not SHA-256.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 blocks and BLAKE2s's restoreState()49  // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run50  // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per51  // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`52  // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a53  // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).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}5960export interface NoiseNKSession {61  encrypt(plaintext: Uint8Array): Uint8Array;62  decrypt(ciphertext: Uint8Array): Uint8Array;63  handshakeHash: Uint8Array;64}6566/**67 * Run the initiator side of the Noise NK handshake. The caller provides68 * `send` / `receive` for the two transport-side handshake messages and gets69 * back a session object that encrypts/decrypts subsequent transport frames.70 */71export async function noiseNKInitiator(72  responderStaticPublicKey: Uint8Array,73  send: (msg: Uint8Array) => Promise<void>,74  receive: () => Promise<Uint8Array>,75): Promise<NoiseNKSession> {76  let h: Uint8Array = initHash();77  let ck: Uint8Array = h.slice();7879  // NK pre-message: mix responder static public key.80  h = mixHash(h, responderStaticPublicKey);8182  const ephemeral = generateKeyPair();8384  // --- Message 1: e, es ---85  h = mixHash(h, ephemeral.publicKey);86  // PCH-02-009: reject all-zero DH outputs from low-order peer points before87  // they enter the Noise key schedule. Honest peers are unaffected.88  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);89  const [ck1, k1] = hkdf2(ck, es);90  ck = ck1;9192  const cipher1 = new ChaCha20Poly1305(k1);93  const nonce1 = new Uint8Array(12);94  const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);95  h = mixHash(h, encrypted1);9697  const msg1 = new Uint8Array(32 + encrypted1.length);98  msg1.set(ephemeral.publicKey, 0);99  msg1.set(encrypted1, 32);100  await send(msg1);101102  // --- Message 2: e, ee ---103  const msg2 = await receive();104  if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");105106  const remoteEphemeralPublic = msg2.slice(0, 32);107  const encrypted2 = msg2.slice(32);108109  h = mixHash(h, remoteEphemeralPublic);110  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);111  const [ck2, k2] = hkdf2(ck, ee);112  ck = ck2;113114  const cipher2 = new ChaCha20Poly1305(k2);115  const nonce2 = new Uint8Array(12);116  const decrypted2 = cipher2.open(nonce2, encrypted2, h);117  if (!decrypted2) {118    throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");119  }120  h = mixHash(h, encrypted2);121122  // --- Transport phase ---123  const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));124  const handshakeHash = h.slice();125126  let sendNonce = 0n;127  let recvNonce = 0n;128129  function nonceBytes(n: bigint): Uint8Array {130    const buf = new Uint8Array(12);131    new DataView(buf.buffer).setBigUint64(4, n, true);132    return buf;133  }134135  const sendCipher = new ChaCha20Poly1305(sendKey);136  const recvCipher = new ChaCha20Poly1305(recvKey);137138  return {139    encrypt(plaintext) {140      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));141    },142    decrypt(ciphertext) {143      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));144      if (!result) throw new Error("Noise NK: decryption failed");145      return result;146    },147    handshakeHash,148  };149}150151/**152 * Compute the post-handshake auth proof.153 *154 * proof = HMAC-SHA256(155 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared156 *   data  = "peach-sync-v1-initiator-auth"157 *        || initiator_public158 *        || responder_public159 *        || nonce_32160 * )161 *162 * Both sides pass the same DH peer key (their PEER's public key) and the same163 * initiator_public/responder_public ordering so the MAC matches. This helper164 * targets the initiator-side: `local_priv` is the initiator's private key,165 * `remote_pub` is the responder's public key.166 */167const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");168169export function computeAuthProof(170  initiatorPrivate: Uint8Array,171  initiatorPublic: Uint8Array,172  responderPublic: Uint8Array,173  nonce: Uint8Array,174  handshakeHash: Uint8Array,175): Uint8Array {176  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");177  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");178179  const dh = sharedKey(initiatorPrivate, responderPublic, true);180  const mac = new HMAC(SHA256, dh);181  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed182  // onto a different handshake. handshakeHash is prepended; both sides hold the183  // identical value and feed it in the same position.184  mac.update(handshakeHash);185  mac.update(AUTH_DOMAIN);186  mac.update(initiatorPublic);187  mac.update(responderPublic);188  mac.update(nonce);189  return mac.digest();190}191192interface AuthChallengeFrame {193  type: "CHALLENGE";194  nonce_hex: string;195  responder_public_key_hex: string;196}197198interface AuthProofFrame {199  type: "PROOF";200  initiator_public_key_hex: string;201  proof_hex: string;202}203204function bytesToHex(bytes: Uint8Array): string {205  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");206}207208function hexToBytes(hex: string): Uint8Array {209  if (hex.length % 2 !== 0) throw new Error("invalid hex");210  const bytes = new Uint8Array(hex.length / 2);211  for (let i = 0; i < hex.length; i += 2) {212    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);213  }214  return bytes;215}216217/**218 * Drive the initiator side of the post-handshake challenge/proof round. The219 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply220 * with a `Proof` frame. Both frames are encrypted via the post-handshake221 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).222 */223/**224 * Run the responder side of the Noise NK handshake. Symmetric with225 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two226 * handshake messages and gets back a session for transport-phase frames.227 *228 * Symmetric with the Android hand-port; no shared Rust implementation remains.229 * An Android-as-host pairing session can be driven by the desktop initiator and230 * vice versa as long as both derive over BLAKE2s (PCH-01-002).231 */232export async function noiseNKResponder(233  ourStaticPrivateKey: Uint8Array,234  ourStaticPublicKey: Uint8Array,235  send: (msg: Uint8Array) => Promise<void>,236  receive: () => Promise<Uint8Array>,237): Promise<NoiseNKSession> {238  let h: Uint8Array = initHash();239  let ck: Uint8Array = h.slice();240241  // NK pre-message: mix our (responder) static public key. The initiator242  // pre-mixed the same key on its side; both transcripts must match.243  h = mixHash(h, ourStaticPublicKey);244245  // --- Message 1: e, es ---246  const msg1 = await receive();247  if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");248  const remoteEphemeralPublic = msg1.slice(0, 32);249  const encrypted1 = msg1.slice(32);250251  h = mixHash(h, remoteEphemeralPublic);252  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);253  const [ck1, k1] = hkdf2(ck, es);254  ck = ck1;255256  const cipher1 = new ChaCha20Poly1305(k1);257  const nonce1 = new Uint8Array(12);258  const decrypted1 = cipher1.open(nonce1, encrypted1, h);259  if (!decrypted1) {260    throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");261  }262  h = mixHash(h, encrypted1);263264  // --- Message 2: e, ee ---265  const ephemeral = generateKeyPair();266  h = mixHash(h, ephemeral.publicKey);267  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);268  const [ck2, k2] = hkdf2(ck, ee);269  ck = ck2;270271  const cipher2 = new ChaCha20Poly1305(k2);272  const nonce2 = new Uint8Array(12);273  const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);274  h = mixHash(h, encrypted2);275276  const msg2 = new Uint8Array(32 + encrypted2.length);277  msg2.set(ephemeral.publicKey, 0);278  msg2.set(encrypted2, 32);279  await send(msg2);280281  // --- Transport phase ---282  // hkdf2 returns (k1, k2); the initiator uses k1 to send and k2 to receive,283  // so the responder swaps: k1 → recv, k2 → send.284  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));285  const handshakeHash = h.slice();286287  let sendNonce = 0n;288  let recvNonce = 0n;289290  function nonceBytes(n: bigint): Uint8Array {291    const buf = new Uint8Array(12);292    new DataView(buf.buffer).setBigUint64(4, n, true);293    return buf;294  }295296  const sendCipher = new ChaCha20Poly1305(responderSendKey);297  const recvCipher = new ChaCha20Poly1305(initiatorSendKey);298299  return {300    encrypt(plaintext) {301      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));302    },303    decrypt(ciphertext) {304      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));305      if (!result) throw new Error("Noise NK: decryption failed");306      return result;307    },308    handshakeHash,309  };310}311312/**313 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`314 * goes through this so a same-LAN attacker can't time the HMAC compare.315 */316function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {317  if (a.length !== b.length) return false;318  let diff = 0;319  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;320  return diff === 0;321}322323/**324 * Drive the responder side of the post-handshake challenge/proof round. We325 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and326 * verify. Returns the initiator's authenticated public key on success.327 *328 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same329 * domain-separated transcript both sides feed into HMAC-SHA256.330 */331export async function performResponderAuth(332  session: NoiseNKSession,333  ourPrivateKey: Uint8Array,334  ourPublicKey: Uint8Array,335  sendEncryptedJson: (data: Uint8Array) => Promise<void>,336  receiveEncryptedJson: () => Promise<Uint8Array>,337): Promise<{ initiatorPublicKey: Uint8Array }> {338  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.339340  const nonce = new Uint8Array(32);341  crypto.getRandomValues(nonce);342343  const challenge: AuthChallengeFrame = {344    type: "CHALLENGE",345    nonce_hex: bytesToHex(nonce),346    responder_public_key_hex: bytesToHex(ourPublicKey),347  };348  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));349350  const proofBytes = await receiveEncryptedJson();351  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;352  if (proofFrame.type !== "PROOF") {353    throw new Error("expected PROOF frame");354  }355356  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);357  const claimedProof = hexToBytes(proofFrame.proof_hex);358  if (initiatorPublicKey.length !== 32) {359    throw new Error("initiator public key must be 32 bytes");360  }361  if (claimedProof.length !== 32) {362    throw new Error("proof must be 32 bytes");363  }364365  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)366  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);367  const mac = new HMAC(SHA256, dh);368  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the369  // existing domain/keys/nonce transcript. Same value, same position on both ends.370  mac.update(session.handshakeHash);371  mac.update(AUTH_DOMAIN);372  mac.update(initiatorPublicKey);373  mac.update(ourPublicKey);374  mac.update(nonce);375  const expected = mac.digest();376377  if (!constantTimeEq(expected, claimedProof)) {378    throw new Error("auth proof did not verify");379  }380  return { initiatorPublicKey };381}382383export async function performInitiatorAuth(384  session: NoiseNKSession,385  ourPrivateKey: Uint8Array,386  ourPublicKey: Uint8Array,387  responderPublicKey: Uint8Array,388  sendEncryptedJson: (data: Uint8Array) => Promise<void>,389  receiveEncryptedJson: () => Promise<Uint8Array>,390): Promise<void> {391  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding392  // it to this exact session (was previously bound via the DH key only).393394  const challengeBytes = await receiveEncryptedJson();395  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;396  if (challenge.type !== "CHALLENGE") {397    throw new Error("expected CHALLENGE frame");398  }399  const nonce = hexToBytes(challenge.nonce_hex);400  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);401  // Defence against an off-key challenge: the responder's claimed key MUST be402  // the one we Noise-NK-handshook against, otherwise an active MITM that403  // managed to substitute a different key is rerouting the auth round.404  if (405    responderKeyFromChallenge.length !== responderPublicKey.length ||406    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])407  ) {408    throw new Error("responder key in challenge does not match Noise handshake");409  }410411  const proof = computeAuthProof(412    ourPrivateKey,413    ourPublicKey,414    responderPublicKey,415    nonce,416    session.handshakeHash,417  );418  const proofFrame: AuthProofFrame = {419    type: "PROOF",420    initiator_public_key_hex: bytesToHex(ourPublicKey),421    proof_hex: bytesToHex(proof),422  };423  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));424}425426export { bytesToHex, hexToBytes };