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, so a stock Noise stack should interoperate.13 * The Android port must mirror these hash choices and transcript steps. Any14 * change to the hash, HKDF, or transcript here is a BREAKING wire change and15 * requires a coordinated Android release.16 */1718import { generateKeyPair, sharedKey } from "@stablelib/x25519";19import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";20import { BLAKE2s } from "@stablelib/blake2s";21import { SHA256 } from "@stablelib/sha256";22import { HMAC } from "@stablelib/hmac";2324const PROTOCOL_NAME = "Noise_NK_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.35  return new BLAKE2s(32).update(nameBytes).digest();36}3738function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {39  const blake = new BLAKE2s(32);40  blake.update(h);41  blake.update(data);42  return blake.digest();43}4445export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {46  // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.47  // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState()48  // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run49  // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per50  // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`51  // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a52  // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).53  const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)54  const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();55  const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();56  return [k1, k2];57}5859export interface NoiseNKSession {60  encrypt(plaintext: Uint8Array): Uint8Array;61  decrypt(ciphertext: Uint8Array): Uint8Array;62  handshakeHash: Uint8Array;63}6465/**66 * Run the initiator side of the Noise NK handshake. The caller provides67 * `send` / `receive` for the two transport-side handshake messages and gets68 * back a session object that encrypts/decrypts subsequent transport frames.69 */70export async function noiseNKInitiator(71  responderStaticPublicKey: Uint8Array,72  send: (msg: Uint8Array) => Promise<void>,73  receive: () => Promise<Uint8Array>,74): Promise<NoiseNKSession> {75  let h: Uint8Array = initHash();76  let ck: Uint8Array = h.slice();7778  // NK pre-message: mix responder static public key.79  h = mixHash(h, responderStaticPublicKey);8081  const ephemeral = generateKeyPair();8283  // --- Message 1: e, es ---84  h = mixHash(h, ephemeral.publicKey);85  // PCH-02-009: reject all-zero DH outputs from low-order peer points before86  // they enter the Noise key schedule. Honest peers are unaffected.87  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey, true);88  const [ck1, k1] = hkdf2(ck, es);89  ck = ck1;9091  const cipher1 = new ChaCha20Poly1305(k1);92  const nonce1 = new Uint8Array(12);93  const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);94  h = mixHash(h, encrypted1);9596  const msg1 = new Uint8Array(32 + encrypted1.length);97  msg1.set(ephemeral.publicKey, 0);98  msg1.set(encrypted1, 32);99  await send(msg1);100101  // --- Message 2: e, ee ---102  const msg2 = await receive();103  if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");104105  const remoteEphemeralPublic = msg2.slice(0, 32);106  const encrypted2 = msg2.slice(32);107108  h = mixHash(h, remoteEphemeralPublic);109  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);110  const [ck2, k2] = hkdf2(ck, ee);111  ck = ck2;112113  const cipher2 = new ChaCha20Poly1305(k2);114  const nonce2 = new Uint8Array(12);115  const decrypted2 = cipher2.open(nonce2, encrypted2, h);116  if (!decrypted2) {117    throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");118  }119  h = mixHash(h, encrypted2);120121  // --- Transport phase ---122  const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));123  const handshakeHash = h.slice();124125  let sendNonce = 0n;126  let recvNonce = 0n;127128  function nonceBytes(n: bigint): Uint8Array {129    const buf = new Uint8Array(12);130    new DataView(buf.buffer).setBigUint64(4, n, true);131    return buf;132  }133134  const sendCipher = new ChaCha20Poly1305(sendKey);135  const recvCipher = new ChaCha20Poly1305(recvKey);136137  return {138    encrypt(plaintext) {139      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));140    },141    decrypt(ciphertext) {142      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));143      if (!result) throw new Error("Noise NK: decryption failed");144      return result;145    },146    handshakeHash,147  };148}149150/**151 * Compute the post-handshake auth proof.152 *153 * proof = HMAC-SHA256(154 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared155 *   data  = "peach-sync-v1-initiator-auth"156 *        || initiator_public157 *        || responder_public158 *        || nonce_32159 * )160 *161 * Both sides pass the same DH peer key (their PEER's public key) and the same162 * initiator_public/responder_public ordering so the MAC matches. This helper163 * targets the initiator-side: `local_priv` is the initiator's private key,164 * `remote_pub` is the responder's public key.165 */166const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");167168export function computeAuthProof(169  initiatorPrivate: Uint8Array,170  initiatorPublic: Uint8Array,171  responderPublic: Uint8Array,172  nonce: Uint8Array,173  handshakeHash: Uint8Array,174): Uint8Array {175  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");176  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");177178  const dh = sharedKey(initiatorPrivate, responderPublic, true);179  const mac = new HMAC(SHA256, dh);180  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed181  // onto a different handshake. handshakeHash is prepended; both sides hold the182  // identical value and feed it in the same position.183  mac.update(handshakeHash);184  mac.update(AUTH_DOMAIN);185  mac.update(initiatorPublic);186  mac.update(responderPublic);187  mac.update(nonce);188  return mac.digest();189}190191interface AuthChallengeFrame {192  type: "CHALLENGE";193  nonce_hex: string;194  responder_public_key_hex: string;195}196197interface AuthProofFrame {198  type: "PROOF";199  initiator_public_key_hex: string;200  proof_hex: string;201}202203function bytesToHex(bytes: Uint8Array): string {204  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");205}206207function hexToBytes(hex: string): Uint8Array {208  if (hex.length % 2 !== 0) throw new Error("invalid hex");209  const bytes = new Uint8Array(hex.length / 2);210  for (let i = 0; i < hex.length; i += 2) {211    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);212  }213  return bytes;214}215216/**217 * Drive the initiator side of the post-handshake challenge/proof round. The218 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply219 * with a `Proof` frame. Both frames are encrypted via the post-handshake220 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).221 */222/**223 * Run the responder side of the Noise NK handshake. Symmetric with224 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two225 * handshake messages and gets back a session for transport-phase frames.226 *227 * Symmetric with the Android hand-port; no shared Rust implementation remains.228 * An Android-as-host pairing session can be driven by the browser initiator and229 * vice versa as long as both derive over BLAKE2s (PCH-01-002).230 */231export async function noiseNKResponder(232  ourStaticPrivateKey: Uint8Array,233  ourStaticPublicKey: Uint8Array,234  send: (msg: Uint8Array) => Promise<void>,235  receive: () => Promise<Uint8Array>,236): Promise<NoiseNKSession> {237  let h: Uint8Array = initHash();238  let ck: Uint8Array = h.slice();239240  // NK pre-message: mix our (responder) static public key. The initiator241  // pre-mixed the same key on its side; both transcripts must match.242  h = mixHash(h, ourStaticPublicKey);243244  // --- Message 1: e, es ---245  const msg1 = await receive();246  if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");247  const remoteEphemeralPublic = msg1.slice(0, 32);248  const encrypted1 = msg1.slice(32);249250  h = mixHash(h, remoteEphemeralPublic);251  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic, true);252  const [ck1, k1] = hkdf2(ck, es);253  ck = ck1;254255  const cipher1 = new ChaCha20Poly1305(k1);256  const nonce1 = new Uint8Array(12);257  const decrypted1 = cipher1.open(nonce1, encrypted1, h);258  if (!decrypted1) {259    throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");260  }261  h = mixHash(h, encrypted1);262263  // --- Message 2: e, ee ---264  const ephemeral = generateKeyPair();265  h = mixHash(h, ephemeral.publicKey);266  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic, true);267  const [ck2, k2] = hkdf2(ck, ee);268  ck = ck2;269270  const cipher2 = new ChaCha20Poly1305(k2);271  const nonce2 = new Uint8Array(12);272  const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);273  h = mixHash(h, encrypted2);274275  const msg2 = new Uint8Array(32 + encrypted2.length);276  msg2.set(ephemeral.publicKey, 0);277  msg2.set(encrypted2, 32);278  await send(msg2);279280  // --- Transport phase ---281  // hkdf2 returns (k1, k2); the initiator uses k1 to send and k2 to receive,282  // so the responder swaps: k1 → recv, k2 → send.283  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));284  const handshakeHash = h.slice();285286  let sendNonce = 0n;287  let recvNonce = 0n;288289  function nonceBytes(n: bigint): Uint8Array {290    const buf = new Uint8Array(12);291    new DataView(buf.buffer).setBigUint64(4, n, true);292    return buf;293  }294295  const sendCipher = new ChaCha20Poly1305(responderSendKey);296  const recvCipher = new ChaCha20Poly1305(initiatorSendKey);297298  return {299    encrypt(plaintext) {300      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));301    },302    decrypt(ciphertext) {303      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));304      if (!result) throw new Error("Noise NK: decryption failed");305      return result;306    },307    handshakeHash,308  };309}310311/**312 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`313 * goes through this so a same-LAN attacker can't time the HMAC compare.314 */315function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {316  if (a.length !== b.length) return false;317  let diff = 0;318  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;319  return diff === 0;320}321322/**323 * Drive the responder side of the post-handshake challenge/proof round. We324 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and325 * verify. Returns the initiator's authenticated public key on success.326 *327 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same328 * domain-separated transcript both sides feed into HMAC-SHA256.329 */330export async function performResponderAuth(331  session: NoiseNKSession,332  ourPrivateKey: Uint8Array,333  ourPublicKey: Uint8Array,334  sendEncryptedJson: (data: Uint8Array) => Promise<void>,335  receiveEncryptedJson: () => Promise<Uint8Array>,336): Promise<{ initiatorPublicKey: Uint8Array }> {337  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.338339  const nonce = new Uint8Array(32);340  crypto.getRandomValues(nonce);341342  const challenge: AuthChallengeFrame = {343    type: "CHALLENGE",344    nonce_hex: bytesToHex(nonce),345    responder_public_key_hex: bytesToHex(ourPublicKey),346  };347  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));348349  const proofBytes = await receiveEncryptedJson();350  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;351  if (proofFrame.type !== "PROOF") {352    throw new Error("expected PROOF frame");353  }354355  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);356  const claimedProof = hexToBytes(proofFrame.proof_hex);357  if (initiatorPublicKey.length !== 32) {358    throw new Error("initiator public key must be 32 bytes");359  }360  if (claimedProof.length !== 32) {361    throw new Error("proof must be 32 bytes");362  }363364  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)365  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);366  const mac = new HMAC(SHA256, dh);367  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the368  // existing domain/keys/nonce transcript. Same value, same position on both ends.369  mac.update(session.handshakeHash);370  mac.update(AUTH_DOMAIN);371  mac.update(initiatorPublicKey);372  mac.update(ourPublicKey);373  mac.update(nonce);374  const expected = mac.digest();375376  if (!constantTimeEq(expected, claimedProof)) {377    throw new Error("auth proof did not verify");378  }379  return { initiatorPublicKey };380}381382export async function performInitiatorAuth(383  session: NoiseNKSession,384  ourPrivateKey: Uint8Array,385  ourPublicKey: Uint8Array,386  responderPublicKey: Uint8Array,387  sendEncryptedJson: (data: Uint8Array) => Promise<void>,388  receiveEncryptedJson: () => Promise<Uint8Array>,389): Promise<void> {390  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding391  // it to this exact session (was previously bound via the DH key only).392393  const challengeBytes = await receiveEncryptedJson();394  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;395  if (challenge.type !== "CHALLENGE") {396    throw new Error("expected CHALLENGE frame");397  }398  const nonce = hexToBytes(challenge.nonce_hex);399  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);400  // Defence against an off-key challenge: the responder's claimed key MUST be401  // the one we Noise-NK-handshook against, otherwise an active MITM that402  // managed to substitute a different key is rerouting the auth round.403  if (404    responderKeyFromChallenge.length !== responderPublicKey.length ||405    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])406  ) {407    throw new Error("responder key in challenge does not match Noise handshake");408  }409410  const proof = computeAuthProof(411    ourPrivateKey,412    ourPublicKey,413    responderPublicKey,414    nonce,415    session.handshakeHash,416  );417  const proofFrame: AuthProofFrame = {418    type: "PROOF",419    initiator_public_key_hex: bytesToHex(ourPublicKey),420    proof_hex: bytesToHex(proof),421  };422  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));423}424425export { bytesToHex, hexToBytes };