noise-kk.test.ts

5.2 KB · raw

1import { describe, expect, it } from "vitest";2import { generateKeyPair, generateKeyPairFromSeed } from "@stablelib/x25519";3import {4  bytesToHex,5  noiseKKInitiator,6  noiseKKResponder,7} from "./noise-js";89function channels() {10  const initiatorToResponder: Uint8Array[] = [];11  const responderToInitiator: Uint8Array[] = [];12  const drain = async (queue: Uint8Array[]) => {13    while (queue.length === 0) await new Promise<void>((resolve) => setTimeout(resolve, 0));14    return queue.shift()!;15  };16  return {17    initiatorSend: async (frame: Uint8Array) => { initiatorToResponder.push(frame); },18    initiatorReceive: () => drain(responderToInitiator),19    responderSend: async (frame: Uint8Array) => { responderToInitiator.push(frame); },20    responderReceive: () => drain(initiatorToResponder),21  };22}2324describe("Noise KK reconnect", () => {25  it("mutually authenticates known static keys and carries transport frames", async () => {26    const initiator = generateKeyPair();27    const responder = generateKeyPair();28    const wire = channels();29    const [a, b] = await Promise.all([30      noiseKKInitiator(31        initiator.secretKey,32        initiator.publicKey,33        responder.publicKey,34        wire.initiatorSend,35        wire.initiatorReceive,36      ),37      noiseKKResponder(38        responder.secretKey,39        responder.publicKey,40        initiator.publicKey,41        wire.responderSend,42        wire.responderReceive,43      ),44    ]);4546    expect(bytesToHex(a.handshakeHash)).toBe(bytesToHex(b.handshakeHash));47    expect(new TextDecoder().decode(b.decrypt(a.encrypt(new TextEncoder().encode("one"))))).toBe("one");48    expect(new TextDecoder().decode(a.decrypt(b.encrypt(new TextEncoder().encode("two"))))).toBe("two");49  });5051  it("E6: every reconnect derives fresh keys, so one session never exposes another", async () => {52    // Peach sync sessions are one-shot: ~10 frames, a 30s coordinator timeout,53    // then close. There is no long-lived session to rekey inside, and each54    // reconnect runs a full KK handshake with fresh ephemerals — which gives55    // strictly stronger post-compromise security than intra-session Rekey()56    // would. This pins that property so a future change cannot quietly reuse57    // ephemerals and turn every past session's traffic into one key's problem.58    const initiator = generateKeyPair();59    const responder = generateKeyPair();6061    async function handshake() {62      const wire = channels();63      const [a, b] = await Promise.all([64        noiseKKInitiator(65          initiator.secretKey,66          initiator.publicKey,67          responder.publicKey,68          wire.initiatorSend,69          wire.initiatorReceive,70        ),71        noiseKKResponder(72          responder.secretKey,73          responder.publicKey,74          initiator.publicKey,75          wire.responderSend,76          wire.responderReceive,77        ),78      ]);79      return { a, b };80    }8182    const first = await handshake();83    const second = await handshake();8485    // Same long-term statics, different session — the transcripts must differ.86    expect(bytesToHex(first.a.handshakeHash)).not.toBe(87      bytesToHex(second.a.handshakeHash),88    );8990    // And the keys must genuinely differ: a frame from session two must not be91    // readable by session one.92    const frame = second.a.encrypt(new TextEncoder().encode("session two"));93    expect(() => first.b.decrypt(frame)).toThrow();94  });9596  it("pins the cross-platform deterministic handshake hash", async () => {97    const initiator = generateKeyPairFromSeed(new Uint8Array(32).fill(0x01));98    const responder = generateKeyPairFromSeed(new Uint8Array(32).fill(0x02));99    const initiatorEphemeral = generateKeyPairFromSeed(new Uint8Array(32).fill(0x03));100    const responderEphemeral = generateKeyPairFromSeed(new Uint8Array(32).fill(0x04));101    const wire = channels();102    const [a, b] = await Promise.all([103      noiseKKInitiator(104        initiator.secretKey,105        initiator.publicKey,106        responder.publicKey,107        wire.initiatorSend,108        wire.initiatorReceive,109        initiatorEphemeral,110      ),111      noiseKKResponder(112        responder.secretKey,113        responder.publicKey,114        initiator.publicKey,115        wire.responderSend,116        wire.responderReceive,117        responderEphemeral,118      ),119    ]);120121    expect(bytesToHex(a.handshakeHash)).toBe(122      "dced1b411d7dc25716d8fc7fdc6b49418ae5648f323df5fb20a105f3c0acabab",123    );124    expect(bytesToHex(b.handshakeHash)).toBe(bytesToHex(a.handshakeHash));125  });126127  it("hard-fails a mismatched expected initiator key without downgrade", async () => {128    const initiator = generateKeyPair();129    const attacker = generateKeyPair();130    const responder = generateKeyPair();131    const wire = channels();132133    const initiatorHandshake = noiseKKInitiator(134      initiator.secretKey,135      initiator.publicKey,136      responder.publicKey,137      wire.initiatorSend,138      wire.initiatorReceive,139    );140    const responderHandshake = noiseKKResponder(141      responder.secretKey,142      responder.publicKey,143      attacker.publicKey,144      wire.responderSend,145      wire.responderReceive,146    );147148    await expect(Promise.all([initiatorHandshake, responderHandshake])).rejects.toThrow(149      /decrypt/i,150    );151  });152});