noise-js.ts

11.5 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";2223function initProtocolHash(protocolName: string): Uint8Array {24  const nameBytes = new TextEncoder().encode(protocolName);25  if (nameBytes.length <= 32) {26    const h = new Uint8Array(32);27    h.set(nameBytes);28    return h;29  }30  // PCH-01-002: the named pattern hash is BLAKE2s; the protocol-name init hash31  // MUST use it too (Noise spec §5.2), not SHA-256.32  return new BLAKE2s(32).update(nameBytes).digest();33}3435export function initHash(): Uint8Array {36  return initProtocolHash(PROTOCOL_NAME);37}3839export function hkdf2(ck: Uint8Array, ikm: Uint8Array): [Uint8Array, Uint8Array] {40  // PCH-01-002: HKDF must run over the named hash (BLAKE2s), not SHA-256.41  // @stablelib's HKDF reuses one HMAC across blocks and BLAKE2s's restoreState()42  // never clears its `finished` flag, so `new HKDF(BLAKE2s, …)` throws. Run43  // standard HKDF-Expand (RFC 5869, empty info) with a FRESH HMAC-BLAKE2s per44  // block — byte-identical to BouncyCastle `HKDFBytesGenerator(Blake2sDigest)`45  // on peach-android. NOTE: the HMAC-SHA256 post-handshake auth proof is a46  // SEPARATE construction, intentionally unchanged (so the SHA256 import stays).47  const prk = new HMAC(BLAKE2s, ck).update(ikm).digest(); // Extract (salt=ck)48  const k1 = new HMAC(BLAKE2s, prk).update(Uint8Array.of(1)).digest();49  const k2 = new HMAC(BLAKE2s, prk).update(k1).update(Uint8Array.of(2)).digest();50  return [k1, k2];51}5253export interface NoiseNKSession {54  encrypt(plaintext: Uint8Array): Uint8Array;55  decrypt(ciphertext: Uint8Array): Uint8Array;56  readonly handshakeHash: Uint8Array;57  rekeyOutgoing(): void;58  rekeyIncoming(): void;59  close(): void;60}6162interface NoiseKeyPair {63  publicKey: Uint8Array;64  secretKey: Uint8Array;65}6667/**68 * Standard Noise KK initiator for reconnects.69 *70 * Pre-messages: -> s, <- s71 * Message 1:    -> e, es, ss72 * Message 2:    <- e, ee, se73 *74 * Both static keys are local inputs. There is no negotiation and no fallback75 * to NK: a mismatch hard-fails this handshake.76 */77export async function noiseKKInitiator(78  ourStaticPrivateKey: Uint8Array,79  ourStaticPublicKey: Uint8Array,80  responderStaticPublicKey: Uint8Array,81  send: (msg: Uint8Array) => Promise<void>,82  receive: () => Promise<Uint8Array>,83  ephemeral?: NoiseKeyPair,84): Promise<NoiseNKSession> {85  return rustNoiseKkInitiator(86    ourStaticPrivateKey,87    ourStaticPublicKey,88    responderStaticPublicKey,89    send,90    receive,91    ephemeral,92  );93}9495/**96 * Standard Noise KK responder. The expected initiator key must come from the97 * local paired-device registry before this function is called.98 */99export async function noiseKKResponder(100  ourStaticPrivateKey: Uint8Array,101  ourStaticPublicKey: Uint8Array,102  expectedInitiatorStaticPublicKey: Uint8Array,103  send: (msg: Uint8Array) => Promise<void>,104  receive: () => Promise<Uint8Array>,105  ephemeral?: NoiseKeyPair,106): Promise<NoiseNKSession> {107  return rustNoiseKkResponder(108    ourStaticPrivateKey,109    ourStaticPublicKey,110    expectedInitiatorStaticPublicKey,111    send,112    receive,113    ephemeral,114  );115}116117/**118 * Run the initiator side of the Noise NK handshake. The caller provides119 * `send` / `receive` for the two transport-side handshake messages and gets120 * back a session object that encrypts/decrypts subsequent transport frames.121 */122export async function noiseNKInitiator(123  responderStaticPublicKey: Uint8Array,124  send: (msg: Uint8Array) => Promise<void>,125  receive: () => Promise<Uint8Array>,126  ephemeral?: NoiseKeyPair,127): Promise<NoiseNKSession> {128  return rustNoiseNkInitiator(129    responderStaticPublicKey,130    send,131    receive,132    ephemeral,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, true);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 * The Rust core runs the same state machine as the browser initiator and the214 * Android JNI adapter.215 */216export async function noiseNKResponder(217  ourStaticPrivateKey: Uint8Array,218  ourStaticPublicKey: Uint8Array,219  send: (msg: Uint8Array) => Promise<void>,220  receive: () => Promise<Uint8Array>,221  ephemeral?: NoiseKeyPair,222): Promise<NoiseNKSession> {223  return rustNoiseNkResponder(224    ourStaticPrivateKey,225    ourStaticPublicKey,226    send,227    receive,228    ephemeral,229  );230}231232/**233 * Constant-time 32-byte equality. The MAC comparison in `verifyAuthProof`234 * goes through this so a same-LAN attacker can't time the HMAC compare.235 */236function constantTimeEq(a: Uint8Array, b: Uint8Array): boolean {237  if (a.length !== b.length) return false;238  let diff = 0;239  for (let i = 0; i < a.length; i++) diff |= a[i]! ^ b[i]!;240  return diff === 0;241}242243/**244 * Drive the responder side of the post-handshake challenge/proof round. We245 * generate a random 32-byte nonce, send `Challenge`, receive `Proof`, and246 * verify. Returns the initiator's authenticated public key on success.247 *248 * The MAC key is X25519(our_priv, initiator_pub) and the data is the same249 * domain-separated transcript both sides feed into HMAC-SHA256.250 */251export async function performResponderAuth(252  session: NoiseNKSession,253  ourPrivateKey: Uint8Array,254  ourPublicKey: Uint8Array,255  sendEncryptedJson: (data: Uint8Array) => Promise<void>,256  receiveEncryptedJson: () => Promise<Uint8Array>,257): Promise<{ initiatorPublicKey: Uint8Array }> {258  // PCH-01-003b: bind the verifier's recomputed MAC to this Noise session.259260  const nonce = new Uint8Array(32);261  crypto.getRandomValues(nonce);262263  const challenge: AuthChallengeFrame = {264    type: "CHALLENGE",265    nonce_hex: bytesToHex(nonce),266    responder_public_key_hex: bytesToHex(ourPublicKey),267  };268  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(challenge)));269270  const proofBytes = await receiveEncryptedJson();271  const proofFrame = JSON.parse(new TextDecoder().decode(proofBytes)) as AuthProofFrame;272  if (proofFrame.type !== "PROOF") {273    throw new Error("expected PROOF frame");274  }275276  const initiatorPublicKey = hexToBytes(proofFrame.initiator_public_key_hex);277  const claimedProof = hexToBytes(proofFrame.proof_hex);278  if (initiatorPublicKey.length !== 32) {279    throw new Error("initiator public key must be 32 bytes");280  }281  if (claimedProof.length !== 32) {282    throw new Error("proof must be 32 bytes");283  }284285  // mac_for(our_priv, initiator_pub_for_dh, initiator_pub, our_pub, nonce)286  const dh = sharedKey(ourPrivateKey, initiatorPublicKey, true);287  const mac = new HMAC(SHA256, dh);288  // PCH-01-003b: must mirror computeAuthProof — handshakeHash first, then the289  // existing domain/keys/nonce transcript. Same value, same position on both ends.290  mac.update(session.handshakeHash);291  mac.update(AUTH_DOMAIN);292  mac.update(initiatorPublicKey);293  mac.update(ourPublicKey);294  mac.update(nonce);295  const expected = mac.digest();296297  if (!constantTimeEq(expected, claimedProof)) {298    throw new Error("auth proof did not verify");299  }300  return { initiatorPublicKey };301}302303export async function performInitiatorAuth(304  session: NoiseNKSession,305  ourPrivateKey: Uint8Array,306  ourPublicKey: Uint8Array,307  responderPublicKey: Uint8Array,308  sendEncryptedJson: (data: Uint8Array) => Promise<void>,309  receiveEncryptedJson: () => Promise<Uint8Array>,310): Promise<void> {311  // PCH-01-003b: the Noise handshake hash is now mixed into the proof, binding312  // it to this exact session (was previously bound via the DH key only).313314  const challengeBytes = await receiveEncryptedJson();315  const challenge = JSON.parse(new TextDecoder().decode(challengeBytes)) as AuthChallengeFrame;316  if (challenge.type !== "CHALLENGE") {317    throw new Error("expected CHALLENGE frame");318  }319  const nonce = hexToBytes(challenge.nonce_hex);320  const responderKeyFromChallenge = hexToBytes(challenge.responder_public_key_hex);321  // Defence against an off-key challenge: the responder's claimed key MUST be322  // the one we Noise-NK-handshook against, otherwise an active MITM that323  // managed to substitute a different key is rerouting the auth round.324  if (325    responderKeyFromChallenge.length !== responderPublicKey.length ||326    !responderKeyFromChallenge.every((b, i) => b === responderPublicKey[i])327  ) {328    throw new Error("responder key in challenge does not match Noise handshake");329  }330331  const proof = computeAuthProof(332    ourPrivateKey,333    ourPublicKey,334    responderPublicKey,335    nonce,336    session.handshakeHash,337  );338  const proofFrame: AuthProofFrame = {339    type: "PROOF",340    initiator_public_key_hex: bytesToHex(ourPublicKey),341    proof_hex: bytesToHex(proof),342  };343  await sendEncryptedJson(new TextEncoder().encode(JSON.stringify(proofFrame)));344}345346export { bytesToHex, hexToBytes };