vault-adopt.test.ts

33.7 KB · raw

1import { describe, expect, it, vi } from "vitest";2import { computeVaultId } from "./sync-identity";3import {4  grantAdoption,5  isAdoptGrant,6  requestAdoption,7  sendFrame,8  sendVaultSnapshot,9  receiveVaultSnapshot,10  type AdoptRequest,11  type AdoptionReceiverDeps,12  type AdoptionSourceDeps,13} from "./vault-adopt";14import type { PairingTransport } from "./pairing-handshake";1516function memoryPair(): [PairingTransport, PairingTransport] {17  const a: Uint8Array[] = [];18  const b: Uint8Array[] = [];19  const ar: Array<(v: Uint8Array) => void> = [];20  const br: Array<(v: Uint8Array) => void> = [];21  function make(22    inbox: Uint8Array[],23    outbox: Uint8Array[],24    readers: Array<(v: Uint8Array) => void>,25    outReaders: Array<(v: Uint8Array) => void>,26  ): PairingTransport {27    return {28      async send(msg) {29        const reader = outReaders.shift();30        if (reader) reader(msg);31        else outbox.push(msg);32      },33      receive() {34        const msg = inbox.shift();35        if (msg) return Promise.resolve(msg);36        return new Promise((resolve) => readers.push(resolve));37      },38    };39  }40  return [make(a, b, ar, br), make(b, a, br, ar)];41}4243function identity(name: string) {44  return {45    deviceId: `${name}-id`,46    deviceName: name,47    deviceType: "extension" as const,48    longTermPublicKeyHex: "ab".repeat(32),49    signingPublicKeyHex: "cd".repeat(32),50  };51}5253describe("vault adoption sub-protocol", () => {54  it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {55    const [receiverChannel, sourceChannel] = memoryPair();56    const rootKeyHex = "11".repeat(32);57    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));58    const sourceUpsert = vi.fn();59    const receiverUpsert = vi.fn();60    const pairState = {61      rendezvousSecretHex: "42".repeat(32),62      relayUrl: "wss://relay.example.test",63    };64    const receiverDeps: AdoptionReceiverDeps = {65      deviceName: "Receiver",66      generateDeviceId: () => "receiver-ephemeral",67      confirmSas: vi.fn(async () => true),68      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),69      deriveLocalIdentity: vi.fn(async () => identity("receiver")),70      upsertPairedDevice: receiverUpsert,71      applyLicense: vi.fn(),72      pairState,73      onError: vi.fn(),74    };75    const sourceDeps: AdoptionSourceDeps = {76      sourceIdentity: identity("source"),77      rootKeyHex: vi.fn(async () => rootKeyHex),78      vaultId: vi.fn(async () => vaultId),79      license: vi.fn(async () => "PPK-test"),80      onConsent: vi.fn(async () => true),81      upsertPairedDevice: sourceUpsert,82      pairState,83    };8485    await Promise.all([86      requestAdoption(receiverChannel, receiverDeps),87      (async () => {88        const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;89        await grantAdoption(sourceChannel, request, sourceDeps);90      })(),91    ]);9293    expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);94    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({95      deviceId: "source-id",96      ...pairState,97    }));98    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({99      deviceId: "receiver-id",100      ...pairState,101    }));102    expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");103  });104105  it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {106    const [receiverChannel, sourceChannel] = memoryPair();107    const rootKeyHex = "12".repeat(32);108    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));109    const createVault = vi.fn();110    const receiverUpsert = vi.fn();111    const sourceUpsert = vi.fn();112    const onError = vi.fn();113114    const receiver = requestAdoption(receiverChannel, {115      deviceName: "Free receiver",116      generateDeviceId: () => "free-receiver",117      confirmSas: vi.fn(async () => true),118      license: vi.fn(async () => undefined),119      createVaultFromRootKey: createVault,120      deriveLocalIdentity: vi.fn(),121      upsertPairedDevice: receiverUpsert,122      onError,123    });124    const request = JSON.parse(125      new TextDecoder().decode(await sourceChannel.receive()),126    ) as AdoptRequest;127    const source = grantAdoption(sourceChannel, request, {128      sourceIdentity: identity("free-source"),129      rootKeyHex: vi.fn(async () => rootKeyHex),130      vaultId: vi.fn(async () => vaultId),131      license: vi.fn(async () => undefined),132      onConsent: vi.fn(async () => true),133      upsertPairedDevice: sourceUpsert,134    });135136    await expect(receiver).resolves.toBeNull();137    await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");138    expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");139    expect(createVault).not.toHaveBeenCalled();140    expect(receiverUpsert).not.toHaveBeenCalled();141    expect(sourceUpsert).not.toHaveBeenCalled();142  });143144  it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {145    const [receiverChannel, sourceChannel] = memoryPair();146    const rootKeyHex = "13".repeat(32);147    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));148    let sourceLicense: string | undefined;149    const sourceApply = vi.fn(async (license: string) => {150      sourceLicense = license;151    });152153    const receiver = requestAdoption(receiverChannel, {154      deviceName: "Pro receiver",155      generateDeviceId: () => "pro-receiver",156      confirmSas: vi.fn(async () => true),157      license: vi.fn(async () => "PPK-receiver"),158      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),159      deriveLocalIdentity: vi.fn(async () => identity("receiver")),160      upsertPairedDevice: vi.fn(),161      applyLicense: vi.fn(),162      onError: vi.fn(),163    });164    const request = JSON.parse(165      new TextDecoder().decode(await sourceChannel.receive()),166    ) as AdoptRequest;167    const source = grantAdoption(sourceChannel, request, {168      sourceIdentity: identity("free-source"),169      rootKeyHex: vi.fn(async () => rootKeyHex),170      vaultId: vi.fn(async () => vaultId),171      license: vi.fn(async () => sourceLicense),172      applyLicense: sourceApply,173      onConsent: vi.fn(async () => true),174      upsertPairedDevice: vi.fn(),175    });176177    await expect(Promise.all([receiver, source])).resolves.toEqual([178      { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },179      expect.objectContaining({ deviceId: "receiver-id" }),180    ]);181    expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");182  });183184  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {185    const [receiverChannel, sourceChannel] = memoryPair();186    const rootKeyHex = vi.fn(async () => "11".repeat(32));187    const createVaultFromRootKey = vi.fn();188    const receiver = requestAdoption(receiverChannel, {189      deviceName: "Receiver",190      generateDeviceId: () => "receiver-ephemeral",191      confirmSas: vi.fn(async () => false),192      createVaultFromRootKey,193      deriveLocalIdentity: vi.fn(),194      upsertPairedDevice: vi.fn(),195      onError: vi.fn(),196    });197    const request = JSON.parse(198      new TextDecoder().decode(await sourceChannel.receive()),199    ) as AdoptRequest;200    const source = grantAdoption(sourceChannel, request, {201      sourceIdentity: identity("source"),202      rootKeyHex,203      vaultId: vi.fn(),204      onConsent: vi.fn(async () => true),205      upsertPairedDevice: vi.fn(),206    });207208    await expect(receiver).resolves.toBeNull();209    await expect(source).resolves.toBeNull();210    expect(rootKeyHex).not.toHaveBeenCalled();211    expect(createVaultFromRootKey).not.toHaveBeenCalled();212  });213214  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {215    const [receiverChannel, sourceChannel] = memoryPair();216    const createVaultFromRootKey = vi.fn();217    const errors: string[] = [];218    const receiver = requestAdoption(receiverChannel, {219      deviceName: "Receiver",220      generateDeviceId: () => "receiver-ephemeral",221      confirmSas: vi.fn(async () => true),222      createVaultFromRootKey,223      deriveLocalIdentity: vi.fn(),224      upsertPairedDevice: vi.fn(),225      onError: (err) => errors.push(err),226    });227    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;228    await grantAdoption(sourceChannel, request, {229      sourceIdentity: identity("source"),230      rootKeyHex: vi.fn(),231      vaultId: vi.fn(),232      onConsent: vi.fn(async () => false),233      upsertPairedDevice: vi.fn(),234    });235    await Promise.race([receiver, Promise.resolve()]);236237    expect(createVaultFromRootKey).not.toHaveBeenCalled();238    expect(errors).toEqual([]);239  });240241  it("T-A13: invalid root_key_hex aborts gracefully", async () => {242    const [receiverChannel, sourceChannel] = memoryPair();243    const onError = vi.fn();244    const createVaultFromRootKey = vi.fn();245    const receiver = requestAdoption(receiverChannel, {246      deviceName: "Receiver",247      generateDeviceId: () => "receiver-ephemeral",248      confirmSas: vi.fn(async () => true),249      createVaultFromRootKey,250      deriveLocalIdentity: vi.fn(),251      upsertPairedDevice: vi.fn(),252      onError,253    });254    await sourceChannel.receive();255    await sendFrame(sourceChannel, {256      type: "ADOPT_GRANT",257      root_key_hex: "bad",258      vault_id: "00".repeat(32),259      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },260    } as never);261    await receiver;262263    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");264    expect(createVaultFromRootKey).not.toHaveBeenCalled();265  });266267  it("T-A14: vault_id mismatch aborts before vault creation", async () => {268    const [receiverChannel, sourceChannel] = memoryPair();269    const onError = vi.fn();270    const createVaultFromRootKey = vi.fn();271    const receiver = requestAdoption(receiverChannel, {272      deviceName: "Receiver",273      generateDeviceId: () => "receiver-ephemeral",274      confirmSas: vi.fn(async () => true),275      createVaultFromRootKey,276      deriveLocalIdentity: vi.fn(),277      upsertPairedDevice: vi.fn(),278      onError,279    });280    await sourceChannel.receive();281    await sendFrame(sourceChannel, {282      type: "ADOPT_GRANT",283      root_key_hex: "22".repeat(32),284      vault_id: "00".repeat(32),285      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },286    });287    await receiver;288289    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");290    expect(createVaultFromRootKey).not.toHaveBeenCalled();291  });292293  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {294    const [receiverChannel, sourceChannel] = memoryPair();295    const upsert = vi.fn();296    const promise = grantAdoption(sourceChannel, {297      type: "ADOPT_REQUEST",298      device_id: "receiver-ephemeral",299      device_name: "Receiver",300      device_type: "extension",301    }, {302      sourceIdentity: identity("source"),303      rootKeyHex: vi.fn(async () => "33".repeat(32)),304      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),305      license: vi.fn(async () => "PPK-test"),306      onConsent: vi.fn(async () => true),307      upsertPairedDevice: upsert,308    });309    await sendFrame(receiverChannel, {310      type: "ADOPT_CONFIRM",311      confirmed: true,312    });313314    await Promise.race([promise, Promise.resolve()]);315    expect(upsert).not.toHaveBeenCalled();316  });317318  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {319    const [receiverChannel, sourceChannel] = memoryPair();320    const rootKeyHex = "66".repeat(32);321    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));322    const receiverUpsert = vi.fn();323    const onError = vi.fn();324    const receiver = requestAdoption(receiverChannel, {325      deviceName: "Receiver",326      generateDeviceId: () => "receiver-ephemeral",327      confirmSas: vi.fn(async () => true),328      license: vi.fn(async () => "PPK-local"),329      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),330      deriveLocalIdentity: vi.fn(async () => identity("receiver")),331      upsertPairedDevice: receiverUpsert,332      onError,333    });334335    await sourceChannel.receive(); // ADOPT_REQUEST336    await sourceChannel.receive(); // ADOPT_CONFIRM337    await sendFrame(sourceChannel, {338      type: "ADOPT_GRANT",339      root_key_hex: rootKeyHex,340      vault_id: vaultId,341      source: {342        device_id: "source-id",343        device_name: "Source",344        device_type: "extension",345        long_term_public_key_hex: "ab".repeat(32),346      },347    });348    await sourceChannel.receive(); // ADOPT_IDENTITY349    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });350351    await expect(receiver).resolves.toBeNull();352    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");353    expect(receiverUpsert).not.toHaveBeenCalled();354  });355356  it("T-A17: receiver safety policy runs before vault creation", async () => {357    const [receiverChannel, sourceChannel] = memoryPair();358    const rootKeyHex = "77".repeat(32);359    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));360    const createVaultFromRootKey = vi.fn();361    const receiver = requestAdoption(receiverChannel, {362      deviceName: "Receiver",363      generateDeviceId: () => "receiver-ephemeral",364      confirmSas: vi.fn(async () => true),365      license: vi.fn(async () => "PPK-local"),366      validateSource: vi.fn(async () => {367        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");368      }),369      createVaultFromRootKey,370      deriveLocalIdentity: vi.fn(),371      upsertPairedDevice: vi.fn(),372      onError: vi.fn(),373    });374375    await sourceChannel.receive(); // ADOPT_REQUEST376    await sourceChannel.receive(); // ADOPT_CONFIRM377    await sendFrame(sourceChannel, {378      type: "ADOPT_GRANT",379      root_key_hex: rootKeyHex,380      vault_id: vaultId,381      source: {382        device_id: "new-source",383        device_name: "Source",384        device_type: "extension",385        long_term_public_key_hex: "ab".repeat(32),386      },387    });388389    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");390    expect(createVaultFromRootKey).not.toHaveBeenCalled();391  });392393  it("T-A31: does not apply a license if vault creation fails", async () => {394    const [receiverChannel, sourceChannel] = memoryPair();395    const rootKeyHex = "44".repeat(32);396    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));397    const applyLicense = vi.fn();398    const receiver = requestAdoption(receiverChannel, {399      deviceName: "Receiver",400      generateDeviceId: () => "receiver-ephemeral",401      confirmSas: vi.fn(async () => true),402      createVaultFromRootKey: vi.fn(async () => {403        throw new Error("commit_failed");404      }),405      deriveLocalIdentity: vi.fn(),406      upsertPairedDevice: vi.fn(),407      applyLicense,408      onError: vi.fn(),409    });410    await sourceChannel.receive();411    await sendFrame(sourceChannel, {412      type: "ADOPT_GRANT",413      root_key_hex: rootKeyHex,414      vault_id: vaultId,415      license: "PPK-test",416      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },417    });418419    await expect(receiver).rejects.toThrow("commit_failed");420    expect(applyLicense).not.toHaveBeenCalled();421  });422423  it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {424    const [receiverChannel, sourceChannel] = memoryPair();425    const createVaultFromRootKey = vi.fn();426    const receiver = requestAdoption(receiverChannel, {427      deviceName: "Receiver",428      generateDeviceId: () => "receiver-ephemeral",429      confirmSas: vi.fn(async () => true),430      createVaultFromRootKey,431      deriveLocalIdentity: vi.fn(),432      upsertPairedDevice: vi.fn(),433      applyLicense: vi.fn(),434      onError: vi.fn(),435    });436    await sourceChannel.receive();437    await sendFrame(sourceChannel, {438      type: "ADOPT_GRANT",439      root_key_hex: "55".repeat(32),440      vault_id: "99".repeat(32),441      license: "PPK-test",442      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },443    });444445    await receiver;446    expect(createVaultFromRootKey).not.toHaveBeenCalled();447  });448});449450describe("snapshot_capable echo on ADOPT_GRANT", () => {451  function validGrant() {452    return {453      type: "ADOPT_GRANT" as const,454      root_key_hex: "11".repeat(32),455      vault_id: "22".repeat(32),456      license: "PPK-test",457      source: {458        device_id: "src-id",459        device_name: "Source",460        device_type: "extension" as const,461        long_term_public_key_hex: "ab".repeat(32),462      },463    };464  }465466  it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {467    expect(isAdoptGrant(validGrant())).toBe(true);468  });469470  it("isAdoptGrant accepts snapshot_capable: true", () => {471    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);472  });473474  it("isAdoptGrant accepts snapshot_capable: false", () => {475    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);476  });477478  it("isAdoptGrant rejects snapshot_capable with wrong type", () => {479    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);480  });481482  it("grantAdoption echoes snapshot_capable: true when request had it", async () => {483    const [receiverChannel, sourceChannel] = memoryPair();484    const rootKeyHex = "33".repeat(32);485    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));486    const sourceDeps: AdoptionSourceDeps = {487      sourceIdentity: identity("source"),488      rootKeyHex: vi.fn(async () => rootKeyHex),489      vaultId: vi.fn(async () => vaultId),490      license: vi.fn(async () => "PPK-test"),491      onConsent: vi.fn(async () => true),492      upsertPairedDevice: vi.fn(),493    };494495    // Run grantAdoption concurrently with the test driver that feeds protocol frames.496    const grantPromise = grantAdoption(sourceChannel, {497      type: "ADOPT_REQUEST",498      device_id: "recv-id",499      device_name: "Receiver",500      device_type: "extension",501      snapshot_capable: true,502    }, sourceDeps);503504    // grantAdoption: consent=true → reads ADOPT_CONFIRM → sends ADOPT_GRANT → reads ADOPT_IDENTITY505    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });506    const grantBytes = await receiverChannel.receive();507    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));508    expect(grantFrame.type).toBe("ADOPT_GRANT");509    expect(grantFrame.snapshot_capable).toBe(true);510511    // Let grantAdoption complete: send ADOPT_IDENTITY, read ADOPT_COMPLETE, send ADOPT_APPLIED.512    await sendFrame(receiverChannel, {513      type: "ADOPT_IDENTITY",514      device_id: "recv-id",515      device_name: "Receiver",516      device_type: "extension",517      long_term_public_key_hex: "ab".repeat(32),518    });519    await receiverChannel.receive(); // ADOPT_COMPLETE520    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });521    await grantPromise;522  });523524  it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {525    const [receiverChannel, sourceChannel] = memoryPair();526    const rootKeyHex = "44".repeat(32);527    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));528    const sourceDeps: AdoptionSourceDeps = {529      sourceIdentity: identity("source"),530      rootKeyHex: vi.fn(async () => rootKeyHex),531      vaultId: vi.fn(async () => vaultId),532      license: vi.fn(async () => "PPK-test"),533      onConsent: vi.fn(async () => true),534      upsertPairedDevice: vi.fn(),535    };536537    const grantPromise = grantAdoption(sourceChannel, {538      type: "ADOPT_REQUEST",539      device_id: "recv-id",540      device_name: "Receiver",541      device_type: "extension",542      // NO snapshot_capable543    }, sourceDeps);544545    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });546    const grantBytes = await receiverChannel.receive();547    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));548    expect(grantFrame.type).toBe("ADOPT_GRANT");549    expect(grantFrame.snapshot_capable).toBe(false);550551    await sendFrame(receiverChannel, {552      type: "ADOPT_IDENTITY",553      device_id: "recv-id",554      device_name: "Receiver",555      device_type: "extension",556      long_term_public_key_hex: "ab".repeat(32),557    });558    await receiverChannel.receive(); // ADOPT_COMPLETE559    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });560    await grantPromise;561  });562563  it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {564    const [receiverChannel, sourceChannel] = memoryPair();565    const rootKeyHex = "55".repeat(32);566    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));567    const deps: AdoptionReceiverDeps = {568      deviceName: "recv",569      generateDeviceId: () => "recv-id",570      async confirmSas() { return true; },571      async createVaultFromRootKey() { return { vaultId }; },572      async deriveLocalIdentity() {573        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };574      },575      async upsertPairedDevice() {},576      onError() {},577    };578579    const receiverPromise = requestAdoption(receiverChannel, deps);580    // requestAdoption sends: ADOPT_REQUEST, ADOPT_CONFIRM (after SAS auto-accept)581    // Then reads: ADOPT_GRANT, sends: ADOPT_IDENTITY, reads: ADOPT_COMPLETE, sends: ADOPT_APPLIED582    await sourceChannel.receive(); // ADOPT_REQUEST583    await sourceChannel.receive(); // ADOPT_CONFIRM584    await sendFrame(sourceChannel, {585      type: "ADOPT_GRANT",586      root_key_hex: rootKeyHex,587      vault_id: vaultId,588      license: "PPK-test",589      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },590      snapshot_capable: true,591    });592    await sourceChannel.receive(); // ADOPT_IDENTITY593    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });594    await sourceChannel.receive(); // ADOPT_APPLIED595596    const result = await receiverPromise;597    expect(result).not.toBeNull();598    expect(result!.snapshotCapable).toBe(true);599  });600601  it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {602    const [receiverChannel, sourceChannel] = memoryPair();603    const rootKeyHex = "66".repeat(32);604    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));605    const deps: AdoptionReceiverDeps = {606      deviceName: "recv",607      generateDeviceId: () => "recv-id",608      async confirmSas() { return true; },609      async createVaultFromRootKey() { return { vaultId }; },610      async deriveLocalIdentity() {611        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };612      },613      async upsertPairedDevice() {},614      onError() {},615    };616617    const receiverPromise = requestAdoption(receiverChannel, deps);618    await sourceChannel.receive(); // ADOPT_REQUEST619    await sourceChannel.receive(); // ADOPT_CONFIRM620    await sendFrame(sourceChannel, {621      type: "ADOPT_GRANT",622      root_key_hex: rootKeyHex,623      vault_id: vaultId,624      license: "PPK-test",625      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },626      // NO snapshot_capable (pre-C source)627    });628    await sourceChannel.receive(); // ADOPT_IDENTITY629    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });630    await sourceChannel.receive(); // ADOPT_APPLIED631632    const result = await receiverPromise;633    expect(result).not.toBeNull();634    expect(result!.snapshotCapable).toBe(false);635  });636637  // T-A33: post-fix regression for the SNAPSHOT_FRAME_WIRE_CEILING (768 KiB)638  // bug. Before v4.6.4, duress rode the entries final chunk, so an entry +639  // duress whose combined raw exceeded 768 KiB threw640  // ADOPT_SNAPSHOT_CHUNK_TOO_LARGE on receive. The frame-ceiling fix moves641  // duress onto its own frame after the entries chunks; this case asserts the642  // root-cause fix rather than just the new wire path.643  it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => {644    const [source, receiver] = memoryPair();645    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };646    const tombstones: Record<string, number> = { "dead-0": 1 };647    // 320 KiB of ciphertext JSON ("cc".repeat(320 * 1024) is ~640 KiB of JSON648    // text — the receiver treats duress_slot as opaque and concatenates it649    // raw; ~640 KiB JSON pushes combined raw over 768 KiB once the entry and650    // envelope are added). Pre-v4.6.4 this duress rode the entries final651    // chunk and pushed that frame over the ceiling; post-fix the duress652    // frame is sent on its own and stays under the ceiling.653    const duressJson = JSON.stringify({654      version: 1,655      salt: "aa".repeat(16),656      nonce: "bb".repeat(12),657      ciphertext: "cc".repeat(320 * 1024),658      tag: "dd".repeat(16),659    });660661    const [result] = await Promise.all([662      receiveVaultSnapshot(receiver),663      sendVaultSnapshot(source, entries, tombstones, duressJson),664    ]);665666    expect(result.entries).toEqual(entries);667    expect(result.tombstones).toEqual(tombstones);668    expect(result.duressSlot).toBe(duressJson);669  });670671  // T-A34: structural proof of the duress-on-separate-frame wire change.672  // Duress must leave the entries chunk so the entries frame stays under the673  // 768 KiB ceiling, even when the combined payload would exceed it.674  it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {675    const [source] = memoryPair();676    const observed: Array<{ size: number; hasDuress: boolean; entriesKeys: number }> = [];677    const probe: PairingTransport = {678      async send(msg) {679        const f = JSON.parse(new TextDecoder().decode(msg)) as Record<string, unknown>;680        observed.push({681          size: msg.length,682          hasDuress: typeof f.duress_slot === "string",683          entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,684        });685        return source.send(msg);686      },687      receive() { return source.receive(); },688    };689    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };690    const duressJson = "y".repeat(320 * 1024);691    const CEILING = 768 * 1024;692693    await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson);694695    expect(observed.length).toBe(2);696    // Entries frame: no duress, one entries key, well under 768 KiB.697    expect(observed[0].hasDuress).toBe(false);698    expect(observed[0].entriesKeys).toBe(1);699    expect(observed[0].size).toBeLessThan(CEILING);700    // Duress frame: duress present, no entries, well under 768 KiB.701    expect(observed[1].hasDuress).toBe(true);702    expect(observed[1].entriesKeys).toBe(0);703    expect(observed[1].size).toBeLessThan(CEILING);704    // Combined raw exceeds the ceiling, which is the point of the split.705    expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);706  });707708  // T-A35: backward compat — a pre-v4.6.4 sender (duress on the entries709  // final chunk) still decodes correctly. The receiver's per-chunk710  // Object.assign (entries / tombstones) plus duress-slot capture tolerates711  // both shapes.712  it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => {713    const [source, receiver] = memoryPair();714    const duressJson = JSON.stringify({715      version: 1,716      salt: "aa".repeat(16),717      nonce: "bb".repeat(12),718      ciphertext: "cc".repeat(32),719      tag: "dd".repeat(16),720    });721    // Manually construct the legacy wire shape: a single chunk with both722    // the entries, the tombstones, AND duress_slot on it.723    await sendFrame(source, {724      type: "ADOPT_SNAPSHOT",725      chunk_index: 0,726      chunk_count: 1,727      entries: { "id-0": "ciphertext" },728      tombstones: { "dead-0": 42 },729      duress_slot: duressJson,730    });731    const result = await receiveVaultSnapshot(receiver);732    expect(result.entries).toEqual({ "id-0": "ciphertext" });733    expect(result.tombstones).toEqual({ "dead-0": 42 });734    expect(result.duressSlot).toBe(duressJson);735  });736737  // T-A36: cap boundary inclusive. With 199 entries and a duress slot, the738  // sender must fit everything within MAX_SNAPSHOT_CHUNKS = 200 (199 entries739  // + 1 duress = 200 frames). Duress MUST arrive intact — sync does not740  // carry duress, so the adopted device would permanently lack its duress741  // vault otherwise. Entries are 40 KiB so the ~8 MiB total also fits the742  // PB-26-206 10 MiB aggregate budget.743  it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {744    const [source, receiver] = memoryPair();745    const duressJson = JSON.stringify({746      version: 1,747      salt: "aa".repeat(16),748      nonce: "bb".repeat(12),749      ciphertext: "cc".repeat(32),750      tag: "dd".repeat(16),751    });752    const big = "x".repeat(40 * 1024);753    const entries: Record<string, string> = {};754    for (let i = 0; i < 199; i++) entries[`id-${i}`] = big;755    // First sanity: 199 entries needs multiple chunks (each ~40 KiB entry756    // packs ~12 per 512 KiB chunk). Verify the chunking math is sane before757    // asserting the duress-reservation behavior.758    const [result] = await Promise.all([759      receiveVaultSnapshot(receiver),760      sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),761    ]);762    expect(result.entries).toEqual(entries);763    expect(result.tombstones).toEqual({ "dead-0": 1 });764    expect(result.duressSlot).toBe(duressJson);765  });766767  // T-A37: PB-26-206 supersedes the old chunk-cap truncation. An over-budget768  // snapshot is never partially sent: the sender preflights with the same769  // aggregate helper the receiver enforces and throws before the first frame,770  // so the caller falls through to the regular sync pipeline instead of771  // sending a truncated snapshot.772  it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => {773    const [source] = memoryPair();774    let frames = 0;775    const countingSource: PairingTransport = {776      async send(msg) {777        frames++;778        return source.send(msg);779      },780      receive() { return source.receive(); },781    };782    const duressJson = JSON.stringify({783      version: 1,784      salt: "aa".repeat(16),785      nonce: "bb".repeat(12),786      ciphertext: "cc".repeat(32),787      tag: "dd".repeat(16),788    });789    // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.790    const big = "x".repeat(60 * 1024);791    const entries: Record<string, string> = {};792    for (let i = 0; i < 200; i++) entries[`id-${i}`] = big;793    await expect(794      sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson),795    ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");796    expect(frames).toBe(0);797  });798799  // T-A38: the single test that proves the *root-cause fix* for the frame800  // ceiling bug, not just the new wire path. Combines the high-level801  // end-to-end success assertion (T-A33) with the structural proof (T-A34):802  // a payload whose combined raw exceeds the 768 KiB wire ceiling succeeds803  // because the duress slot rides its own frame instead of piggybacking on804  // the entries final chunk. Pre-v4.6.4, duress counted against the entries805  // frame, so a single frame would exceed SNAPSHOT_FRAME_WIRE_CEILING and the806  // receiver would reject the whole snapshot with ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.807  it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => {808    const [source, receiver] = memoryPair();809    // Single entry that is ~450 KiB on its own; combined with a ~640 KiB810    // duress JSON, the total payload exceeds the 768 KiB wire ceiling.811    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };812    const tombstones: Record<string, number> = { "dead-0": 1 };813    const duressJson = JSON.stringify({814      version: 1,815      salt: "aa".repeat(16),816      nonce: "bb".repeat(12),817      ciphertext: "cc".repeat(320 * 1024),818      tag: "dd".repeat(16),819    });820    const CEILING = 768 * 1024;821822    // Probe the wire so we can assert the structural shape alongside the823    // end-to-end success.824    const observed: Array<{ size: number; hasDuress: boolean; entriesKeys: number }> = [];825    const probe: PairingTransport = {826      async send(msg) {827        const f = JSON.parse(new TextDecoder().decode(msg)) as Record<string, unknown>;828        observed.push({829          size: msg.length,830          hasDuress: typeof f.duress_slot === "string",831          entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,832        });833        return source.send(msg);834      },835      receive() { return source.receive(); },836    };837838    const [result] = await Promise.all([839      receiveVaultSnapshot(receiver),840      sendVaultSnapshot(probe, entries, tombstones, duressJson),841    ]);842843    // High-level success: the receiver accepts the >768 KiB combined payload.844    expect(result.entries).toEqual(entries);845    expect(result.tombstones).toEqual(tombstones);846    expect(result.duressSlot).toBe(duressJson);847848    // Root-cause proof: duress is on its own frame, not on the entries frame.849    // Every frame stays under the ceiling; only the combined raw exceeds it.850    expect(observed.length).toBe(2);851    expect(observed[0].hasDuress).toBe(false);852    expect(observed[0].entriesKeys).toBe(1);853    expect(observed[0].size).toBeLessThan(CEILING);854    expect(observed[1].hasDuress).toBe(true);855    expect(observed[1].entriesKeys).toBe(0);856    expect(observed[1].size).toBeLessThan(CEILING);857    const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({858      type: "ADOPT_SNAPSHOT",859      chunk_index: 0,860      chunk_count: 1,861      entries,862      tombstones,863      duress_slot: duressJson,864    })).length;865    expect(legacyCombinedSize).toBeGreaterThan(CEILING);866  });867});