chirp-pair.adopt-source.test.ts

8.9 KB · raw

1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";2import { computeVaultId } from "../shared/sync-identity";3import { routeSourcePostSASFrame, runChirpAdoptionReceiver } from "./chirp-pair";4import {5  memoryTransportPair,6  runPairingResponderOpenFrame,7  type LocalIdentity,8  type PairingTransport,9} from "../shared/chirp-handshake";10import { createPairingAttempt } from "../shared/chirp-pairing";11import { __setGgwaveModuleForTest } from "../shared/ggwave";12import { encodeChirpPayload } from "../shared/chirp-payload";13import { CHIRP_PAYLOAD_LENGTH, DEVICE_TYPE_EXTENSION, type ChirpPayload } from "../shared/sync-types";1415const localIdentity: LocalIdentity = {16  deviceId: "source-device",17  deviceName: "Source",18  deviceType: "extension",19  longTermPublicKeyHex: "ab".repeat(32),20  signingPublicKeyHex: "cd".repeat(32),21};2223function channel(): PairingTransport & { sent: Uint8Array[]; close: ReturnType<typeof vi.fn> } {24  const sent: Uint8Array[] = [];25  return {26    sent,27    close: vi.fn(async () => {}),28    async send(msg) { sent.push(msg); },29    async receive() {30      return new TextEncoder().encode(JSON.stringify({31        type: "ADOPT_IDENTITY",32        device_id: "receiver-device",33        device_name: "Receiver",34        device_type: "extension",35        long_term_public_key_hex: "ef".repeat(32),36      }));37    },38  };39}4041function fakeGgwave() {42  return {43    ProtocolId: { GGWAVE_PROTOCOL_ULTRASOUND_FASTEST: 5 },44    GGWAVE_OPERATING_MODE_RX: 1,45    GGWAVE_OPERATING_MODE_TX: 2,46    GGWAVE_OPERATING_MODE_RX_AND_TX: 3,47    getDefaultParameters() {48      return {49        sampleRateInp: 48000,50        sampleRateOut: 48000,51        samplesPerFrame: 1024,52        soundMarkerThreshold: 3,53        payloadLength: -1,54        operatingMode: 0,55      };56    },57    init() { return 1; },58    free() {},59    encode(_inst: number, data: Uint8Array | string) {60      const bytes = typeof data === "string" ? new TextEncoder().encode(data) : data;61      const out = new Float32Array(Math.max(bytes.length, 128));62      for (let i = 0; i < bytes.length; i++) out[i] = bytes[i] / 255;63      for (let i = bytes.length; i < out.length; i++) out[i] = 99;64      return out;65    },66    decode(_inst: number, samples: Float32Array | Int16Array) {67      if (samples.length < CHIRP_PAYLOAD_LENGTH) return null;68      const buf = new Uint8Array(CHIRP_PAYLOAD_LENGTH);69      for (let i = 0; i < CHIRP_PAYLOAD_LENGTH; i++) {70        const v = (samples as Float32Array)[i];71        if (v === 99) return null;72        buf[i] = Math.round(v * 255);73      }74      return buf;75    },76  };77}7879function fakeAudio(injectedChirp: ChirpPayload) {80  const bytes = encodeChirpPayload(injectedChirp);81  let pending = new Float32Array(128);82  for (let i = 0; i < bytes.length; i++) pending[i] = bytes[i] / 255;83  for (let i = bytes.length; i < pending.length; i++) pending[i] = 99;84  return async () => ({85    sampleRate: 48000,86    async play() { /* no-op */ },87    async pullSamples() {88      const out = pending;89      pending = new Float32Array(0);90      return out;91    },92    async close() { /* no-op */ },93  });94}9596describe("source-side ADOPT_REQUEST routing", () => {97  beforeEach(() => __setGgwaveModuleForTest(fakeGgwave()));98  afterEach(() => __setGgwaveModuleForTest(null));99100  it("T-A26: routes ADOPT_REQUEST to grantAdoption after SAS confirmation and consent", async () => {101    const rootKeyHex = "77".repeat(32);102    const ch = channel();103    const completePairing = vi.fn();104    const result = await routeSourcePostSASFrame(105      {106        sas: "895245",107        frame: {108          type: "ADOPT_REQUEST",109          device_id: "receiver-ephemeral",110          device_name: "Receiver",111          device_type: "extension",112        },113        channel: ch,114      },115      localIdentity,116      {117        confirmSAS: vi.fn(async () => true),118        completePairing,119        grantAdoptionConsent: vi.fn(async () => true),120        fetchAdoptionMaterial: vi.fn(async () => ({121          rootKeyHex,122          vaultId: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),123          license: "PPK-test",124        })),125        onProgress: vi.fn(),126      },127    );128129    const grant = JSON.parse(new TextDecoder().decode(ch.sent[0]));130    expect(result).toEqual(expect.objectContaining({ deviceId: "receiver-device" }));131    expect(grant).toMatchObject({132      type: "ADOPT_GRANT",133      root_key_hex: rootKeyHex,134      vault_id: await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))),135      license: "PPK-test",136    });137    expect(completePairing).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));138  });139140  it("T-A27: routes PAIR_IDENTITY to the existing pairing path", async () => {141    const ch = channel();142    const completePairing = vi.fn();143    const result = await routeSourcePostSASFrame(144      {145        sas: "895245",146        frame: {147          type: "PAIR_IDENTITY",148          device_id: "phone",149          device_name: "Phone",150          device_type: "android",151          long_term_public_key_hex: "aa".repeat(32),152        },153        channel: ch,154      },155      localIdentity,156      {157        confirmSAS: vi.fn(async () => true),158        completePairing,159        onProgress: vi.fn(),160      },161    );162163    const response = JSON.parse(new TextDecoder().decode(ch.sent[0]));164    expect(response.type).toBe("PAIR_IDENTITY");165    expect(result).toEqual(expect.objectContaining({ deviceId: "phone" }));166    expect(completePairing).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "phone" }));167  });168169  it("T-A28: unknown frame closes the channel gracefully", async () => {170    const ch = channel();171    await expect(routeSourcePostSASFrame(172      { sas: "895245", frame: { type: "WHAT_IS_THIS" }, channel: ch },173      localIdentity,174      {175        confirmSAS: vi.fn(async () => true),176        completePairing: vi.fn(),177        onProgress: vi.fn(),178      },179    )).rejects.toThrow("unexpected_pairing_frame");180181    expect(ch.close).toHaveBeenCalledTimes(1);182  });183184  it("T-A29: source root key bytes are zeroed after grantAdoption regardless of outcome", async () => {185    const fillSpy = vi.spyOn(Uint8Array.prototype, "fill");186    await routeSourcePostSASFrame(187      {188        sas: "895245",189        frame: {190          type: "ADOPT_REQUEST",191          device_id: "receiver-ephemeral",192          device_name: "Receiver",193          device_type: "extension",194        },195        channel: channel(),196      },197      localIdentity,198      {199        confirmSAS: vi.fn(async () => true),200        completePairing: vi.fn(),201        grantAdoptionConsent: vi.fn(async () => true),202        fetchAdoptionMaterial: vi.fn(async () => ({203          rootKeyHex: "88".repeat(32),204          vaultId: await computeVaultId(Uint8Array.from(Buffer.from("88".repeat(32), "hex"))),205        })),206        onProgress: vi.fn(),207      },208    );209210    expect(fillSpy).toHaveBeenCalledWith(0);211  });212213  it("T-A33: receiver onboarding orchestrator sends ADOPT_REQUEST and persists the granted source", async () => {214    const sourceAttempt = createPairingAttempt(DEVICE_TYPE_EXTENSION);215    const [receiverTransport, sourceTransport] = memoryTransportPair();216    const rootKeyHex = "99".repeat(32);217    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));218    const receiverUpsert = vi.fn();219    const sourceUpsert = vi.fn();220221    const source = (async () => {222      const postNoise = await runPairingResponderOpenFrame(223        sourceAttempt.ephemeralPrivateKey,224        sourceAttempt.ephemeralPublicKey,225        sourceTransport,226      );227      return routeSourcePostSASFrame(postNoise, localIdentity, {228        confirmSAS: vi.fn(async () => true),229        completePairing: sourceUpsert,230        grantAdoptionConsent: vi.fn(async () => true),231        fetchAdoptionMaterial: vi.fn(async () => ({ rootKeyHex, vaultId })),232        onProgress: vi.fn(),233      });234    })();235236    const receiver = await runChirpAdoptionReceiver({237      deviceName: "Receiver",238      deviceType: "extension",239      openAudio: fakeAudio(sourceAttempt.outgoingPayload),240      openPairingServer: vi.fn(async () => ({241        transport: { ...receiverTransport, close: vi.fn(async () => {}) },242        peerIp: "127.0.0.1",243      })),244      confirmSAS: vi.fn(async () => true),245      createVaultFromRootKey: vi.fn(async (adoptedRootKeyHex) => {246        expect(adoptedRootKeyHex).toBe(rootKeyHex);247        return { vaultId };248      }),249      deriveLocalIdentity: vi.fn(async () => ({250        deviceId: "receiver-device",251        deviceName: "Receiver",252        deviceType: "extension" as const,253        longTermPublicKeyHex: "ef".repeat(32),254        signingPublicKeyHex: "12".repeat(32),255      })),256      completePairing: receiverUpsert,257      onProgress: vi.fn(),258    });259    await source;260261    expect(receiver).toEqual(expect.objectContaining({ deviceId: "source-device" }));262    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-device" }));263    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-device" }));264  });265});