noise-js.ts

13.7 KB · raw

1/**2 * Noise NK initiator for Android / Capacitor.3 * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s4 *5 * The native Rust host runs `snow` to drive the same pattern; this JS6 * implementation produces wire-identical bytes so a Capacitor WebView client7 * can connect to the desktop host over WebSocket.8 *9 * MUST stay in lockstep with the native side. The cross-check test in10 * `noise-js.test.ts` pins exact byte outputs against fixed inputs so a11 * regression on either side fails CI before any device pair gets wedged.12 */1314import { generateKeyPair, sharedKey } from "@stablelib/x25519";15import { ChaCha20Poly1305 } from "@stablelib/chacha20poly1305";16import { BLAKE2s } from "@stablelib/blake2s";17import { HKDF } from "@stablelib/hkdf";18import { SHA256 } from "@stablelib/sha256";19import { HMAC } from "@stablelib/hmac";2021const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2223function initHash(): Uint8Array {24  const nameBytes = new TextEncoder().encode(PROTOCOL_NAME);25  if (nameBytes.length <= 32) {26    const h = new Uint8Array(32);27    h.set(nameBytes);28    return h;29  }30  const sha = new SHA256();31  sha.update(nameBytes);32  return sha.digest();33}3435function mixHash(h: Uint8Array, data: Uint8Array): Uint8Array {36  const blake = new BLAKE2s(32);37  blake.update(h);38  blake.update(data);39  return blake.digest();40}4142function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {43  const out = new HKDF(SHA256, ikm, ck).expand(64);44  return [out.slice(0, 32), out.slice(32, 64)];45}4647export interface NoiseNKSession {48  encrypt(plaintext: Uint8Array): Uint8Array;49  decrypt(ciphertext: Uint8Array): Uint8Array;50  handshakeHash: Uint8Array;51}5253/**54 * Run the initiator side of the Noise NK handshake. The caller provides55 * `send` / `receive` for the two transport-side handshake messages and gets56 * back a session object that encrypts/decrypts subsequent transport frames.57 */58export async function noiseNKInitiator(59  responderStaticPublicKey: Uint8Array,60  send: (msg: Uint8Array) => Promise<void>,61  receive: () => Promise<Uint8Array>,62): Promise<NoiseNKSession> {63  let h: Uint8Array = initHash();64  let ck: Uint8Array = h.slice();6566  // NK pre-message: mix responder static public key.67  h = mixHash(h, responderStaticPublicKey);6869  const ephemeral = generateKeyPair();7071  // --- Message 1: e, es ---72  h = mixHash(h, ephemeral.publicKey);73  const es = sharedKey(ephemeral.secretKey, responderStaticPublicKey);74  const [ck1, k1] = hkdf2(ck, es);75  ck = ck1;7677  const cipher1 = new ChaCha20Poly1305(k1);78  const nonce1 = new Uint8Array(12);79  const encrypted1 = cipher1.seal(nonce1, new Uint8Array(0), h);80  h = mixHash(h, encrypted1);8182  const msg1 = new Uint8Array(32 + encrypted1.length);83  msg1.set(ephemeral.publicKey, 0);84  msg1.set(encrypted1, 32);85  await send(msg1);8687  // --- Message 2: e, ee ---88  const msg2 = await receive();89  if (msg2.length < 48) throw new Error("Noise NK: msg2 too short");9091  const remoteEphemeralPublic = msg2.slice(0, 32);92  const encrypted2 = msg2.slice(32);9394  h = mixHash(h, remoteEphemeralPublic);95  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);96  const [ck2, k2] = hkdf2(ck, ee);97  ck = ck2;9899  const cipher2 = new ChaCha20Poly1305(k2);100  const nonce2 = new Uint8Array(12);101  const decrypted2 = cipher2.open(nonce2, encrypted2, h);102  if (!decrypted2) {103    throw new Error("Noise NK: msg2 auth failed — wrong responder key or MITM");104  }105  h = mixHash(h, encrypted2);106107  // --- Transport phase ---108  const [sendKey, recvKey] = hkdf2(ck, new Uint8Array(0));109  const handshakeHash = h.slice();110111  let sendNonce = 0n;112  let recvNonce = 0n;113114  function nonceBytes(n: bigint): Uint8Array {115    const buf = new Uint8Array(12);116    new DataView(buf.buffer).setBigUint64(4, n, true);117    return buf;118  }119120  const sendCipher = new ChaCha20Poly1305(sendKey);121  const recvCipher = new ChaCha20Poly1305(recvKey);122123  return {124    encrypt(plaintext) {125      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));126    },127    decrypt(ciphertext) {128      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));129      if (!result) throw new Error("Noise NK: decryption failed");130      return result;131    },132    handshakeHash,133  };134}135136/**137 * Compute the post-handshake auth proof.138 *139 * proof = HMAC-SHA256(140 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared141 *   data  = "peach-sync-v1-initiator-auth"142 *        || initiator_public143 *        || responder_public144 *        || nonce_32145 * )146 *147 * Both sides pass the same DH peer key (their PEER's public key) and the same148 * initiator_public/responder_public ordering so the MAC matches. This helper149 * targets the initiator-side: `local_priv` is the initiator's private key,150 * `remote_pub` is the responder's public key.151 */152const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");153154export function computeAuthProof(155  initiatorPrivate: Uint8Array,156  initiatorPublic: Uint8Array,157  responderPublic: Uint8Array,158  nonce: Uint8Array,159  handshakeHash: Uint8Array,160): Uint8Array {161  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");162  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");163164  const dh = sharedKey(initiatorPrivate, responderPublic);165  const mac = new HMAC(SHA256, dh);166  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed167  // onto a different handshake. handshakeHash is prepended; both sides hold the168  // identical value and feed it in the same position.169  mac.update(handshakeHash);170  mac.update(AUTH_DOMAIN);171  mac.update(initiatorPublic);172  mac.update(responderPublic);173  mac.update(nonce);174  return mac.digest();175}176177interface AuthChallengeFrame {178  type: "CHALLENGE";179  nonce_hex: string;180  responder_public_key_hex: string;181}182183interface AuthProofFrame {184  type: "PROOF";185  initiator_public_key_hex: string;186  proof_hex: string;187}188189function bytesToHex(bytes: Uint8Array): string {190  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");191}192193function hexToBytes(hex: string): Uint8Array {194  if (hex.length % 2 !== 0) throw new Error("invalid hex");195  const bytes = new Uint8Array(hex.length / 2);196  for (let i = 0; i < hex.length; i += 2) {197    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);198  }199  return bytes;200}201202/**203 * Drive the initiator side of the post-handshake challenge/proof round. The204 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply205 * with a `Proof` frame. Both frames are encrypted via the post-handshake206 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).207 */208/**209 * Run the responder side of the Noise NK handshake. Symmetric with210 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two211 * handshake messages and gets back a session for transport-phase frames.212 *213 * Wire-identical with the Rust `snow` responder so an Android-as-host214 * pairing session can be driven by the desktop initiator and vice versa.215 */216export async function noiseNKResponder(217  ourStaticPrivateKey: Uint8Array,218  ourStaticPublicKey: Uint8Array,219  send: (msg: Uint8Array) => Promise<void>,220  receive: () => Promise<Uint8Array>,221): Promise<NoiseNKSession> {222  let h: Uint8Array = initHash();223  let ck: Uint8Array = h.slice();224225  // NK pre-message: mix our (responder) static public key. The initiator226  // pre-mixed the same key on its side; both transcripts must match.227  h = mixHash(h, ourStaticPublicKey);228229  // --- Message 1: e, es ---230  const msg1 = await receive();231  if (msg1.length < 48) throw new Error("Noise NK: msg1 too short");232  const remoteEphemeralPublic = msg1.slice(0, 32);233  const encrypted1 = msg1.slice(32);234235  h = mixHash(h, remoteEphemeralPublic);236  const es = sharedKey(ourStaticPrivateKey, remoteEphemeralPublic);237  const [ck1, k1] = hkdf2(ck, es);238  ck = ck1;239240  const cipher1 = new ChaCha20Poly1305(k1);241  const nonce1 = new Uint8Array(12);242  const decrypted1 = cipher1.open(nonce1, encrypted1, h);243  if (!decrypted1) {244    throw new Error("Noise NK: msg1 auth failed — wrong static key or MITM");245  }246  h = mixHash(h, encrypted1);247248  // --- Message 2: e, ee ---249  const ephemeral = generateKeyPair();250  h = mixHash(h, ephemeral.publicKey);251  const ee = sharedKey(ephemeral.secretKey, remoteEphemeralPublic);252  const [ck2, k2] = hkdf2(ck, ee);253  ck = ck2;254255  const cipher2 = new ChaCha20Poly1305(k2);256  const nonce2 = new Uint8Array(12);257  const encrypted2 = cipher2.seal(nonce2, new Uint8Array(0), h);258  h = mixHash(h, encrypted2);259260  const msg2 = new Uint8Array(32 + encrypted2.length);261  msg2.set(ephemeral.publicKey, 0);262  msg2.set(encrypted2, 32);263  await send(msg2);264265  // --- Transport phase ---266  // hkdf2 returns (k1, k2); the initiator uses k1 to send and k2 to receive,267  // so the responder swaps: k1 → recv, k2 → send.268  const [initiatorSendKey, responderSendKey] = hkdf2(ck, new Uint8Array(0));269  const handshakeHash = h.slice();270271  let sendNonce = 0n;272  let recvNonce = 0n;273274  function nonceBytes(n: bigint): Uint8Array {275    const buf = new Uint8Array(12);276    new DataView(buf.buffer).setBigUint64(4, n, true);277    return buf;278  }279280  const sendCipher = new ChaCha20Poly1305(responderSendKey);281  const recvCipher = new ChaCha20Poly1305(initiatorSendKey);282283  return {284    encrypt(plaintext) {285      return sendCipher.seal(nonceBytes(sendNonce++), plaintext, new Uint8Array(0));286    },287    decrypt(ciphertext) {288      const result = recvCipher.open(nonceBytes(recvNonce++), ciphertext, new Uint8Array(0));289      if (!result) throw new Error("Noise NK: decryption failed");290      return result;291    },292    handshakeHash,293  };294}295296/**297 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`298 * goes through this so a same-LAN attacker can't time the HMAC compare.299 */300function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {301  if (a.length !== b.length) return false;302  let diff = 0;303  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;304  return diff === 0;305}306307/**308 * Drive the responder side of the post-handshake challenge/proof round. We309 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and310 * verify. Returns the initiator's authenticated public key on success.311 *312 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same313 * domain-separated transcript both sides feed into HMAC-SHA256.314 */315export async function performResponderAuth(316  session: NoiseNKSession,317  ourPrivateKey: Uint8Array,318  ourPublicKey: Uint8Array,319  sendEncryptedJson: (data: Uint8Array) => Promise<void>,320  receiveEncryptedJson: () => Promise<Uint8Array>,321): Promise<{ initiatorPublicKey: Uint8Array }> {322  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.323324  const nonce = new Uint8Array(32);325  crypto.getRandomValues(nonce);326327  const challenge: AuthChallengeFrame = {328    type: "CHALLENGE",329    nonce_hex: bytesToHex(nonce),330    responder_public_key_hex: bytesToHex(ourPublicKey),331  };332  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));333334  const proofBytes = await receiveEncryptedJson();335  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;336  if (proofFrame.type !== "PROOF") {337    throw new Error("expected PROOF frame");338  }339340  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);341  const claimedProof = hexToBytes(proofFrame.proof_hex);342  if (initiatorPublicKey.length !== 32) {343    throw new Error("initiator public key must be 32 bytes");344  }345  if (claimedProof.length !== 32) {346    throw new Error("proof must be 32 bytes");347  }348349  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)350  const dh = sharedKey(ourPrivateKey, initiatorPublicKey);351  const mac = new HMAC(SHA256, dh);352  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the353  // existing domain/keys/nonce transcript. Same value, same position on both ends.354  mac.update(session.handshakeHash);355  mac.update(AUTH_DOMAIN);356  mac.update(initiatorPublicKey);357  mac.update(ourPublicKey);358  mac.update(nonce);359  const expected = mac.digest();360361  if (!constantTimeEq(expected, claimedProof)) {362    throw new Error("auth proof did not verify");363  }364  return { initiatorPublicKey };365}366367export async function performInitiatorAuth(368  session: NoiseNKSession,369  ourPrivateKey: Uint8Array,370  ourPublicKey: Uint8Array,371  responderPublicKey: Uint8Array,372  sendEncryptedJson: (data: Uint8Array) => Promise<void>,373  receiveEncryptedJson: () => Promise<Uint8Array>,374): Promise<void> {375  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding376  // it to this exact session (was previously bound via the DH key only).377378  const challengeBytes = await receiveEncryptedJson();379  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;380  if (challenge.type !== "CHALLENGE") {381    throw new Error("expected CHALLENGE frame");382  }383  const nonce = hexToBytes(challenge.nonce_hex);384  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);385  // Defence against an off-key challenge: the responder's claimed key MUST be386  // the one we Noise-NK-handshook against, otherwise an active MITM that387  // managed to substitute a different key is rerouting the auth round.388  if (389    responderKeyFromChallenge.length !== responderPublicKey.length ||390    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])391  ) {392    throw new Error("responder key in challenge does not match Noise handshake");393  }394395  const proof = computeAuthProof(396    ourPrivateKey,397    ourPublicKey,398    responderPublicKey,399    nonce,400    session.handshakeHash,401  );402  const proofFrame: AuthProofFrame = {403    type: "PROOF",404    initiator_public_key_hex: bytesToHex(ourPublicKey),405    proof_hex: bytesToHex(proof),406  };407  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));408}409410export { bytesToHex, hexToBytes };