vault-adopt.test.ts

23.5 KB · raw

1import { describe, expect, it, vi } from "vitest";2import { computeVaultId } from "./sync-identity";3import {4  grantAdoption,5  isAdoptGrant,6  requestAdoption,7  sendFrame,8  type AdoptRequest,9  type AdoptionReceiverDeps,10  type AdoptionSourceDeps,11} from "./vault-adopt";12import type { PairingTransport } from "./pairing-handshake";1314function memoryPair(): [PairingTransport, PairingTransport] {15  const a: Uint8Array[] = [];16  const b: Uint8Array[] = [];17  const ar: Array<(v: Uint8Array) => void> = [];18  const br: Array<(v: Uint8Array) => void> = [];19  function make(20    inbox: Uint8Array[],21    outbox: Uint8Array[],22    readers: Array<(v: Uint8Array) => void>,23    outReaders: Array<(v: Uint8Array) => void>,24  ): PairingTransport {25    return {26      async send(msg) {27        const reader = outReaders.shift();28        if (reader) reader(msg);29        else outbox.push(msg);30      },31      receive() {32        const msg = inbox.shift();33        if (msg) return Promise.resolve(msg);34        return new Promise((resolve) => readers.push(resolve));35      },36    };37  }38  return [make(a, b, ar, br), make(b, a, br, ar)];39}4041function identity(name: string) {42  return {43    deviceId: `${name}-id`,44    deviceName: name,45    deviceType: "extension" as const,46    longTermPublicKeyHex: "ab".repeat(32),47    signingPublicKeyHex: "cd".repeat(32),48  };49}5051describe("vault adoption sub-protocol", () => {52  it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {53    const [receiverChannel, sourceChannel] = memoryPair();54    const rootKeyHex = "11".repeat(32);55    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));56    const sourceUpsert = vi.fn();57    const receiverUpsert = vi.fn();58    const pairState = {59      rendezvousSecretHex: "42".repeat(32),60      relayUrl: "wss://relay.example.test",61    };62    const receiverDeps: AdoptionReceiverDeps = {63      deviceName: "Receiver",64      generateDeviceId: () => "receiver-ephemeral",65      confirmSas: vi.fn(async () => true),66      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),67      deriveLocalIdentity: vi.fn(async () => identity("receiver")),68      upsertPairedDevice: receiverUpsert,69      applyLicense: vi.fn(),70      pairState,71      onError: vi.fn(),72    };73    const sourceDeps: AdoptionSourceDeps = {74      sourceIdentity: identity("source"),75      rootKeyHex: vi.fn(async () => rootKeyHex),76      vaultId: vi.fn(async () => vaultId),77      license: vi.fn(async () => "PPK-test"),78      onConsent: vi.fn(async () => true),79      upsertPairedDevice: sourceUpsert,80      pairState,81    };8283    await Promise.all([84      requestAdoption(receiverChannel, receiverDeps),85      (async () => {86        const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;87        await grantAdoption(sourceChannel, request, sourceDeps);88      })(),89    ]);9091    expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);92    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({93      deviceId: "source-id",94      ...pairState,95    }));96    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({97      deviceId: "receiver-id",98      ...pairState,99    }));100    expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");101  });102103  it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {104    const [receiverChannel, sourceChannel] = memoryPair();105    const rootKeyHex = "12".repeat(32);106    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));107    const createVault = vi.fn();108    const receiverUpsert = vi.fn();109    const sourceUpsert = vi.fn();110    const onError = vi.fn();111112    const receiver = requestAdoption(receiverChannel, {113      deviceName: "Free receiver",114      generateDeviceId: () => "free-receiver",115      confirmSas: vi.fn(async () => true),116      license: vi.fn(async () => undefined),117      createVaultFromRootKey: createVault,118      deriveLocalIdentity: vi.fn(),119      upsertPairedDevice: receiverUpsert,120      onError,121    });122    const request = JSON.parse(123      new TextDecoder().decode(await sourceChannel.receive()),124    ) as AdoptRequest;125    const source = grantAdoption(sourceChannel, request, {126      sourceIdentity: identity("free-source"),127      rootKeyHex: vi.fn(async () => rootKeyHex),128      vaultId: vi.fn(async () => vaultId),129      license: vi.fn(async () => undefined),130      onConsent: vi.fn(async () => true),131      upsertPairedDevice: sourceUpsert,132    });133134    await expect(receiver).resolves.toBeNull();135    await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");136    expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");137    expect(createVault).not.toHaveBeenCalled();138    expect(receiverUpsert).not.toHaveBeenCalled();139    expect(sourceUpsert).not.toHaveBeenCalled();140  });141142  it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {143    const [receiverChannel, sourceChannel] = memoryPair();144    const rootKeyHex = "13".repeat(32);145    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));146    let sourceLicense: string | undefined;147    const sourceApply = vi.fn(async (license: string) => {148      sourceLicense = license;149    });150151    const receiver = requestAdoption(receiverChannel, {152      deviceName: "Pro receiver",153      generateDeviceId: () => "pro-receiver",154      confirmSas: vi.fn(async () => true),155      license: vi.fn(async () => "PPK-receiver"),156      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),157      deriveLocalIdentity: vi.fn(async () => identity("receiver")),158      upsertPairedDevice: vi.fn(),159      applyLicense: vi.fn(),160      onError: vi.fn(),161    });162    const request = JSON.parse(163      new TextDecoder().decode(await sourceChannel.receive()),164    ) as AdoptRequest;165    const source = grantAdoption(sourceChannel, request, {166      sourceIdentity: identity("free-source"),167      rootKeyHex: vi.fn(async () => rootKeyHex),168      vaultId: vi.fn(async () => vaultId),169      license: vi.fn(async () => sourceLicense),170      applyLicense: sourceApply,171      onConsent: vi.fn(async () => true),172      upsertPairedDevice: vi.fn(),173    });174175    await expect(Promise.all([receiver, source])).resolves.toEqual([176      { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },177      expect.objectContaining({ deviceId: "receiver-id" }),178    ]);179    expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");180  });181182  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {183    const [receiverChannel, sourceChannel] = memoryPair();184    const rootKeyHex = vi.fn(async () => "11".repeat(32));185    const createVaultFromRootKey = vi.fn();186    const receiver = requestAdoption(receiverChannel, {187      deviceName: "Receiver",188      generateDeviceId: () => "receiver-ephemeral",189      confirmSas: vi.fn(async () => false),190      createVaultFromRootKey,191      deriveLocalIdentity: vi.fn(),192      upsertPairedDevice: vi.fn(),193      onError: vi.fn(),194    });195    const request = JSON.parse(196      new TextDecoder().decode(await sourceChannel.receive()),197    ) as AdoptRequest;198    const source = grantAdoption(sourceChannel, request, {199      sourceIdentity: identity("source"),200      rootKeyHex,201      vaultId: vi.fn(),202      onConsent: vi.fn(async () => true),203      upsertPairedDevice: vi.fn(),204    });205206    await expect(receiver).resolves.toBeNull();207    await expect(source).resolves.toBeNull();208    expect(rootKeyHex).not.toHaveBeenCalled();209    expect(createVaultFromRootKey).not.toHaveBeenCalled();210  });211212  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {213    const [receiverChannel, sourceChannel] = memoryPair();214    const createVaultFromRootKey = vi.fn();215    const errors: string[] = [];216    const receiver = requestAdoption(receiverChannel, {217      deviceName: "Receiver",218      generateDeviceId: () => "receiver-ephemeral",219      confirmSas: vi.fn(async () => true),220      createVaultFromRootKey,221      deriveLocalIdentity: vi.fn(),222      upsertPairedDevice: vi.fn(),223      onError: (err) => errors.push(err),224    });225    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;226    await grantAdoption(sourceChannel, request, {227      sourceIdentity: identity("source"),228      rootKeyHex: vi.fn(),229      vaultId: vi.fn(),230      onConsent: vi.fn(async () => false),231      upsertPairedDevice: vi.fn(),232    });233    await Promise.race([receiver, Promise.resolve()]);234235    expect(createVaultFromRootKey).not.toHaveBeenCalled();236    expect(errors).toEqual([]);237  });238239  it("T-A13: invalid root_key_hex aborts gracefully", async () => {240    const [receiverChannel, sourceChannel] = memoryPair();241    const onError = vi.fn();242    const createVaultFromRootKey = vi.fn();243    const receiver = requestAdoption(receiverChannel, {244      deviceName: "Receiver",245      generateDeviceId: () => "receiver-ephemeral",246      confirmSas: vi.fn(async () => true),247      createVaultFromRootKey,248      deriveLocalIdentity: vi.fn(),249      upsertPairedDevice: vi.fn(),250      onError,251    });252    await sourceChannel.receive();253    await sendFrame(sourceChannel, {254      type: "ADOPT_GRANT",255      root_key_hex: "bad",256      vault_id: "00".repeat(32),257      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },258    } as never);259    await receiver;260261    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");262    expect(createVaultFromRootKey).not.toHaveBeenCalled();263  });264265  it("T-A14: vault_id mismatch aborts before vault creation", async () => {266    const [receiverChannel, sourceChannel] = memoryPair();267    const onError = vi.fn();268    const createVaultFromRootKey = vi.fn();269    const receiver = requestAdoption(receiverChannel, {270      deviceName: "Receiver",271      generateDeviceId: () => "receiver-ephemeral",272      confirmSas: vi.fn(async () => true),273      createVaultFromRootKey,274      deriveLocalIdentity: vi.fn(),275      upsertPairedDevice: vi.fn(),276      onError,277    });278    await sourceChannel.receive();279    await sendFrame(sourceChannel, {280      type: "ADOPT_GRANT",281      root_key_hex: "22".repeat(32),282      vault_id: "00".repeat(32),283      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },284    });285    await receiver;286287    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");288    expect(createVaultFromRootKey).not.toHaveBeenCalled();289  });290291  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {292    const [receiverChannel, sourceChannel] = memoryPair();293    const upsert = vi.fn();294    const promise = grantAdoption(sourceChannel, {295      type: "ADOPT_REQUEST",296      device_id: "receiver-ephemeral",297      device_name: "Receiver",298      device_type: "extension",299    }, {300      sourceIdentity: identity("source"),301      rootKeyHex: vi.fn(async () => "33".repeat(32)),302      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),303      license: vi.fn(async () => "PPK-test"),304      onConsent: vi.fn(async () => true),305      upsertPairedDevice: upsert,306    });307    await sendFrame(receiverChannel, {308      type: "ADOPT_CONFIRM",309      confirmed: true,310    });311312    await Promise.race([promise, Promise.resolve()]);313    expect(upsert).not.toHaveBeenCalled();314  });315316  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {317    const [receiverChannel, sourceChannel] = memoryPair();318    const rootKeyHex = "66".repeat(32);319    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));320    const receiverUpsert = vi.fn();321    const onError = vi.fn();322    const receiver = requestAdoption(receiverChannel, {323      deviceName: "Receiver",324      generateDeviceId: () => "receiver-ephemeral",325      confirmSas: vi.fn(async () => true),326      license: vi.fn(async () => "PPK-local"),327      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),328      deriveLocalIdentity: vi.fn(async () => identity("receiver")),329      upsertPairedDevice: receiverUpsert,330      onError,331    });332333    await sourceChannel.receive(); // ADOPT_REQUEST334    await sourceChannel.receive(); // ADOPT_CONFIRM335    await sendFrame(sourceChannel, {336      type: "ADOPT_GRANT",337      root_key_hex: rootKeyHex,338      vault_id: vaultId,339      source: {340        device_id: "source-id",341        device_name: "Source",342        device_type: "extension",343        long_term_public_key_hex: "ab".repeat(32),344      },345    });346    await sourceChannel.receive(); // ADOPT_IDENTITY347    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });348349    await expect(receiver).resolves.toBeNull();350    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");351    expect(receiverUpsert).not.toHaveBeenCalled();352  });353354  it("T-A17: receiver safety policy runs before vault creation", async () => {355    const [receiverChannel, sourceChannel] = memoryPair();356    const rootKeyHex = "77".repeat(32);357    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));358    const createVaultFromRootKey = vi.fn();359    const receiver = requestAdoption(receiverChannel, {360      deviceName: "Receiver",361      generateDeviceId: () => "receiver-ephemeral",362      confirmSas: vi.fn(async () => true),363      license: vi.fn(async () => "PPK-local"),364      validateSource: vi.fn(async () => {365        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");366      }),367      createVaultFromRootKey,368      deriveLocalIdentity: vi.fn(),369      upsertPairedDevice: vi.fn(),370      onError: vi.fn(),371    });372373    await sourceChannel.receive(); // ADOPT_REQUEST374    await sourceChannel.receive(); // ADOPT_CONFIRM375    await sendFrame(sourceChannel, {376      type: "ADOPT_GRANT",377      root_key_hex: rootKeyHex,378      vault_id: vaultId,379      source: {380        device_id: "new-source",381        device_name: "Source",382        device_type: "extension",383        long_term_public_key_hex: "ab".repeat(32),384      },385    });386387    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");388    expect(createVaultFromRootKey).not.toHaveBeenCalled();389  });390391  it("T-A31: does not apply a license if vault creation fails", async () => {392    const [receiverChannel, sourceChannel] = memoryPair();393    const rootKeyHex = "44".repeat(32);394    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));395    const applyLicense = vi.fn();396    const receiver = requestAdoption(receiverChannel, {397      deviceName: "Receiver",398      generateDeviceId: () => "receiver-ephemeral",399      confirmSas: vi.fn(async () => true),400      createVaultFromRootKey: vi.fn(async () => {401        throw new Error("commit_failed");402      }),403      deriveLocalIdentity: vi.fn(),404      upsertPairedDevice: vi.fn(),405      applyLicense,406      onError: vi.fn(),407    });408    await sourceChannel.receive();409    await sendFrame(sourceChannel, {410      type: "ADOPT_GRANT",411      root_key_hex: rootKeyHex,412      vault_id: vaultId,413      license: "PPK-test",414      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },415    });416417    await expect(receiver).rejects.toThrow("commit_failed");418    expect(applyLicense).not.toHaveBeenCalled();419  });420421  it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {422    const [receiverChannel, sourceChannel] = memoryPair();423    const createVaultFromRootKey = vi.fn();424    const receiver = requestAdoption(receiverChannel, {425      deviceName: "Receiver",426      generateDeviceId: () => "receiver-ephemeral",427      confirmSas: vi.fn(async () => true),428      createVaultFromRootKey,429      deriveLocalIdentity: vi.fn(),430      upsertPairedDevice: vi.fn(),431      applyLicense: vi.fn(),432      onError: vi.fn(),433    });434    await sourceChannel.receive();435    await sendFrame(sourceChannel, {436      type: "ADOPT_GRANT",437      root_key_hex: "55".repeat(32),438      vault_id: "99".repeat(32),439      license: "PPK-test",440      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },441    });442443    await receiver;444    expect(createVaultFromRootKey).not.toHaveBeenCalled();445  });446});447448describe("snapshot_capable echo on ADOPT_GRANT", () => {449  function validGrant() {450    return {451      type: "ADOPT_GRANT" as const,452      root_key_hex: "11".repeat(32),453      vault_id: "22".repeat(32),454      license: "PPK-test",455      source: {456        device_id: "src-id",457        device_name: "Source",458        device_type: "extension" as const,459        long_term_public_key_hex: "ab".repeat(32),460      },461    };462  }463464  it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {465    expect(isAdoptGrant(validGrant())).toBe(true);466  });467468  it("isAdoptGrant accepts snapshot_capable: true", () => {469    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);470  });471472  it("isAdoptGrant accepts snapshot_capable: false", () => {473    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);474  });475476  it("isAdoptGrant rejects snapshot_capable with wrong type", () => {477    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);478  });479480  it("grantAdoption echoes snapshot_capable: true when request had it", async () => {481    const [receiverChannel, sourceChannel] = memoryPair();482    const rootKeyHex = "33".repeat(32);483    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));484    const sourceDeps: AdoptionSourceDeps = {485      sourceIdentity: identity("source"),486      rootKeyHex: vi.fn(async () => rootKeyHex),487      vaultId: vi.fn(async () => vaultId),488      license: vi.fn(async () => "PPK-test"),489      onConsent: vi.fn(async () => true),490      upsertPairedDevice: vi.fn(),491    };492493    // Run grantAdoption concurrently with the test driver that feeds protocol frames.494    const grantPromise = grantAdoption(sourceChannel, {495      type: "ADOPT_REQUEST",496      device_id: "recv-id",497      device_name: "Receiver",498      device_type: "extension",499      snapshot_capable: true,500    }, sourceDeps);501502    // grantAdoption: consent=true → reads ADOPT_CONFIRM → sends ADOPT_GRANT → reads ADOPT_IDENTITY503    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });504    const grantBytes = await receiverChannel.receive();505    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));506    expect(grantFrame.type).toBe("ADOPT_GRANT");507    expect(grantFrame.snapshot_capable).toBe(true);508509    // Let grantAdoption complete: send ADOPT_IDENTITY, read ADOPT_COMPLETE, send ADOPT_APPLIED.510    await sendFrame(receiverChannel, {511      type: "ADOPT_IDENTITY",512      device_id: "recv-id",513      device_name: "Receiver",514      device_type: "extension",515      long_term_public_key_hex: "ab".repeat(32),516    });517    await receiverChannel.receive(); // ADOPT_COMPLETE518    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });519    await grantPromise;520  });521522  it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {523    const [receiverChannel, sourceChannel] = memoryPair();524    const rootKeyHex = "44".repeat(32);525    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));526    const sourceDeps: AdoptionSourceDeps = {527      sourceIdentity: identity("source"),528      rootKeyHex: vi.fn(async () => rootKeyHex),529      vaultId: vi.fn(async () => vaultId),530      license: vi.fn(async () => "PPK-test"),531      onConsent: vi.fn(async () => true),532      upsertPairedDevice: vi.fn(),533    };534535    const grantPromise = grantAdoption(sourceChannel, {536      type: "ADOPT_REQUEST",537      device_id: "recv-id",538      device_name: "Receiver",539      device_type: "extension",540      // NO snapshot_capable541    }, sourceDeps);542543    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });544    const grantBytes = await receiverChannel.receive();545    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));546    expect(grantFrame.type).toBe("ADOPT_GRANT");547    expect(grantFrame.snapshot_capable).toBe(false);548549    await sendFrame(receiverChannel, {550      type: "ADOPT_IDENTITY",551      device_id: "recv-id",552      device_name: "Receiver",553      device_type: "extension",554      long_term_public_key_hex: "ab".repeat(32),555    });556    await receiverChannel.receive(); // ADOPT_COMPLETE557    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });558    await grantPromise;559  });560561  it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {562    const [receiverChannel, sourceChannel] = memoryPair();563    const rootKeyHex = "55".repeat(32);564    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));565    const deps: AdoptionReceiverDeps = {566      deviceName: "recv",567      generateDeviceId: () => "recv-id",568      async confirmSas() { return true; },569      async createVaultFromRootKey() { return { vaultId }; },570      async deriveLocalIdentity() {571        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };572      },573      async upsertPairedDevice() {},574      onError() {},575    };576577    const receiverPromise = requestAdoption(receiverChannel, deps);578    // requestAdoption sends: ADOPT_REQUEST, ADOPT_CONFIRM (after SAS auto-accept)579    // Then reads: ADOPT_GRANT, sends: ADOPT_IDENTITY, reads: ADOPT_COMPLETE, sends: ADOPT_APPLIED580    await sourceChannel.receive(); // ADOPT_REQUEST581    await sourceChannel.receive(); // ADOPT_CONFIRM582    await sendFrame(sourceChannel, {583      type: "ADOPT_GRANT",584      root_key_hex: rootKeyHex,585      vault_id: vaultId,586      license: "PPK-test",587      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },588      snapshot_capable: true,589    });590    await sourceChannel.receive(); // ADOPT_IDENTITY591    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });592    await sourceChannel.receive(); // ADOPT_APPLIED593594    const result = await receiverPromise;595    expect(result).not.toBeNull();596    expect(result!.snapshotCapable).toBe(true);597  });598599  it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {600    const [receiverChannel, sourceChannel] = memoryPair();601    const rootKeyHex = "66".repeat(32);602    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));603    const deps: AdoptionReceiverDeps = {604      deviceName: "recv",605      generateDeviceId: () => "recv-id",606      async confirmSas() { return true; },607      async createVaultFromRootKey() { return { vaultId }; },608      async deriveLocalIdentity() {609        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };610      },611      async upsertPairedDevice() {},612      onError() {},613    };614615    const receiverPromise = requestAdoption(receiverChannel, deps);616    await sourceChannel.receive(); // ADOPT_REQUEST617    await sourceChannel.receive(); // ADOPT_CONFIRM618    await sendFrame(sourceChannel, {619      type: "ADOPT_GRANT",620      root_key_hex: rootKeyHex,621      vault_id: vaultId,622      license: "PPK-test",623      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },624      // NO snapshot_capable (pre-C source)625    });626    await sourceChannel.receive(); // ADOPT_IDENTITY627    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });628    await sourceChannel.receive(); // ADOPT_APPLIED629630    const result = await receiverPromise;631    expect(result).not.toBeNull();632    expect(result!.snapshotCapable).toBe(false);633  });634});