noise-kk.test.ts

4.9 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 wire = channels();100    const [a, b] = await Promise.all([101      noiseKKInitiator(102        initiator.secretKey,103        initiator.publicKey,104        responder.publicKey,105        wire.initiatorSend,106        wire.initiatorReceive,107      ),108      noiseKKResponder(109        responder.secretKey,110        responder.publicKey,111        initiator.publicKey,112        wire.responderSend,113        wire.responderReceive,114      ),115    ]);116117    expect(a.handshakeHash).toHaveLength(32);118    expect(bytesToHex(b.handshakeHash)).toBe(bytesToHex(a.handshakeHash));119  });120121  it("hard-fails a mismatched expected initiator key without downgrade", async () => {122    const initiator = generateKeyPair();123    const attacker = generateKeyPair();124    const responder = generateKeyPair();125    const wire = channels();126127    const initiatorHandshake = noiseKKInitiator(128      initiator.secretKey,129      initiator.publicKey,130      responder.publicKey,131      wire.initiatorSend,132      wire.initiatorReceive,133    );134    const responderHandshake = noiseKKResponder(135      responder.secretKey,136      responder.publicKey,137      attacker.publicKey,138      wire.responderSend,139      wire.responderReceive,140    );141142    await expect(Promise.all([initiatorHandshake, responderHandshake])).rejects.toThrow(143      /decrypt/i,144    );145  });146});