noise-js.ts

12.3 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";20import { constantTimeEqualBytes } from "./constant-time";2122const PROTOCOL_NAME = "Noise_NK_25519_ChaChaPoly_BLAKE2s";2324// libhydrogen's contributory-behaviour blocklist: the nine small-order X2551925// u-coordinates. Mirrors X25519_SMALL_ORDER_PUBKEYS in crypto-core (PB-26-014)26// so the TS-side DH boundaries fail before the DH, not just the WASM core.27const X25519_SMALL_ORDER_PUBKEYS = new Set([28  "00".repeat(32),29  "01" + "00".repeat(31),30  "e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",31  "5f9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",32  "ec" + "ff".repeat(30) + "7f",33  "ed" + "ff".repeat(30) + "7f",34  "ee" + "ff".repeat(30) + "7f",35  "cdeb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b880",36  "4c9c95bca3508c24b1d0b1559c83ef5b04445cc4581c8e86d8224eddd09f11d7",37]);3839export function isSmallOrderPubkey(publicKey: Uint8Array): boolean {40  return publicKey.length === 32 && X25519_SMALL_ORDER_PUBKEYS.has(bytesToHex(publicKey));41}4243function initProtocolHash(protocolName: string): Uint8Array {44  const nameBytes = new TextEncoder().encode(protocolName);45  if (nameBytes.length <= 32) {46    const h = new Uint8Array(32);47    h.set(nameBytes);48    return h;49  }50  // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash51  // MUST use it too (Noise spec §5.2), not SHA-256.52  return new BLAKE2s(32).update(nameBytes).digest();53}5455export function initHash(): Uint8Array {56  return initProtocolHash(PROTOCOL_NAME);57}5859export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {60  // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.61  // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState()62  // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run63  // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per64  // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`65  // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a66  // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).67  const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)68  const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();69  const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();70  return [k1, k2];71}7273export interface NoiseNKSession {74  encrypt(plaintext: Uint8Array): Uint8Array;75  decrypt(ciphertext: Uint8Array): Uint8Array;76  readonly handshakeHash: Uint8Array;77  rekeyOutgoing(): void;78  rekeyIncoming(): void;79  close(): void;80}8182/**83 * Standard Noise KK initiator for reconnects.84 *85 * Pre-messages: -> s, <- s86 * Message 1:    -> e, es, ss87 * Message 2:    <- e, ee, se88 *89 * Both static keys are local inputs. There is no negotiation and no fallback90 * to NK: a mismatch hard-fails this handshake.91 */92export async function noiseKKInitiator(93  ourStaticPrivateKey: Uint8Array,94  ourStaticPublicKey: Uint8Array,95  responderStaticPublicKey: Uint8Array,96  send: (msg: Uint8Array) => Promise<void>,97  receive: () => Promise<Uint8Array>,98): Promise<NoiseNKSession> {99  if (isSmallOrderPubkey(responderStaticPublicKey)) {100    throw new Error("noise: small-order X25519 responder static key");101  }102  return rustNoiseKkInitiator(103    ourStaticPrivateKey,104    ourStaticPublicKey,105    responderStaticPublicKey,106    send,107    receive,108  );109}110111/**112 * Standard Noise KK responder. The expected initiator key must come from the113 * local paired-device registry before this function is called.114 */115export async function noiseKKResponder(116  ourStaticPrivateKey: Uint8Array,117  ourStaticPublicKey: Uint8Array,118  expectedInitiatorStaticPublicKey: Uint8Array,119  send: (msg: Uint8Array) => Promise<void>,120  receive: () => Promise<Uint8Array>,121): Promise<NoiseNKSession> {122  if (isSmallOrderPubkey(expectedInitiatorStaticPublicKey)) {123    throw new Error("noise: small-order X25519 initiator static key");124  }125  return rustNoiseKkResponder(126    ourStaticPrivateKey,127    ourStaticPublicKey,128    expectedInitiatorStaticPublicKey,129    send,130    receive,131  );132}133134/**135 * Run the initiator side of the Noise NK handshake. The caller provides136 * `send` / `receive` for the two transport-side handshake messages and gets137 * back a session object that encrypts/decrypts subsequent transport frames.138 */139export async function noiseNKInitiator(140  responderStaticPublicKey: Uint8Array,141  send: (msg: Uint8Array) => Promise<void>,142  receive: () => Promise<Uint8Array>,143): Promise<NoiseNKSession> {144  if (isSmallOrderPubkey(responderStaticPublicKey)) {145    throw new Error("noise: small-order X25519 responder static key");146  }147  return rustNoiseNkInitiator(148    responderStaticPublicKey,149    send,150    receive,151  );152}153154/**155 * Compute the post-handshake auth proof.156 *157 * proof = HMAC-SHA256(158 *   key   = X25519(local_priv, remote_pub),  // 32-byte DH shared159 *   data  = "peach-sync-v1-initiator-auth"160 *        || initiator_public161 *        || responder_public162 *        || nonce_32163 * )164 *165 * Both sides pass the same DH peer key (their PEER's public key) and the same166 * initiator_public/responder_public ordering so the MAC matches. This helper167 * targets the initiator-side: `local_priv` is the initiator's private key,168 * `remote_pub` is the responder's public key.169 */170const AUTH_DOMAIN = new TextEncoder().encode("peach-sync-v1-initiator-auth");171172export function computeAuthProof(173  initiatorPrivate: Uint8Array,174  initiatorPublic: Uint8Array,175  responderPublic: Uint8Array,176  nonce: Uint8Array,177  handshakeHash: Uint8Array,178): Uint8Array {179  if (nonce.length !== 32) throw new Error("auth nonce must be 32 bytes");180  if (handshakeHash.length !== 32) throw new Error("handshake hash must be 32 bytes");181  if (isSmallOrderPubkey(responderPublic)) {182    throw new Error("small-order X25519 responder public key");183  }184185  const dh = sharedKey(initiatorPrivate, responderPublic, true);186  const mac = new HMAC(SHA256, dh);187  // PCH-01-003b: bind the proof to THIS Noise session so it cannot be replayed188  // onto a different handshake. handshakeHash is prepended; both sides hold the189  // identical value and feed it in the same position.190  mac.update(handshakeHash);191  mac.update(AUTH_DOMAIN);192  mac.update(initiatorPublic);193  mac.update(responderPublic);194  mac.update(nonce);195  return mac.digest();196}197198interface AuthChallengeFrame {199  type: "CHALLENGE";200  nonce_hex: string;201  responder_public_key_hex: string;202}203204interface AuthProofFrame {205  type: "PROOF";206  initiator_public_key_hex: string;207  proof_hex: string;208}209210function bytesToHex(bytes: Uint8Array): string {211  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");212}213214function hexToBytes(hex: string): Uint8Array {215  if (hex.length % 2 !== 0) throw new Error("invalid hex");216  const bytes = new Uint8Array(hex.length / 2);217  for (let i = 0; i < hex.length; i += 2) {218    bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16);219  }220  return bytes;221}222223/**224 * Drive the initiator side of the post-handshake challenge/proof round. The225 * responder sends a `Challenge` JSON frame; we compute the HMAC and reply226 * with a `Proof` frame. Both frames are encrypted via the post-handshake227 * Noise transport (caller owns the `sendEncrypted` / `receiveEncrypted` pair).228 */229/**230 * Run the responder side of the Noise NK handshake. Symmetric with231 * `noiseNKInitiator` — the caller provides `send` / `receive` for the two232 * handshake messages and gets back a session for transport-phase frames.233 *234 * The Rust core runs the same state machine as the browser initiator and the235 * Android JNI adapter.236 */237export async function noiseNKResponder(238  ourStaticPrivateKey: Uint8Array,239  ourStaticPublicKey: Uint8Array,240  send: (msg: Uint8Array) => Promise<void>,241  receive: () => Promise<Uint8Array>,242): Promise<NoiseNKSession> {243  return rustNoiseNkResponder(244    ourStaticPrivateKey,245    ourStaticPublicKey,246    send,247    receive,248  );249}250251/**252 * Drive the responder side of the post-handshake challenge/proof round. We253 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and254 * verify. Returns the initiator's authenticated public key on success.255 *256 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same257 * domain-separated transcript both sides feed into HMAC-SHA256.258 */259export async function performResponderAuth(260  session: NoiseNKSession,261  ourPrivateKey: Uint8Array,262  ourPublicKey: Uint8Array,263  sendEncryptedJson: (data: Uint8Array) => Promise<void>,264  receiveEncryptedJson: () => Promise<Uint8Array>,265): Promise<{ initiatorPublicKey: Uint8Array }> {266  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.267268  const nonce = new Uint8Array(32);269  crypto.getRandomValues(nonce);270271  const challenge: AuthChallengeFrame = {272    type: "CHALLENGE",273    nonce_hex: bytesToHex(nonce),274    responder_public_key_hex: bytesToHex(ourPublicKey),275  };276  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));277278  const proofBytes = await receiveEncryptedJson();279  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;280  if (proofFrame.type !== "PROOF") {281    throw new Error("expected PROOF frame");282  }283284  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);285  const claimedProof = hexToBytes(proofFrame.proof_hex);286  if (initiatorPublicKey.length !== 32) {287    throw new Error("initiator public key must be 32 bytes");288  }289  if (claimedProof.length !== 32) {290    throw new Error("proof must be 32 bytes");291  }292  if (isSmallOrderPubkey(initiatorPublicKey)) {293    throw new Error("small-order X25519 initiator public key");294  }295296  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)297  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);298  const mac = new HMAC(SHA256, dh);299  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the300  // existing domain/keys/nonce transcript. Same value, same position on both ends.301  mac.update(session.handshakeHash);302  mac.update(AUTH_DOMAIN);303  mac.update(initiatorPublicKey);304  mac.update(ourPublicKey);305  mac.update(nonce);306  const expected = mac.digest();307308  if (!constantTimeEqualBytes(expected, claimedProof)) {309    throw new Error("auth proof did not verify");310  }311  return { initiatorPublicKey };312}313314export async function performInitiatorAuth(315  session: NoiseNKSession,316  ourPrivateKey: Uint8Array,317  ourPublicKey: Uint8Array,318  responderPublicKey: Uint8Array,319  sendEncryptedJson: (data: Uint8Array) => Promise<void>,320  receiveEncryptedJson: () => Promise<Uint8Array>,321): Promise<void> {322  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding323  // it to this exact session (was previously bound via the DH key only).324325  const challengeBytes = await receiveEncryptedJson();326  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;327  if (challenge.type !== "CHALLENGE") {328    throw new Error("expected CHALLENGE frame");329  }330  const nonce = hexToBytes(challenge.nonce_hex);331  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);332  // Defence against an off-key challenge: the responder's claimed key MUST be333  // the one we Noise-NK-handshook against, otherwise an active MITM that334  // managed to substitute a different key is rerouting the auth round.335  if (!constantTimeEqualBytes(responderKeyFromChallenge, responderPublicKey)) {336    throw new Error("responder key in challenge does not match Noise handshake");337  }338339  const proof = computeAuthProof(340    ourPrivateKey,341    ourPublicKey,342    responderPublicKey,343    nonce,344    session.handshakeHash,345  );346  const proofFrame: AuthProofFrame = {347    type: "PROOF",348    initiator_public_key_hex: bytesToHex(ourPublicKey),349    proof_hex: bytesToHex(proof),350  };351  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));352}353354export { bytesToHex, hexToBytes };