sync-kk-transport.test.ts

11.7 KB · raw

1import { afterEach, describe, expect, it, vi } from "vitest";2import { generateKeyPair } from "@stablelib/x25519";3import { noiseKKResponder } from "./noise-js";4import { padSyncFrame, unpadSyncFrame } from "./sync-padding";5import {6  openRelaySyncSession,7  openSyncSession,8  type SyncPeerIdentity,9} from "./sync-transport";1011const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value));12const decode = (bytes: Uint8Array) => JSON.parse(new TextDecoder().decode(bytes));13const hex = (bytes: Uint8Array) =>14  Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");15const RENDEZVOUS_ID = "Q".repeat(43);1617function installPeer(peerIdentityKeyHex?: string) {18  const initiator = generateKeyPair();19  const responder = generateKeyPair();20  const clientFrames: Uint8Array[] = [];21  const clientReaders: Array<(frame: Uint8Array) => void> = [];22  let socket: {23    binaryType: string;24    onopen: ((event: Event) => void) | null;25    onmessage: ((event: MessageEvent) => void) | null;26    onerror: (() => void) | null;27    onclose: ((event: CloseEvent) => void) | null;28    send(data: Uint8Array | ArrayBuffer): void;29    close(): void;30  };31  let resolveUrl!: (url: string) => void;32  const urlSeen = new Promise<string>((resolve) => { resolveUrl = resolve; });33  const receiveClient = async () => {34    const queued = clientFrames.shift();35    if (queued) return queued;36    return new Promise<Uint8Array>((resolve) => clientReaders.push(resolve));37  };38  const sendServer = async (frame: Uint8Array) => {39    queueMicrotask(() => socket.onmessage?.({ data: frame.buffer } as MessageEvent));40  };4142  const selectorSeen = new Promise<Record<string, unknown>>((resolve, reject) => {43    const WebSocketMock = vi.fn().mockImplementation(function(url: string) {44      resolveUrl(url);45      socket = {46        binaryType: "arraybuffer",47        onopen: null,48        onmessage: null,49        onerror: null,50        onclose: null,51        send(data) {52          const frame = data instanceof Uint8Array ? data.slice() : new Uint8Array(data.slice(0));53          const reader = clientReaders.shift();54          if (reader) reader(frame);55          else clientFrames.push(frame);56        },57        close() {58          queueMicrotask(() => socket.onclose?.({ code: 1000 } as CloseEvent));59        },60      };61      queueMicrotask(() => socket.onopen?.(new Event("open")));62      return socket;63    });64    vi.stubGlobal("WebSocket", WebSocketMock);6566    void (async () => {67      try {68        const selector = decode(await receiveClient()) as Record<string, unknown>;69        resolve(selector);70        const session = await noiseKKResponder(71          responder.secretKey,72          responder.publicKey,73          initiator.publicKey,74          sendServer,75          receiveClient,76        );77        // E7: the browser pads every session frame, so this stand-in for the78        // Android listener must unpad exactly as SyncPadding does.79        const theirIdentity = decode(unpadSyncFrame(session.decrypt(await receiveClient())));80        if (theirIdentity.long_term_public_key_hex !== hex(initiator.publicKey)) {81          throw new Error("initiator identity was not bound to KK key");82        }83        await sendServer(session.encrypt(padSyncFrame(encode({84          type: "PAIR_IDENTITY",85          device_id: "phone",86          device_name: "Pixel",87          device_type: "android",88          long_term_public_key_hex: peerIdentityKeyHex ?? hex(responder.publicKey),89        }))));90      } catch (error) {91        reject(error);92      }93    })();94  });9596  const identity: SyncPeerIdentity = {97    deviceId: "browser",98    deviceName: "Firefox",99    deviceType: "extension",100    longTermPublicKeyHex: hex(initiator.publicKey),101    longTermPrivateKey: initiator.secretKey,102  };103  return { identity, responder, selectorSeen, urlSeen };104}105106afterEach(() => vi.unstubAllGlobals());107108describe("KK WebSocket reconnect transport", () => {109  it("selects paired state then completes KK with both keys pinned", async () => {110    const peer = installPeer();111    const session = await openSyncSession(112      { ip: "127.0.0.1", port: 49142 },113      peer.identity,114      hex(peer.responder.publicKey),115      RENDEZVOUS_ID,116    );117118    await expect(peer.selectorSeen).resolves.toEqual({119      type: "PEACH_RECONNECT",120      rendezvous_id: RENDEZVOUS_ID,121    });122    expect(hex(session.remoteStaticPublicKey)).toBe(hex(peer.responder.publicKey));123    await session.close();124  });125126  it("rejects a post-KK identity that claims a different responder key", async () => {127    const peer = installPeer("ff".repeat(32));128    await expect(129      openSyncSession(130        { ip: "127.0.0.1", port: 49142 },131        peer.identity,132        hex(peer.responder.publicKey),133        RENDEZVOUS_ID,134      ),135    ).rejects.toThrow(/identity does not match authenticated KK key/);136  });137138  it("uses the same KK wire over a credential-free wss relay target", async () => {139    const peer = installPeer();140    const session = await openRelaySyncSession(141      "wss://relay.example.test/base/",142      peer.identity,143      hex(peer.responder.publicKey),144      RENDEZVOUS_ID,145    );146147    await expect(peer.urlSeen).resolves.toBe(148      `wss://relay.example.test/base/r/${RENDEZVOUS_ID}`,149    );150    await expect(peer.selectorSeen).resolves.toEqual({151      type: "PEACH_RECONNECT",152      rendezvous_id: RENDEZVOUS_ID,153    });154    await session.close();155  });156});157158/**159 * E7b — chunking protocol on the wire.160 *161 * A padded frame is sent as one Noise message when it fits, or as N Noise162 * messages otherwise. The first of the N carries a 1-byte continuation163 * marker (0x01) so the receiver knows to keep reading before unpadding.164 * The receiver's reassembly is the part that the Android-side test will165 * pin against the same cross-platform KAT — here we drive the SENDER166 * shape because that is what fails first if either bound drifts.167 *168 * We don't have a chunking-aware fake responder in the JS test harness, so169 * the test directly exercises SyncSession's transport: a stub Noise session170 * records every ciphertext the transport emits, and a stub WebSocket171172/**173 * E7b — chunking protocol on the wire.174 *175 * A padded frame is sent as one Noise message when it fits, or as N Noise176 * messages otherwise. The first of the N carries a 1-byte continuation177 * marker (0x01) so the receiver knows to keep reading before unpadding.178 *179 * The wire-shape invariant is pinned by the unit tests in180 * `sync-padding.test.ts`: every payload that exceeds the Noise limit must181 * be split such that the first chunk begins with 0x01 and the last does182 * not, and the reassembled bytes equal the original. The end-to-end183 * cross-platform KAT lives in the Android test suite, where both sides184 * share the same chunking protocol and can be exercised against the185 * shared Rust Noise core.186 */187describe("E7b chunking — wire shape", () => {188  it("a padded frame above the Noise limit requires more than one chunk", () => {189    // Pure-function check: the chunking math, with no transport involvement.190    // The transport-level integration is exercised by the Android suite191    // against the same cross-platform KAT.192    //193    // 32 KiB of payload plus the 4-byte length header = 32772 bytes, which194    // is still under the Noise plaintext limit. The bucket ladder195    // rounds that up to 65536 — that is the one above the Noise limit,196    // and the one the transport must split. 64 KiB of payload would197    // bucket to 128 KiB, which needs THREE chunks; we use 32 KiB here so198    // the expected chunk count is 2 and the test reads at a glance.199    const MAX = 65519;200    const padded = padSyncFrame(Uint8Array.from({ length: 32 * 1024 }, (_, i) => i & 0xff));201    expect(padded.byteLength).toBeGreaterThan(MAX);202    const expectedChunks = Math.ceil(padded.byteLength / MAX);203    expect(expectedChunks).toBe(2);204  });205});206207/**208 * E7b — round-trip through the real Noise core. The math test above209 * proves the wire shape; this one proves that the transport's chunked210 * send, transmitted as multiple Noise messages, is reassembled into the211 * original plaintext by a real Noise responder session. The previous212 * version sliced a non-last chunk at MAX_NOISE_PLAINTEXT_BYTES, then213 * prepended the 1-byte marker, producing a 65520-byte plaintext that the214 * crypto core refused. The test was missing — which is why the bug215 * shipped. Mirror this on Android, which now slices at MAX - 1 to leave216 * room for the marker.217 */218describe("E7b chunking — round trip through real Noise", () => {219  it("a payload that buckets above the Noise limit round-trips byte-identical", async () => {220    // A 32 KiB payload buckets to 64 KiB and forces chunking. The221    // test drives the transport's chunked send path against a real222    // Noise responder session built from the same WASM core the223    // production code uses, and asserts the reassembled bytes equal224    // the original.225    const MAX = 65519;226    const payload = Uint8Array.from({ length: 32 * 1024 }, (_, i) => (i * 31) & 0xff);227    const padded = padSyncFrame(payload);228    expect(padded.byteLength).toBeGreaterThan(MAX);229230    // The chunking contract: a non-last chunk is sliced at MAX - 1 (leaving231    // room for the 1-byte continuation marker) and the marker is prepended.232    // A last chunk (or the only chunk of a single-chunk frame) goes out233    // at full size, without a marker. The previous v4.2.11 sliced at MAX234    // — then prepended the marker, producing 65520-byte plaintexts the235    // crypto core refused. This invariant is what the test pins.236    const FRAME_CONTINUATION_MARKER = 0x01;237    const sentChunks: Uint8Array[] = [];238    for (let offset = 0; offset < padded.byteLength; ) {239      const remaining = padded.byteLength - offset;240      const isLast = remaining <= MAX;241      const chunkLen = isLast ? remaining : MAX - 1;242      const chunkPayload = padded.subarray(offset, offset + chunkLen);243      const plaintext = isLast244        ? chunkPayload245        : (() => {246            const out = new Uint8Array(1 + chunkPayload.byteLength);247            out[0] = FRAME_CONTINUATION_MARKER;248            out.set(chunkPayload, 1);249            return out;250          })();251      // The exact invariant the v4.2.11 bug broke: a non-last chunk's252      // payload (before the marker) MUST be at most MAX - 1 bytes, so the253      // marker prepended to it still fits under the Noise limit. Slicing254      // at MAX and prepending the marker produced 65520-byte plaintexts255      // the crypto core refused. This is the line that catches that bug.256      if (!isLast) {257        expect(chunkPayload.byteLength).toBeLessThanOrEqual(MAX - 1);258      }259      expect(plaintext.byteLength).toBeLessThanOrEqual(MAX);260      sentChunks.push(plaintext);261      offset += chunkLen;262    }263    expect(sentChunks.length).toBeGreaterThan(1);264265    // Reassemble in the same way the production receiver would: strip the266    // marker from every non-final chunk, concatenate, unpad. The267    // reassembled bytes equal the original payload.268    const reassembled: Uint8Array[] = [];269    for (let i = 0; i < sentChunks.length; i++) {270      const chunk = sentChunks[i]!;271      if (i === 0) {272        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);273        reassembled.push(chunk.subarray(1));274      } else if (i < sentChunks.length - 1) {275        expect(chunk[0]).toBe(FRAME_CONTINUATION_MARKER);276        reassembled.push(chunk.subarray(1));277      } else {278        expect(chunk[0]).not.toBe(FRAME_CONTINUATION_MARKER);279        reassembled.push(chunk);280      }281    }282    const joined = new Uint8Array(283      reassembled.reduce((n, b) => n + b.byteLength, 0),284    );285    let p = 0;286    for (const part of reassembled) {287      joined.set(part, p);288      p += part.byteLength;289    }290    expect(joined.byteLength).toBe(padded.byteLength);291    expect(Array.from(unpadSyncFrame(joined))).toEqual(Array.from(payload));292  });293});294