vault-adopt.test.ts

12.8 KB · raw

1import { describe, expect, it, vi } from "vitest";2import { computeVaultId } from "./sync-identity";3import {4  grantAdoption,5  requestAdoption,6  sendFrame,7  type AdoptRequest,8  type AdoptionReceiverDeps,9  type AdoptionSourceDeps,10} from "./vault-adopt";11import type { PairingTransport } from "./pairing-handshake";1213function memoryPair(): [PairingTransport, PairingTransport] {14  const a: Uint8Array[] = [];15  const b: Uint8Array[] = [];16  const ar: Array<(v: Uint8Array) => void> = [];17  const br: Array<(v: Uint8Array) => void> = [];18  function make(19    inbox: Uint8Array[],20    outbox: Uint8Array[],21    readers: Array<(v: Uint8Array) => void>,22    outReaders: Array<(v: Uint8Array) => void>,23  ): PairingTransport {24    return {25      async send(msg) {26        const reader = outReaders.shift();27        if (reader) reader(msg);28        else outbox.push(msg);29      },30      receive() {31        const msg = inbox.shift();32        if (msg) return Promise.resolve(msg);33        return new Promise((resolve) => readers.push(resolve));34      },35    };36  }37  return [make(a, b, ar, br), make(b, a, br, ar)];38}3940function identity(name: string) {41  return {42    deviceId: `${name}-id`,43    deviceName: name,44    deviceType: "extension" as const,45    longTermPublicKeyHex: "ab".repeat(32),46    signingPublicKeyHex: "cd".repeat(32),47  };48}4950describe("vault adoption sub-protocol", () => {51  it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {52    const [receiverChannel, sourceChannel] = memoryPair();53    const rootKeyHex = "11".repeat(32);54    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));55    const sourceUpsert = vi.fn();56    const receiverUpsert = vi.fn();57    const pairState = {58      rendezvousSecretHex: "42".repeat(32),59      relayUrl: "wss://relay.example.test",60    };61    const receiverDeps: AdoptionReceiverDeps = {62      deviceName: "Receiver",63      generateDeviceId: () => "receiver-ephemeral",64      confirmSas: vi.fn(async () => true),65      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),66      deriveLocalIdentity: vi.fn(async () => identity("receiver")),67      upsertPairedDevice: receiverUpsert,68      applyLicense: vi.fn(),69      pairState,70      onError: vi.fn(),71    };72    const sourceDeps: AdoptionSourceDeps = {73      sourceIdentity: identity("source"),74      rootKeyHex: vi.fn(async () => rootKeyHex),75      vaultId: vi.fn(async () => vaultId),76      license: vi.fn(async () => "PPK-test"),77      onConsent: vi.fn(async () => true),78      upsertPairedDevice: sourceUpsert,79      pairState,80    };8182    await Promise.all([83      requestAdoption(receiverChannel, receiverDeps),84      (async () => {85        const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;86        await grantAdoption(sourceChannel, request, sourceDeps);87      })(),88    ]);8990    expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);91    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({92      deviceId: "source-id",93      ...pairState,94    }));95    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({96      deviceId: "receiver-id",97      ...pairState,98    }));99    expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");100  });101102  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {103    const [receiverChannel, sourceChannel] = memoryPair();104    const rootKeyHex = vi.fn(async () => "11".repeat(32));105    const createVaultFromRootKey = vi.fn();106    const receiver = requestAdoption(receiverChannel, {107      deviceName: "Receiver",108      generateDeviceId: () => "receiver-ephemeral",109      confirmSas: vi.fn(async () => false),110      createVaultFromRootKey,111      deriveLocalIdentity: vi.fn(),112      upsertPairedDevice: vi.fn(),113      onError: vi.fn(),114    });115    const request = JSON.parse(116      new TextDecoder().decode(await sourceChannel.receive()),117    ) as AdoptRequest;118    const source = grantAdoption(sourceChannel, request, {119      sourceIdentity: identity("source"),120      rootKeyHex,121      vaultId: vi.fn(),122      onConsent: vi.fn(async () => true),123      upsertPairedDevice: vi.fn(),124    });125126    await expect(receiver).resolves.toBeNull();127    await expect(source).resolves.toBeNull();128    expect(rootKeyHex).not.toHaveBeenCalled();129    expect(createVaultFromRootKey).not.toHaveBeenCalled();130  });131132  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {133    const [receiverChannel, sourceChannel] = memoryPair();134    const createVaultFromRootKey = vi.fn();135    const errors: string[] = [];136    const receiver = requestAdoption(receiverChannel, {137      deviceName: "Receiver",138      generateDeviceId: () => "receiver-ephemeral",139      confirmSas: vi.fn(async () => true),140      createVaultFromRootKey,141      deriveLocalIdentity: vi.fn(),142      upsertPairedDevice: vi.fn(),143      onError: (err) => errors.push(err),144    });145    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;146    await grantAdoption(sourceChannel, request, {147      sourceIdentity: identity("source"),148      rootKeyHex: vi.fn(),149      vaultId: vi.fn(),150      onConsent: vi.fn(async () => false),151      upsertPairedDevice: vi.fn(),152    });153    await Promise.race([receiver, Promise.resolve()]);154155    expect(createVaultFromRootKey).not.toHaveBeenCalled();156    expect(errors).toEqual([]);157  });158159  it("T-A13: invalid root_key_hex aborts gracefully", async () => {160    const [receiverChannel, sourceChannel] = memoryPair();161    const onError = vi.fn();162    const createVaultFromRootKey = vi.fn();163    const receiver = requestAdoption(receiverChannel, {164      deviceName: "Receiver",165      generateDeviceId: () => "receiver-ephemeral",166      confirmSas: vi.fn(async () => true),167      createVaultFromRootKey,168      deriveLocalIdentity: vi.fn(),169      upsertPairedDevice: vi.fn(),170      onError,171    });172    await sourceChannel.receive();173    await sendFrame(sourceChannel, {174      type: "ADOPT_GRANT",175      root_key_hex: "bad",176      vault_id: "00".repeat(32),177      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },178    } as never);179    await receiver;180181    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");182    expect(createVaultFromRootKey).not.toHaveBeenCalled();183  });184185  it("T-A14: vault_id mismatch aborts before vault creation", async () => {186    const [receiverChannel, sourceChannel] = memoryPair();187    const onError = vi.fn();188    const createVaultFromRootKey = vi.fn();189    const receiver = requestAdoption(receiverChannel, {190      deviceName: "Receiver",191      generateDeviceId: () => "receiver-ephemeral",192      confirmSas: vi.fn(async () => true),193      createVaultFromRootKey,194      deriveLocalIdentity: vi.fn(),195      upsertPairedDevice: vi.fn(),196      onError,197    });198    await sourceChannel.receive();199    await sendFrame(sourceChannel, {200      type: "ADOPT_GRANT",201      root_key_hex: "22".repeat(32),202      vault_id: "00".repeat(32),203      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },204    });205    await receiver;206207    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");208    expect(createVaultFromRootKey).not.toHaveBeenCalled();209  });210211  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {212    const [receiverChannel, sourceChannel] = memoryPair();213    const upsert = vi.fn();214    const promise = grantAdoption(sourceChannel, {215      type: "ADOPT_REQUEST",216      device_id: "receiver-ephemeral",217      device_name: "Receiver",218      device_type: "extension",219    }, {220      sourceIdentity: identity("source"),221      rootKeyHex: vi.fn(async () => "33".repeat(32)),222      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),223      onConsent: vi.fn(async () => true),224      upsertPairedDevice: upsert,225    });226    await sendFrame(receiverChannel, {227      type: "ADOPT_CONFIRM",228      confirmed: true,229    });230231    await Promise.race([promise, Promise.resolve()]);232    expect(upsert).not.toHaveBeenCalled();233  });234235  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {236    const [receiverChannel, sourceChannel] = memoryPair();237    const rootKeyHex = "66".repeat(32);238    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));239    const receiverUpsert = vi.fn();240    const onError = vi.fn();241    const receiver = requestAdoption(receiverChannel, {242      deviceName: "Receiver",243      generateDeviceId: () => "receiver-ephemeral",244      confirmSas: vi.fn(async () => true),245      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),246      deriveLocalIdentity: vi.fn(async () => identity("receiver")),247      upsertPairedDevice: receiverUpsert,248      onError,249    });250251    await sourceChannel.receive(); // ADOPT_REQUEST252    await sourceChannel.receive(); // ADOPT_CONFIRM253    await sendFrame(sourceChannel, {254      type: "ADOPT_GRANT",255      root_key_hex: rootKeyHex,256      vault_id: vaultId,257      source: {258        device_id: "source-id",259        device_name: "Source",260        device_type: "extension",261        long_term_public_key_hex: "ab".repeat(32),262      },263    });264    await sourceChannel.receive(); // ADOPT_IDENTITY265    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });266267    await expect(receiver).resolves.toBeNull();268    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");269    expect(receiverUpsert).not.toHaveBeenCalled();270  });271272  it("T-A17: receiver safety policy runs before vault creation", async () => {273    const [receiverChannel, sourceChannel] = memoryPair();274    const rootKeyHex = "77".repeat(32);275    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));276    const createVaultFromRootKey = vi.fn();277    const receiver = requestAdoption(receiverChannel, {278      deviceName: "Receiver",279      generateDeviceId: () => "receiver-ephemeral",280      confirmSas: vi.fn(async () => true),281      validateSource: vi.fn(async () => {282        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");283      }),284      createVaultFromRootKey,285      deriveLocalIdentity: vi.fn(),286      upsertPairedDevice: vi.fn(),287      onError: vi.fn(),288    });289290    await sourceChannel.receive(); // ADOPT_REQUEST291    await sourceChannel.receive(); // ADOPT_CONFIRM292    await sendFrame(sourceChannel, {293      type: "ADOPT_GRANT",294      root_key_hex: rootKeyHex,295      vault_id: vaultId,296      source: {297        device_id: "new-source",298        device_name: "Source",299        device_type: "extension",300        long_term_public_key_hex: "ab".repeat(32),301      },302    });303304    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");305    expect(createVaultFromRootKey).not.toHaveBeenCalled();306  });307308  it("T-A31: does not apply a license if vault creation fails", async () => {309    const [receiverChannel, sourceChannel] = memoryPair();310    const rootKeyHex = "44".repeat(32);311    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));312    const applyLicense = vi.fn();313    const receiver = requestAdoption(receiverChannel, {314      deviceName: "Receiver",315      generateDeviceId: () => "receiver-ephemeral",316      confirmSas: vi.fn(async () => true),317      createVaultFromRootKey: vi.fn(async () => {318        throw new Error("commit_failed");319      }),320      deriveLocalIdentity: vi.fn(),321      upsertPairedDevice: vi.fn(),322      applyLicense,323      onError: vi.fn(),324    });325    await sourceChannel.receive();326    await sendFrame(sourceChannel, {327      type: "ADOPT_GRANT",328      root_key_hex: rootKeyHex,329      vault_id: vaultId,330      license: "PPK-test",331      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },332    });333334    await expect(receiver).rejects.toThrow("commit_failed");335    expect(applyLicense).not.toHaveBeenCalled();336  });337338  it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {339    const [receiverChannel, sourceChannel] = memoryPair();340    const createVaultFromRootKey = vi.fn();341    const receiver = requestAdoption(receiverChannel, {342      deviceName: "Receiver",343      generateDeviceId: () => "receiver-ephemeral",344      confirmSas: vi.fn(async () => true),345      createVaultFromRootKey,346      deriveLocalIdentity: vi.fn(),347      upsertPairedDevice: vi.fn(),348      applyLicense: vi.fn(),349      onError: vi.fn(),350    });351    await sourceChannel.receive();352    await sendFrame(sourceChannel, {353      type: "ADOPT_GRANT",354      root_key_hex: "55".repeat(32),355      vault_id: "99".repeat(32),356      license: "PPK-test",357      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },358    });359360    await receiver;361    expect(createVaultFromRootKey).not.toHaveBeenCalled();362  });363});