vault-adopt.test.ts

16.1 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-A10b: rejects without creating or pairing when neither peer has Pro", async () => {103    const [receiverChannel, sourceChannel] = memoryPair();104    const rootKeyHex = "12".repeat(32);105    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));106    const createVault = vi.fn();107    const receiverUpsert = vi.fn();108    const sourceUpsert = vi.fn();109    const onError = vi.fn();110111    const receiver = requestAdoption(receiverChannel, {112      deviceName: "Free receiver",113      generateDeviceId: () => "free-receiver",114      confirmSas: vi.fn(async () => true),115      license: vi.fn(async () => undefined),116      createVaultFromRootKey: createVault,117      deriveLocalIdentity: vi.fn(),118      upsertPairedDevice: receiverUpsert,119      onError,120    });121    const request = JSON.parse(122      new TextDecoder().decode(await sourceChannel.receive()),123    ) as AdoptRequest;124    const source = grantAdoption(sourceChannel, request, {125      sourceIdentity: identity("free-source"),126      rootKeyHex: vi.fn(async () => rootKeyHex),127      vaultId: vi.fn(async () => vaultId),128      license: vi.fn(async () => undefined),129      onConsent: vi.fn(async () => true),130      upsertPairedDevice: sourceUpsert,131    });132133    await expect(receiver).resolves.toBeNull();134    await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");135    expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");136    expect(createVault).not.toHaveBeenCalled();137    expect(receiverUpsert).not.toHaveBeenCalled();138    expect(sourceUpsert).not.toHaveBeenCalled();139  });140141  it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {142    const [receiverChannel, sourceChannel] = memoryPair();143    const rootKeyHex = "13".repeat(32);144    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));145    let sourceLicense: string | undefined;146    const sourceApply = vi.fn(async (license: string) => {147      sourceLicense = license;148    });149150    const receiver = requestAdoption(receiverChannel, {151      deviceName: "Pro receiver",152      generateDeviceId: () => "pro-receiver",153      confirmSas: vi.fn(async () => true),154      license: vi.fn(async () => "PPK-receiver"),155      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),156      deriveLocalIdentity: vi.fn(async () => identity("receiver")),157      upsertPairedDevice: vi.fn(),158      applyLicense: vi.fn(),159      onError: vi.fn(),160    });161    const request = JSON.parse(162      new TextDecoder().decode(await sourceChannel.receive()),163    ) as AdoptRequest;164    const source = grantAdoption(sourceChannel, request, {165      sourceIdentity: identity("free-source"),166      rootKeyHex: vi.fn(async () => rootKeyHex),167      vaultId: vi.fn(async () => vaultId),168      license: vi.fn(async () => sourceLicense),169      applyLicense: sourceApply,170      onConsent: vi.fn(async () => true),171      upsertPairedDevice: vi.fn(),172    });173174    await expect(Promise.all([receiver, source])).resolves.toEqual([175      expect.objectContaining({ deviceId: "free-source-id" }),176      expect.objectContaining({ deviceId: "receiver-id" }),177    ]);178    expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");179  });180181  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {182    const [receiverChannel, sourceChannel] = memoryPair();183    const rootKeyHex = vi.fn(async () => "11".repeat(32));184    const createVaultFromRootKey = vi.fn();185    const receiver = requestAdoption(receiverChannel, {186      deviceName: "Receiver",187      generateDeviceId: () => "receiver-ephemeral",188      confirmSas: vi.fn(async () => false),189      createVaultFromRootKey,190      deriveLocalIdentity: vi.fn(),191      upsertPairedDevice: vi.fn(),192      onError: vi.fn(),193    });194    const request = JSON.parse(195      new TextDecoder().decode(await sourceChannel.receive()),196    ) as AdoptRequest;197    const source = grantAdoption(sourceChannel, request, {198      sourceIdentity: identity("source"),199      rootKeyHex,200      vaultId: vi.fn(),201      onConsent: vi.fn(async () => true),202      upsertPairedDevice: vi.fn(),203    });204205    await expect(receiver).resolves.toBeNull();206    await expect(source).resolves.toBeNull();207    expect(rootKeyHex).not.toHaveBeenCalled();208    expect(createVaultFromRootKey).not.toHaveBeenCalled();209  });210211  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {212    const [receiverChannel, sourceChannel] = memoryPair();213    const createVaultFromRootKey = vi.fn();214    const errors: string[] = [];215    const receiver = requestAdoption(receiverChannel, {216      deviceName: "Receiver",217      generateDeviceId: () => "receiver-ephemeral",218      confirmSas: vi.fn(async () => true),219      createVaultFromRootKey,220      deriveLocalIdentity: vi.fn(),221      upsertPairedDevice: vi.fn(),222      onError: (err) => errors.push(err),223    });224    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;225    await grantAdoption(sourceChannel, request, {226      sourceIdentity: identity("source"),227      rootKeyHex: vi.fn(),228      vaultId: vi.fn(),229      onConsent: vi.fn(async () => false),230      upsertPairedDevice: vi.fn(),231    });232    await Promise.race([receiver, Promise.resolve()]);233234    expect(createVaultFromRootKey).not.toHaveBeenCalled();235    expect(errors).toEqual([]);236  });237238  it("T-A13: invalid root_key_hex aborts gracefully", async () => {239    const [receiverChannel, sourceChannel] = memoryPair();240    const onError = vi.fn();241    const createVaultFromRootKey = vi.fn();242    const receiver = requestAdoption(receiverChannel, {243      deviceName: "Receiver",244      generateDeviceId: () => "receiver-ephemeral",245      confirmSas: vi.fn(async () => true),246      createVaultFromRootKey,247      deriveLocalIdentity: vi.fn(),248      upsertPairedDevice: vi.fn(),249      onError,250    });251    await sourceChannel.receive();252    await sendFrame(sourceChannel, {253      type: "ADOPT_GRANT",254      root_key_hex: "bad",255      vault_id: "00".repeat(32),256      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },257    } as never);258    await receiver;259260    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");261    expect(createVaultFromRootKey).not.toHaveBeenCalled();262  });263264  it("T-A14: vault_id mismatch aborts before vault creation", async () => {265    const [receiverChannel, sourceChannel] = memoryPair();266    const onError = vi.fn();267    const createVaultFromRootKey = vi.fn();268    const receiver = requestAdoption(receiverChannel, {269      deviceName: "Receiver",270      generateDeviceId: () => "receiver-ephemeral",271      confirmSas: vi.fn(async () => true),272      createVaultFromRootKey,273      deriveLocalIdentity: vi.fn(),274      upsertPairedDevice: vi.fn(),275      onError,276    });277    await sourceChannel.receive();278    await sendFrame(sourceChannel, {279      type: "ADOPT_GRANT",280      root_key_hex: "22".repeat(32),281      vault_id: "00".repeat(32),282      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },283    });284    await receiver;285286    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");287    expect(createVaultFromRootKey).not.toHaveBeenCalled();288  });289290  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {291    const [receiverChannel, sourceChannel] = memoryPair();292    const upsert = vi.fn();293    const promise = grantAdoption(sourceChannel, {294      type: "ADOPT_REQUEST",295      device_id: "receiver-ephemeral",296      device_name: "Receiver",297      device_type: "extension",298    }, {299      sourceIdentity: identity("source"),300      rootKeyHex: vi.fn(async () => "33".repeat(32)),301      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),302      license: vi.fn(async () => "PPK-test"),303      onConsent: vi.fn(async () => true),304      upsertPairedDevice: upsert,305    });306    await sendFrame(receiverChannel, {307      type: "ADOPT_CONFIRM",308      confirmed: true,309    });310311    await Promise.race([promise, Promise.resolve()]);312    expect(upsert).not.toHaveBeenCalled();313  });314315  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {316    const [receiverChannel, sourceChannel] = memoryPair();317    const rootKeyHex = "66".repeat(32);318    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));319    const receiverUpsert = vi.fn();320    const onError = vi.fn();321    const receiver = requestAdoption(receiverChannel, {322      deviceName: "Receiver",323      generateDeviceId: () => "receiver-ephemeral",324      confirmSas: vi.fn(async () => true),325      license: vi.fn(async () => "PPK-local"),326      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),327      deriveLocalIdentity: vi.fn(async () => identity("receiver")),328      upsertPairedDevice: receiverUpsert,329      onError,330    });331332    await sourceChannel.receive(); // ADOPT_REQUEST333    await sourceChannel.receive(); // ADOPT_CONFIRM334    await sendFrame(sourceChannel, {335      type: "ADOPT_GRANT",336      root_key_hex: rootKeyHex,337      vault_id: vaultId,338      source: {339        device_id: "source-id",340        device_name: "Source",341        device_type: "extension",342        long_term_public_key_hex: "ab".repeat(32),343      },344    });345    await sourceChannel.receive(); // ADOPT_IDENTITY346    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });347348    await expect(receiver).resolves.toBeNull();349    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");350    expect(receiverUpsert).not.toHaveBeenCalled();351  });352353  it("T-A17: receiver safety policy runs before vault creation", async () => {354    const [receiverChannel, sourceChannel] = memoryPair();355    const rootKeyHex = "77".repeat(32);356    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));357    const createVaultFromRootKey = vi.fn();358    const receiver = requestAdoption(receiverChannel, {359      deviceName: "Receiver",360      generateDeviceId: () => "receiver-ephemeral",361      confirmSas: vi.fn(async () => true),362      license: vi.fn(async () => "PPK-local"),363      validateSource: vi.fn(async () => {364        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");365      }),366      createVaultFromRootKey,367      deriveLocalIdentity: vi.fn(),368      upsertPairedDevice: vi.fn(),369      onError: vi.fn(),370    });371372    await sourceChannel.receive(); // ADOPT_REQUEST373    await sourceChannel.receive(); // ADOPT_CONFIRM374    await sendFrame(sourceChannel, {375      type: "ADOPT_GRANT",376      root_key_hex: rootKeyHex,377      vault_id: vaultId,378      source: {379        device_id: "new-source",380        device_name: "Source",381        device_type: "extension",382        long_term_public_key_hex: "ab".repeat(32),383      },384    });385386    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");387    expect(createVaultFromRootKey).not.toHaveBeenCalled();388  });389390  it("T-A31: does not apply a license if vault creation fails", async () => {391    const [receiverChannel, sourceChannel] = memoryPair();392    const rootKeyHex = "44".repeat(32);393    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));394    const applyLicense = vi.fn();395    const receiver = requestAdoption(receiverChannel, {396      deviceName: "Receiver",397      generateDeviceId: () => "receiver-ephemeral",398      confirmSas: vi.fn(async () => true),399      createVaultFromRootKey: vi.fn(async () => {400        throw new Error("commit_failed");401      }),402      deriveLocalIdentity: vi.fn(),403      upsertPairedDevice: vi.fn(),404      applyLicense,405      onError: vi.fn(),406    });407    await sourceChannel.receive();408    await sendFrame(sourceChannel, {409      type: "ADOPT_GRANT",410      root_key_hex: rootKeyHex,411      vault_id: vaultId,412      license: "PPK-test",413      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },414    });415416    await expect(receiver).rejects.toThrow("commit_failed");417    expect(applyLicense).not.toHaveBeenCalled();418  });419420  it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {421    const [receiverChannel, sourceChannel] = memoryPair();422    const createVaultFromRootKey = vi.fn();423    const receiver = requestAdoption(receiverChannel, {424      deviceName: "Receiver",425      generateDeviceId: () => "receiver-ephemeral",426      confirmSas: vi.fn(async () => true),427      createVaultFromRootKey,428      deriveLocalIdentity: vi.fn(),429      upsertPairedDevice: vi.fn(),430      applyLicense: vi.fn(),431      onError: vi.fn(),432    });433    await sourceChannel.receive();434    await sendFrame(sourceChannel, {435      type: "ADOPT_GRANT",436      root_key_hex: "55".repeat(32),437      vault_id: "99".repeat(32),438      license: "PPK-test",439      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },440    });441442    await receiver;443    expect(createVaultFromRootKey).not.toHaveBeenCalled();444  });445});