noise-js.ts

12.8 KB · raw

1/**2 * Noise NK initiator for browser-to-browser sync.3 * Pattern: Noise_NK_25519_ChaChaPoly_BLAKE2s4 *5 * The protocol state machine lives in the canonical Rust `snow` core and is6 * compiled to WASM here and JNI on Android. This file keeps the stable7 * TypeScript orchestration API plus the post-handshake proof helpers.8 */910import { sharedKey } from "@stablelib/x25519";11import { BLAKE2s } from "@stablelib/blake2s";12import { SHA256 } from "@stablelib/sha256";13import { HMAC } from "@stablelib/hmac";14import {15  rustNoiseKkInitiator,16  rustNoiseKkResponder,17  rustNoiseNkInitiator,18  rustNoiseNkResponder,19} from "./noise-core";2021const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2223// libhydrogen's contributory-behaviour blocklist: the nine small-order X2551924// u-coordinates. Mirrors X25519_SMALL_ORDER_PUBKEYS in crypto-core (PB-26-014)25// so the TS-side DH boundaries fail before the DH, not just the WASM core.26const X25519_SMALL_ORDER_PUBKEYS = new Set([27  "00".repeat(32),28  "01" + "00".repeat(31),29  "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",30  "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",31  "ec" + "ff".repeat(30) + "7f",32  "ed" + "ff".repeat(30) + "7f",33  "ee" + "ff".repeat(30) + "7f",34  "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",35  "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",36]);3738export function isSmallOrderPubkey(publicKey: Uint8Array): boolean {39  return publicKey.length === 32 && X25519_SMALL_ORDER_PUBKEYS.has(bytesToHex(publicKey));40}4142function initProtocolHash(protocolName: string): Uint8Array {43  const nameBytes = new TextEncoder().encode(protocolName);44  if (nameBytes.length <= 32) {45    const h = new Uint8Array(32);46    h.set(nameBytes);47    return h;48  }49  // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash50  // MUST use it too (Noise spec §5.2), not SHA-256.51  return new BLAKE2s(32).update(nameBytes).digest();52}5354export function initHash(): Uint8Array {55  return initProtocolHash(PROTOCOL_NAME);56}5758export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {59  // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.60  // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState()61  // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run62  // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per63  // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`64  // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a65  // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).66  const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)67  const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();68  const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();69  return [k1, k2];70}7172export interface NoiseNKSession {73  encrypt(plaintext: Uint8Array): Uint8Array;74  decrypt(ciphertext: Uint8Array): Uint8Array;75  readonly handshakeHash: Uint8Array;76  rekeyOutgoing(): void;77  rekeyIncoming(): void;78  close(): void;79}8081interface NoiseKeyPair {82  publicKey: Uint8Array;83  secretKey: Uint8Array;84}8586/**87 * Standard Noise KK initiator for reconnects.88 *89 * Pre-messages: -> s, <- s90 * Message 1:    -> e, es, ss91 * Message 2:    <- e, ee, se92 *93 * Both static keys are local inputs. There is no negotiation and no fallback94 * to NK: a mismatch hard-fails this handshake.95 */96export async function noiseKKInitiator(97  ourStaticPrivateKey: Uint8Array,98  ourStaticPublicKey: Uint8Array,99  responderStaticPublicKey: Uint8Array,100  send: (msg: Uint8Array) => Promise<void>,101  receive: () => Promise<Uint8Array>,102  ephemeral?: NoiseKeyPair,103): Promise<NoiseNKSession> {104  if (isSmallOrderPubkey(responderStaticPublicKey)) {105    throw new Error("noise: small-order X25519 responder static key");106  }107  return rustNoiseKkInitiator(108    ourStaticPrivateKey,109    ourStaticPublicKey,110    responderStaticPublicKey,111    send,112    receive,113    ephemeral,114  );115}116117/**118 * Standard Noise KK responder. The expected initiator key must come from the119 * local paired-device registry before this function is called.120 */121export async function noiseKKResponder(122  ourStaticPrivateKey: Uint8Array,123  ourStaticPublicKey: Uint8Array,124  expectedInitiatorStaticPublicKey: Uint8Array,125  send: (msg: Uint8Array) => Promise<void>,126  receive: () => Promise<Uint8Array>,127  ephemeral?: NoiseKeyPair,128): Promise<NoiseNKSession> {129  return rustNoiseKkResponder(130    ourStaticPrivateKey,131    ourStaticPublicKey,132    expectedInitiatorStaticPublicKey,133    send,134    receive,135    ephemeral,136  );137}138139/**140 * Run the initiator side of the Noise NK handshake. The caller provides141 * `send` / `receive` for the two transport-side handshake messages and gets142 * back a session object that encrypts/decrypts subsequent transport frames.143 */144export async function noiseNKInitiator(145  responderStaticPublicKey: Uint8Array,146  send: (msg: Uint8Array) => Promise<void>,147  receive: () => Promise<Uint8Array>,148  ephemeral?: NoiseKeyPair,149): Promise<NoiseNKSession> {150  if (isSmallOrderPubkey(responderStaticPublicKey)) {151    throw new Error("noise: small-order X25519 responder static key");152  }153  return rustNoiseNkInitiator(154    responderStaticPublicKey,155    send,156    receive,157    ephemeral,158  );159}160161/**162 * Compute the post-handshake auth proof.163 *164 * proof = HMAC-SHA256(165 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared166 *   data  = "peach-sync-v1-initiator-auth"167 *        || initiator_public168 *        || responder_public169 *        || nonce_32170 * )171 *172 * Both sides pass the same DH peer key (their PEER's public key) and the same173 * initiator_public/responder_public ordering so the MAC matches. This helper174 * targets the initiator-side: `local_priv` is the initiator's private key,175 * `remote_pub` is the responder's public key.176 */177const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");178179export function computeAuthProof(180  initiatorPrivate: Uint8Array,181  initiatorPublic: Uint8Array,182  responderPublic: Uint8Array,183  nonce: Uint8Array,184  handshakeHash: Uint8Array,185): Uint8Array {186  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");187  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");188  if (isSmallOrderPubkey(responderPublic)) {189    throw new Error("small-order X25519 responder public key");190  }191192  const dh = sharedKey(initiatorPrivate, responderPublic, true);193  const mac = new HMAC(SHA256, dh);194  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed195  // onto a different handshake. handshakeHash is prepended; both sides hold the196  // identical value and feed it in the same position.197  mac.update(handshakeHash);198  mac.update(AUTH_DOMAIN);199  mac.update(initiatorPublic);200  mac.update(responderPublic);201  mac.update(nonce);202  return mac.digest();203}204205interface AuthChallengeFrame {206  type: "CHALLENGE";207  nonce_hex: string;208  responder_public_key_hex: string;209}210211interface AuthProofFrame {212  type: "PROOF";213  initiator_public_key_hex: string;214  proof_hex: string;215}216217function bytesToHex(bytes: Uint8Array): string {218  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");219}220221function hexToBytes(hex: string): Uint8Array {222  if (hex.length % 2 !== 0) throw new Error("invalid hex");223  const bytes = new Uint8Array(hex.length / 2);224  for (let i = 0; i < hex.length; i += 2) {225    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);226  }227  return bytes;228}229230/**231 * Drive the initiator side of the post-handshake challenge/proof round. The232 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply233 * with a `Proof` frame. Both frames are encrypted via the post-handshake234 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).235 */236/**237 * Run the responder side of the Noise NK handshake. Symmetric with238 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two239 * handshake messages and gets back a session for transport-phase frames.240 *241 * The Rust core runs the same state machine as the browser initiator and the242 * Android JNI adapter.243 */244export async function noiseNKResponder(245  ourStaticPrivateKey: Uint8Array,246  ourStaticPublicKey: Uint8Array,247  send: (msg: Uint8Array) => Promise<void>,248  receive: () => Promise<Uint8Array>,249  ephemeral?: NoiseKeyPair,250): Promise<NoiseNKSession> {251  return rustNoiseNkResponder(252    ourStaticPrivateKey,253    ourStaticPublicKey,254    send,255    receive,256    ephemeral,257  );258}259260/**261 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`262 * goes through this so a same-LAN attacker can't time the HMAC compare.263 */264function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {265  if (a.length !== b.length) return false;266  let diff = 0;267  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;268  return diff === 0;269}270271/**272 * Drive the responder side of the post-handshake challenge/proof round. We273 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and274 * verify. Returns the initiator's authenticated public key on success.275 *276 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same277 * domain-separated transcript both sides feed into HMAC-SHA256.278 */279export async function performResponderAuth(280  session: NoiseNKSession,281  ourPrivateKey: Uint8Array,282  ourPublicKey: Uint8Array,283  sendEncryptedJson: (data: Uint8Array) => Promise<void>,284  receiveEncryptedJson: () => Promise<Uint8Array>,285): Promise<{ initiatorPublicKey: Uint8Array }> {286  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.287288  const nonce = new Uint8Array(32);289  crypto.getRandomValues(nonce);290291  const challenge: AuthChallengeFrame = {292    type: "CHALLENGE",293    nonce_hex: bytesToHex(nonce),294    responder_public_key_hex: bytesToHex(ourPublicKey),295  };296  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));297298  const proofBytes = await receiveEncryptedJson();299  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;300  if (proofFrame.type !== "PROOF") {301    throw new Error("expected PROOF frame");302  }303304  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);305  const claimedProof = hexToBytes(proofFrame.proof_hex);306  if (initiatorPublicKey.length !== 32) {307    throw new Error("initiator public key must be 32 bytes");308  }309  if (claimedProof.length !== 32) {310    throw new Error("proof must be 32 bytes");311  }312  if (isSmallOrderPubkey(initiatorPublicKey)) {313    throw new Error("small-order X25519 initiator public key");314  }315316  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)317  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);318  const mac = new HMAC(SHA256, dh);319  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the320  // existing domain/keys/nonce transcript. Same value, same position on both ends.321  mac.update(session.handshakeHash);322  mac.update(AUTH_DOMAIN);323  mac.update(initiatorPublicKey);324  mac.update(ourPublicKey);325  mac.update(nonce);326  const expected = mac.digest();327328  if (!constantTimeEq(expected, claimedProof)) {329    throw new Error("auth proof did not verify");330  }331  return { initiatorPublicKey };332}333334export async function performInitiatorAuth(335  session: NoiseNKSession,336  ourPrivateKey: Uint8Array,337  ourPublicKey: Uint8Array,338  responderPublicKey: Uint8Array,339  sendEncryptedJson: (data: Uint8Array) => Promise<void>,340  receiveEncryptedJson: () => Promise<Uint8Array>,341): Promise<void> {342  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding343  // it to this exact session (was previously bound via the DH key only).344345  const challengeBytes = await receiveEncryptedJson();346  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;347  if (challenge.type !== "CHALLENGE") {348    throw new Error("expected CHALLENGE frame");349  }350  const nonce = hexToBytes(challenge.nonce_hex);351  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);352  // Defence against an off-key challenge: the responder's claimed key MUST be353  // the one we Noise-NK-handshook against, otherwise an active MITM that354  // managed to substitute a different key is rerouting the auth round.355  if (356    responderKeyFromChallenge.length !== responderPublicKey.length ||357    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])358  ) {359    throw new Error("responder key in challenge does not match Noise handshake");360  }361362  const proof = computeAuthProof(363    ourPrivateKey,364    ourPublicKey,365    responderPublicKey,366    nonce,367    session.handshakeHash,368  );369  const proofFrame: AuthProofFrame = {370    type: "PROOF",371    initiator_public_key_hex: bytesToHex(ourPublicKey),372    proof_hex: bytesToHex(proof),373  };374  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));375}376377export { bytesToHex, hexToBytes };