vault-adopt.test.ts

37.0 KB · raw

1import { describe, expect, it, vi } from "vitest";2import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding";3import { computeVaultId } from "./sync-identity";4import {5  grantAdoption,6  isAdoptGrant,7  requestAdoption,8  sendFrame,9  sendVaultSnapshot,10  receiveVaultSnapshot,11  type AdoptRequest,12  type AdoptionReceiverDeps,13  type AdoptionSourceDeps,14} from "./vault-adopt";15import type { PairingTransport } from "./pairing-handshake";1617function memoryPair(): [PairingTransport, PairingTransport] {18  const a: Uint8Array[] = [];19  const b: Uint8Array[] = [];20  const ar: Array<(v: Uint8Array) => void> = [];21  const br: Array<(v: Uint8Array) => void> = [];22  function make(23    inbox: Uint8Array[],24    outbox: Uint8Array[],25    readers: Array<(v: Uint8Array) => void>,26    outReaders: Array<(v: Uint8Array) => void>,27  ): PairingTransport {28    return {29      async send(msg) {30        const reader = outReaders.shift();31        if (reader) reader(msg);32        else outbox.push(msg);33      },34      receive() {35        const msg = inbox.shift();36        if (msg) return Promise.resolve(msg);37        return new Promise((resolve) => readers.push(resolve));38      },39    };40  }41  return [make(a, b, ar, br), make(b, a, br, ar)];42}4344function identity(name: string) {45  return {46    deviceId: `${name}-id`,47    deviceName: name,48    deviceType: "extension" as const,49    longTermPublicKeyHex: "ab".repeat(32),50    signingPublicKeyHex: "cd".repeat(32),51  };52}5354/** Snapshot frames are padded on the wire (PB-26-604), so hand-built ones55 *  must go through padSyncFrame exactly as sendVaultSnapshot does. */56function sendSnapshotFrame(channel: PairingTransport, frame: unknown): Promise<void> {57  return channel.send(padSyncFrame(new TextEncoder().encode(JSON.stringify(frame))));58}5960describe("vault adoption sub-protocol", () => {61  it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {62    const [receiverChannel, sourceChannel] = memoryPair();63    const rootKeyHex = "11".repeat(32);64    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));65    const sourceUpsert = vi.fn();66    const receiverUpsert = vi.fn();67    const pairState = {68      rendezvousSecretHex: "42".repeat(32),69      relayUrl: "wss://relay.example.test",70    };71    const receiverDeps: AdoptionReceiverDeps = {72      deviceName: "Receiver",73      generateDeviceId: () => "receiver-ephemeral",74      confirmSas: vi.fn(async () => true),75      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),76      deriveLocalIdentity: vi.fn(async () => identity("receiver")),77      upsertPairedDevice: receiverUpsert,78      applyLicense: vi.fn(),79      pairState,80      onError: vi.fn(),81    };82    const sourceDeps: AdoptionSourceDeps = {83      sourceIdentity: identity("source"),84      rootKeyHex: vi.fn(async () => rootKeyHex),85      vaultId: vi.fn(async () => vaultId),86      license: vi.fn(async () => "PPK-test"),87      onConsent: vi.fn(async () => true),88      upsertPairedDevice: sourceUpsert,89      pairState,90    };9192    await Promise.all([93      requestAdoption(receiverChannel, receiverDeps),94      (async () => {95        const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;96        await grantAdoption(sourceChannel, request, sourceDeps);97      })(),98    ]);99100    expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);101    expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({102      deviceId: "source-id",103      ...pairState,104    }));105    expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({106      deviceId: "receiver-id",107      ...pairState,108    }));109    expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");110  });111112  it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {113    const [receiverChannel, sourceChannel] = memoryPair();114    const rootKeyHex = "12".repeat(32);115    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));116    const createVault = vi.fn();117    const receiverUpsert = vi.fn();118    const sourceUpsert = vi.fn();119    const onError = vi.fn();120121    const receiver = requestAdoption(receiverChannel, {122      deviceName: "Free receiver",123      generateDeviceId: () => "free-receiver",124      confirmSas: vi.fn(async () => true),125      license: vi.fn(async () => undefined),126      createVaultFromRootKey: createVault,127      deriveLocalIdentity: vi.fn(),128      upsertPairedDevice: receiverUpsert,129      onError,130    });131    const request = JSON.parse(132      new TextDecoder().decode(await sourceChannel.receive()),133    ) as AdoptRequest;134    const source = grantAdoption(sourceChannel, request, {135      sourceIdentity: identity("free-source"),136      rootKeyHex: vi.fn(async () => rootKeyHex),137      vaultId: vi.fn(async () => vaultId),138      license: vi.fn(async () => undefined),139      onConsent: vi.fn(async () => true),140      upsertPairedDevice: sourceUpsert,141    });142143    await expect(receiver).resolves.toBeNull();144    await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");145    expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");146    expect(createVault).not.toHaveBeenCalled();147    expect(receiverUpsert).not.toHaveBeenCalled();148    expect(sourceUpsert).not.toHaveBeenCalled();149  });150151  it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {152    const [receiverChannel, sourceChannel] = memoryPair();153    const rootKeyHex = "13".repeat(32);154    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));155    let sourceLicense: string | undefined;156    const sourceApply = vi.fn(async (license: string) => {157      sourceLicense = license;158    });159160    const receiver = requestAdoption(receiverChannel, {161      deviceName: "Pro receiver",162      generateDeviceId: () => "pro-receiver",163      confirmSas: vi.fn(async () => true),164      license: vi.fn(async () => "PPK-receiver"),165      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),166      deriveLocalIdentity: vi.fn(async () => identity("receiver")),167      upsertPairedDevice: vi.fn(),168      applyLicense: vi.fn(),169      onError: vi.fn(),170    });171    const request = JSON.parse(172      new TextDecoder().decode(await sourceChannel.receive()),173    ) as AdoptRequest;174    const source = grantAdoption(sourceChannel, request, {175      sourceIdentity: identity("free-source"),176      rootKeyHex: vi.fn(async () => rootKeyHex),177      vaultId: vi.fn(async () => vaultId),178      license: vi.fn(async () => sourceLicense),179      applyLicense: sourceApply,180      onConsent: vi.fn(async () => true),181      upsertPairedDevice: vi.fn(),182    });183184    await expect(Promise.all([receiver, source])).resolves.toEqual([185      { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },186      expect.objectContaining({ deviceId: "receiver-id" }),187    ]);188    expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");189  });190191  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {192    const [receiverChannel, sourceChannel] = memoryPair();193    const rootKeyHex = vi.fn(async () => "11".repeat(32));194    const createVaultFromRootKey = vi.fn();195    const receiver = requestAdoption(receiverChannel, {196      deviceName: "Receiver",197      generateDeviceId: () => "receiver-ephemeral",198      confirmSas: vi.fn(async () => false),199      createVaultFromRootKey,200      deriveLocalIdentity: vi.fn(),201      upsertPairedDevice: vi.fn(),202      onError: vi.fn(),203    });204    const request = JSON.parse(205      new TextDecoder().decode(await sourceChannel.receive()),206    ) as AdoptRequest;207    const source = grantAdoption(sourceChannel, request, {208      sourceIdentity: identity("source"),209      rootKeyHex,210      vaultId: vi.fn(),211      onConsent: vi.fn(async () => true),212      upsertPairedDevice: vi.fn(),213    });214215    await expect(receiver).resolves.toBeNull();216    await expect(source).resolves.toBeNull();217    expect(rootKeyHex).not.toHaveBeenCalled();218    expect(createVaultFromRootKey).not.toHaveBeenCalled();219  });220221  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {222    const [receiverChannel, sourceChannel] = memoryPair();223    const createVaultFromRootKey = vi.fn();224    const errors: string[] = [];225    const receiver = requestAdoption(receiverChannel, {226      deviceName: "Receiver",227      generateDeviceId: () => "receiver-ephemeral",228      confirmSas: vi.fn(async () => true),229      createVaultFromRootKey,230      deriveLocalIdentity: vi.fn(),231      upsertPairedDevice: vi.fn(),232      onError: (err) => errors.push(err),233    });234    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;235    await grantAdoption(sourceChannel, request, {236      sourceIdentity: identity("source"),237      rootKeyHex: vi.fn(),238      vaultId: vi.fn(),239      onConsent: vi.fn(async () => false),240      upsertPairedDevice: vi.fn(),241    });242    await Promise.race([receiver, Promise.resolve()]);243244    expect(createVaultFromRootKey).not.toHaveBeenCalled();245    expect(errors).toEqual([]);246  });247248  it("T-A13: invalid root_key_hex aborts gracefully", async () => {249    const [receiverChannel, sourceChannel] = memoryPair();250    const onError = vi.fn();251    const createVaultFromRootKey = vi.fn();252    const receiver = requestAdoption(receiverChannel, {253      deviceName: "Receiver",254      generateDeviceId: () => "receiver-ephemeral",255      confirmSas: vi.fn(async () => true),256      createVaultFromRootKey,257      deriveLocalIdentity: vi.fn(),258      upsertPairedDevice: vi.fn(),259      onError,260    });261    await sourceChannel.receive();262    await sendFrame(sourceChannel, {263      type: "ADOPT_GRANT",264      root_key_hex: "bad",265      vault_id: "00".repeat(32),266      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },267    } as never);268    await receiver;269270    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");271    expect(createVaultFromRootKey).not.toHaveBeenCalled();272  });273274  it("T-A14: vault_id mismatch aborts before vault creation", async () => {275    const [receiverChannel, sourceChannel] = memoryPair();276    const onError = vi.fn();277    const createVaultFromRootKey = vi.fn();278    const receiver = requestAdoption(receiverChannel, {279      deviceName: "Receiver",280      generateDeviceId: () => "receiver-ephemeral",281      confirmSas: vi.fn(async () => true),282      createVaultFromRootKey,283      deriveLocalIdentity: vi.fn(),284      upsertPairedDevice: vi.fn(),285      onError,286    });287    await sourceChannel.receive();288    await sendFrame(sourceChannel, {289      type: "ADOPT_GRANT",290      root_key_hex: "22".repeat(32),291      vault_id: "00".repeat(32),292      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },293    });294    await receiver;295296    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");297    expect(createVaultFromRootKey).not.toHaveBeenCalled();298  });299300  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {301    const [receiverChannel, sourceChannel] = memoryPair();302    const upsert = vi.fn();303    const promise = grantAdoption(sourceChannel, {304      type: "ADOPT_REQUEST",305      device_id: "receiver-ephemeral",306      device_name: "Receiver",307      device_type: "extension",308    }, {309      sourceIdentity: identity("source"),310      rootKeyHex: vi.fn(async () => "33".repeat(32)),311      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),312      license: vi.fn(async () => "PPK-test"),313      onConsent: vi.fn(async () => true),314      upsertPairedDevice: upsert,315    });316    await sendFrame(receiverChannel, {317      type: "ADOPT_CONFIRM",318      confirmed: true,319    });320321    await Promise.race([promise, Promise.resolve()]);322    expect(upsert).not.toHaveBeenCalled();323  });324325  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {326    const [receiverChannel, sourceChannel] = memoryPair();327    const rootKeyHex = "66".repeat(32);328    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));329    const receiverUpsert = vi.fn();330    const onError = vi.fn();331    const receiver = requestAdoption(receiverChannel, {332      deviceName: "Receiver",333      generateDeviceId: () => "receiver-ephemeral",334      confirmSas: vi.fn(async () => true),335      license: vi.fn(async () => "PPK-local"),336      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),337      deriveLocalIdentity: vi.fn(async () => identity("receiver")),338      upsertPairedDevice: receiverUpsert,339      onError,340    });341342    await sourceChannel.receive(); // ADOPT_REQUEST343    await sourceChannel.receive(); // ADOPT_CONFIRM344    await sendFrame(sourceChannel, {345      type: "ADOPT_GRANT",346      root_key_hex: rootKeyHex,347      vault_id: vaultId,348      source: {349        device_id: "source-id",350        device_name: "Source",351        device_type: "extension",352        long_term_public_key_hex: "ab".repeat(32),353      },354    });355    await sourceChannel.receive(); // ADOPT_IDENTITY356    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });357358    await expect(receiver).resolves.toBeNull();359    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");360    expect(receiverUpsert).not.toHaveBeenCalled();361  });362363  it("T-A17: receiver safety policy runs before vault creation", async () => {364    const [receiverChannel, sourceChannel] = memoryPair();365    const rootKeyHex = "77".repeat(32);366    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));367    const createVaultFromRootKey = vi.fn();368    const receiver = requestAdoption(receiverChannel, {369      deviceName: "Receiver",370      generateDeviceId: () => "receiver-ephemeral",371      confirmSas: vi.fn(async () => true),372      license: vi.fn(async () => "PPK-local"),373      validateSource: vi.fn(async () => {374        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");375      }),376      createVaultFromRootKey,377      deriveLocalIdentity: vi.fn(),378      upsertPairedDevice: vi.fn(),379      onError: vi.fn(),380    });381382    await sourceChannel.receive(); // ADOPT_REQUEST383    await sourceChannel.receive(); // ADOPT_CONFIRM384    await sendFrame(sourceChannel, {385      type: "ADOPT_GRANT",386      root_key_hex: rootKeyHex,387      vault_id: vaultId,388      source: {389        device_id: "new-source",390        device_name: "Source",391        device_type: "extension",392        long_term_public_key_hex: "ab".repeat(32),393      },394    });395396    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");397    expect(createVaultFromRootKey).not.toHaveBeenCalled();398  });399400  it("T-A31: does not apply a license if vault creation fails", async () => {401    const [receiverChannel, sourceChannel] = memoryPair();402    const rootKeyHex = "44".repeat(32);403    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));404    const applyLicense = vi.fn();405    const receiver = requestAdoption(receiverChannel, {406      deviceName: "Receiver",407      generateDeviceId: () => "receiver-ephemeral",408      confirmSas: vi.fn(async () => true),409      createVaultFromRootKey: vi.fn(async () => {410        throw new Error("commit_failed");411      }),412      deriveLocalIdentity: vi.fn(),413      upsertPairedDevice: vi.fn(),414      applyLicense,415      onError: vi.fn(),416    });417    await sourceChannel.receive();418    await sendFrame(sourceChannel, {419      type: "ADOPT_GRANT",420      root_key_hex: rootKeyHex,421      vault_id: vaultId,422      license: "PPK-test",423      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },424    });425426    await expect(receiver).rejects.toThrow("commit_failed");427    expect(applyLicense).not.toHaveBeenCalled();428  });429430  it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {431    const [receiverChannel, sourceChannel] = memoryPair();432    const createVaultFromRootKey = vi.fn();433    const receiver = requestAdoption(receiverChannel, {434      deviceName: "Receiver",435      generateDeviceId: () => "receiver-ephemeral",436      confirmSas: vi.fn(async () => true),437      createVaultFromRootKey,438      deriveLocalIdentity: vi.fn(),439      upsertPairedDevice: vi.fn(),440      applyLicense: vi.fn(),441      onError: vi.fn(),442    });443    await sourceChannel.receive();444    await sendFrame(sourceChannel, {445      type: "ADOPT_GRANT",446      root_key_hex: "55".repeat(32),447      vault_id: "99".repeat(32),448      license: "PPK-test",449      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },450    });451452    await receiver;453    expect(createVaultFromRootKey).not.toHaveBeenCalled();454  });455});456457describe("snapshot_capable echo on ADOPT_GRANT", () => {458  function validGrant() {459    return {460      type: "ADOPT_GRANT" as const,461      root_key_hex: "11".repeat(32),462      vault_id: "22".repeat(32),463      license: "PPK-test",464      source: {465        device_id: "src-id",466        device_name: "Source",467        device_type: "extension" as const,468        long_term_public_key_hex: "ab".repeat(32),469      },470    };471  }472473  it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {474    expect(isAdoptGrant(validGrant())).toBe(true);475  });476477  it("isAdoptGrant accepts snapshot_capable: true", () => {478    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);479  });480481  it("isAdoptGrant accepts snapshot_capable: false", () => {482    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);483  });484485  it("isAdoptGrant rejects snapshot_capable with wrong type", () => {486    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);487  });488489  it("grantAdoption echoes snapshot_capable: true when request had it", async () => {490    const [receiverChannel, sourceChannel] = memoryPair();491    const rootKeyHex = "33".repeat(32);492    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));493    const sourceDeps: AdoptionSourceDeps = {494      sourceIdentity: identity("source"),495      rootKeyHex: vi.fn(async () => rootKeyHex),496      vaultId: vi.fn(async () => vaultId),497      license: vi.fn(async () => "PPK-test"),498      onConsent: vi.fn(async () => true),499      upsertPairedDevice: vi.fn(),500    };501502    // Run grantAdoption concurrently with the test driver that feeds protocol frames.503    const grantPromise = grantAdoption(sourceChannel, {504      type: "ADOPT_REQUEST",505      device_id: "recv-id",506      device_name: "Receiver",507      device_type: "extension",508      snapshot_capable: true,509    }, sourceDeps);510511    // grantAdoption: consent=true → reads ADOPT_CONFIRM → sends ADOPT_GRANT → reads ADOPT_IDENTITY512    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });513    const grantBytes = await receiverChannel.receive();514    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));515    expect(grantFrame.type).toBe("ADOPT_GRANT");516    expect(grantFrame.snapshot_capable).toBe(true);517518    // Let grantAdoption complete: send ADOPT_IDENTITY, read ADOPT_COMPLETE, send ADOPT_APPLIED.519    await sendFrame(receiverChannel, {520      type: "ADOPT_IDENTITY",521      device_id: "recv-id",522      device_name: "Receiver",523      device_type: "extension",524      long_term_public_key_hex: "ab".repeat(32),525    });526    await receiverChannel.receive(); // ADOPT_COMPLETE527    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });528    await grantPromise;529  });530531  it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {532    const [receiverChannel, sourceChannel] = memoryPair();533    const rootKeyHex = "44".repeat(32);534    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));535    const sourceDeps: AdoptionSourceDeps = {536      sourceIdentity: identity("source"),537      rootKeyHex: vi.fn(async () => rootKeyHex),538      vaultId: vi.fn(async () => vaultId),539      license: vi.fn(async () => "PPK-test"),540      onConsent: vi.fn(async () => true),541      upsertPairedDevice: vi.fn(),542    };543544    const grantPromise = grantAdoption(sourceChannel, {545      type: "ADOPT_REQUEST",546      device_id: "recv-id",547      device_name: "Receiver",548      device_type: "extension",549      // NO snapshot_capable550    }, sourceDeps);551552    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });553    const grantBytes = await receiverChannel.receive();554    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));555    expect(grantFrame.type).toBe("ADOPT_GRANT");556    expect(grantFrame.snapshot_capable).toBe(false);557558    await sendFrame(receiverChannel, {559      type: "ADOPT_IDENTITY",560      device_id: "recv-id",561      device_name: "Receiver",562      device_type: "extension",563      long_term_public_key_hex: "ab".repeat(32),564    });565    await receiverChannel.receive(); // ADOPT_COMPLETE566    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });567    await grantPromise;568  });569570  it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {571    const [receiverChannel, sourceChannel] = memoryPair();572    const rootKeyHex = "55".repeat(32);573    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));574    const deps: AdoptionReceiverDeps = {575      deviceName: "recv",576      generateDeviceId: () => "recv-id",577      async confirmSas() { return true; },578      async createVaultFromRootKey() { return { vaultId }; },579      async deriveLocalIdentity() {580        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };581      },582      async upsertPairedDevice() {},583      onError() {},584    };585586    const receiverPromise = requestAdoption(receiverChannel, deps);587    // requestAdoption sends: ADOPT_REQUEST, ADOPT_CONFIRM (after SAS auto-accept)588    // Then reads: ADOPT_GRANT, sends: ADOPT_IDENTITY, reads: ADOPT_COMPLETE, sends: ADOPT_APPLIED589    await sourceChannel.receive(); // ADOPT_REQUEST590    await sourceChannel.receive(); // ADOPT_CONFIRM591    await sendFrame(sourceChannel, {592      type: "ADOPT_GRANT",593      root_key_hex: rootKeyHex,594      vault_id: vaultId,595      license: "PPK-test",596      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },597      snapshot_capable: true,598    });599    await sourceChannel.receive(); // ADOPT_IDENTITY600    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });601    await sourceChannel.receive(); // ADOPT_APPLIED602603    const result = await receiverPromise;604    expect(result).not.toBeNull();605    expect(result!.snapshotCapable).toBe(true);606  });607608  it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {609    const [receiverChannel, sourceChannel] = memoryPair();610    const rootKeyHex = "66".repeat(32);611    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));612    const deps: AdoptionReceiverDeps = {613      deviceName: "recv",614      generateDeviceId: () => "recv-id",615      async confirmSas() { return true; },616      async createVaultFromRootKey() { return { vaultId }; },617      async deriveLocalIdentity() {618        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };619      },620      async upsertPairedDevice() {},621      onError() {},622    };623624    const receiverPromise = requestAdoption(receiverChannel, deps);625    await sourceChannel.receive(); // ADOPT_REQUEST626    await sourceChannel.receive(); // ADOPT_CONFIRM627    await sendFrame(sourceChannel, {628      type: "ADOPT_GRANT",629      root_key_hex: rootKeyHex,630      vault_id: vaultId,631      license: "PPK-test",632      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },633      // NO snapshot_capable (pre-C source)634    });635    await sourceChannel.receive(); // ADOPT_IDENTITY636    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });637    await sourceChannel.receive(); // ADOPT_APPLIED638639    const result = await receiverPromise;640    expect(result).not.toBeNull();641    expect(result!.snapshotCapable).toBe(false);642  });643644  // T-A33: post-fix regression for the SNAPSHOT_FRAME_WIRE_CEILING (768 KiB)645  // bug. Before v4.6.4, duress rode the entries final chunk, so an entry +646  // duress whose combined raw exceeded 768 KiB threw647  // ADOPT_SNAPSHOT_CHUNK_TOO_LARGE on receive. The frame-ceiling fix moves648  // duress onto its own frame after the entries chunks; this case asserts the649  // root-cause fix rather than just the new wire path.650  it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => {651    const [source, receiver] = memoryPair();652    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };653    const tombstones: Record<string, number> = { "dead-0": 1 };654    // 320 KiB of ciphertext JSON ("cc".repeat(320 * 1024) is ~640 KiB of JSON655    // text — the receiver treats duress_slot as opaque and concatenates it656    // raw; ~640 KiB JSON pushes combined raw over 768 KiB once the entry and657    // envelope are added). Pre-v4.6.4 this duress rode the entries final658    // chunk and pushed that frame over the ceiling; post-fix the duress659    // frame is sent on its own and stays under the ceiling.660    const duressJson = JSON.stringify({661      version: 1,662      salt: "aa".repeat(16),663      nonce: "bb".repeat(12),664      ciphertext: "cc".repeat(320 * 1024),665      tag: "dd".repeat(16),666    });667668    const [result] = await Promise.all([669      receiveVaultSnapshot(receiver),670      sendVaultSnapshot(source, entries, tombstones, duressJson),671    ]);672673    expect(result.entries).toEqual(entries);674    expect(result.tombstones).toEqual(tombstones);675    expect(result.duressSlot).toBe(duressJson);676  });677678  // T-A34: structural proof of the duress-on-separate-frame wire change.679  // Duress must leave the entries chunk so the entries frame stays under the680  // 768 KiB ceiling, even when the combined payload would exceed it.681  it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {682    const [source] = memoryPair();683    const observed: Array<{684      size: number;685      wireSize: number;686      hasDuress: boolean;687      entriesKeys: number;688    }> = [];689    const probe: PairingTransport = {690      async send(msg) {691        const raw = unpadSyncFrame(msg);692        const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;693        observed.push({694          size: raw.length,695          wireSize: msg.length,696          hasDuress: typeof f.duress_slot === "string",697          entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,698        });699        return source.send(msg);700      },701      receive() { return source.receive(); },702    };703    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };704    const duressJson = "y".repeat(320 * 1024);705    const CEILING = 768 * 1024;706707    await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson);708709    expect(observed.length).toBe(2);710    // Entries frame: no duress, one entries key, well under 768 KiB.711    expect(observed[0].hasDuress).toBe(false);712    expect(observed[0].entriesKeys).toBe(1);713    expect(observed[0].size).toBeLessThan(CEILING);714    // Duress frame: duress present, no entries, well under 768 KiB.715    expect(observed[1].hasDuress).toBe(true);716    expect(observed[1].entriesKeys).toBe(0);717    expect(observed[1].size).toBeLessThan(CEILING);718    // Combined raw exceeds the ceiling, which is the point of the split.719    expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);720    // PB-26-604: both frames left the wire bucket-padded, so the relay sees721    // neither exact plaintext length.722    for (const frame of observed) {723      expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4));724    }725  });726727  // T-A35: backward compat — a pre-v4.6.4 sender (duress on the entries728  // final chunk) still decodes correctly. The receiver's per-chunk729  // Object.assign (entries / tombstones) plus duress-slot capture tolerates730  // both shapes.731  it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => {732    const [source, receiver] = memoryPair();733    const duressJson = JSON.stringify({734      version: 1,735      salt: "aa".repeat(16),736      nonce: "bb".repeat(12),737      ciphertext: "cc".repeat(32),738      tag: "dd".repeat(16),739    });740    // Manually construct the legacy wire shape: a single chunk with both741    // the entries, the tombstones, AND duress_slot on it.742    await sendSnapshotFrame(source, {743      type: "ADOPT_SNAPSHOT",744      chunk_index: 0,745      chunk_count: 1,746      entries: { "id-0": "ciphertext" },747      tombstones: { "dead-0": 42 },748      duress_slot: duressJson,749    });750    const result = await receiveVaultSnapshot(receiver);751    expect(result.entries).toEqual({ "id-0": "ciphertext" });752    expect(result.tombstones).toEqual({ "dead-0": 42 });753    expect(result.duressSlot).toBe(duressJson);754  });755756  // T-A36: cap boundary inclusive. With 199 entries and a duress slot, the757  // sender must fit everything within MAX_SNAPSHOT_CHUNKS = 200 (199 entries758  // + 1 duress = 200 frames). Duress MUST arrive intact — sync does not759  // carry duress, so the adopted device would permanently lack its duress760  // vault otherwise. Entries are 40 KiB so the ~8 MiB total also fits the761  // PB-26-206 10 MiB aggregate budget.762  it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {763    const [source, receiver] = memoryPair();764    const duressJson = JSON.stringify({765      version: 1,766      salt: "aa".repeat(16),767      nonce: "bb".repeat(12),768      ciphertext: "cc".repeat(32),769      tag: "dd".repeat(16),770    });771    const big = "x".repeat(40 * 1024);772    const entries: Record<string, string> = {};773    for (let i = 0; i < 199; i++) entries[`id-${i}`] = big;774    // First sanity: 199 entries needs multiple chunks (each ~40 KiB entry775    // packs ~12 per 512 KiB chunk). Verify the chunking math is sane before776    // asserting the duress-reservation behavior.777    const [result] = await Promise.all([778      receiveVaultSnapshot(receiver),779      sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),780    ]);781    expect(result.entries).toEqual(entries);782    expect(result.tombstones).toEqual({ "dead-0": 1 });783    expect(result.duressSlot).toBe(duressJson);784  });785786  // T-A37: PB-26-206 supersedes the old chunk-cap truncation. An over-budget787  // snapshot is never partially sent: the sender preflights with the same788  // aggregate helper the receiver enforces and throws before the first frame,789  // so the caller falls through to the regular sync pipeline instead of790  // sending a truncated snapshot.791  it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => {792    const [source] = memoryPair();793    let frames = 0;794    const countingSource: PairingTransport = {795      async send(msg) {796        frames++;797        return source.send(msg);798      },799      receive() { return source.receive(); },800    };801    const duressJson = JSON.stringify({802      version: 1,803      salt: "aa".repeat(16),804      nonce: "bb".repeat(12),805      ciphertext: "cc".repeat(32),806      tag: "dd".repeat(16),807    });808    // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.809    const big = "x".repeat(60 * 1024);810    const entries: Record<string, string> = {};811    for (let i = 0; i < 200; i++) entries[`id-${i}`] = big;812    await expect(813      sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson),814    ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");815    expect(frames).toBe(0);816  });817818  // T-A38: the single test that proves the *root-cause fix* for the frame819  // ceiling bug, not just the new wire path. Combines the high-level820  // end-to-end success assertion (T-A33) with the structural proof (T-A34):821  // a payload whose combined raw exceeds the 768 KiB wire ceiling succeeds822  // because the duress slot rides its own frame instead of piggybacking on823  // the entries final chunk. Pre-v4.6.4, duress counted against the entries824  // frame, so a single frame would exceed SNAPSHOT_FRAME_WIRE_CEILING and the825  // receiver would reject the whole snapshot with ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.826  it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => {827    const [source, receiver] = memoryPair();828    // Single entry that is ~450 KiB on its own; combined with a ~640 KiB829    // duress JSON, the total payload exceeds the 768 KiB wire ceiling.830    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };831    const tombstones: Record<string, number> = { "dead-0": 1 };832    const duressJson = JSON.stringify({833      version: 1,834      salt: "aa".repeat(16),835      nonce: "bb".repeat(12),836      ciphertext: "cc".repeat(320 * 1024),837      tag: "dd".repeat(16),838    });839    const CEILING = 768 * 1024;840841    // Probe the wire so we can assert the structural shape alongside the842    // end-to-end success.843    const observed: Array<{844      size: number;845      wireSize: number;846      hasDuress: boolean;847      entriesKeys: number;848    }> = [];849    const probe: PairingTransport = {850      async send(msg) {851        const raw = unpadSyncFrame(msg);852        const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;853        observed.push({854          size: raw.length,855          wireSize: msg.length,856          hasDuress: typeof f.duress_slot === "string",857          entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,858        });859        return source.send(msg);860      },861      receive() { return source.receive(); },862    };863864    const [result] = await Promise.all([865      receiveVaultSnapshot(receiver),866      sendVaultSnapshot(probe, entries, tombstones, duressJson),867    ]);868869    // High-level success: the receiver accepts the >768 KiB combined payload.870    expect(result.entries).toEqual(entries);871    expect(result.tombstones).toEqual(tombstones);872    expect(result.duressSlot).toBe(duressJson);873874    // Root-cause proof: duress is on its own frame, not on the entries frame.875    // Every frame stays under the ceiling; only the combined raw exceeds it.876    expect(observed.length).toBe(2);877    expect(observed[0].hasDuress).toBe(false);878    expect(observed[0].entriesKeys).toBe(1);879    expect(observed[0].size).toBeLessThan(CEILING);880    expect(observed[1].hasDuress).toBe(true);881    expect(observed[1].entriesKeys).toBe(0);882    expect(observed[1].size).toBeLessThan(CEILING);883    const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({884      type: "ADOPT_SNAPSHOT",885      chunk_index: 0,886      chunk_count: 1,887      entries,888      tombstones,889      duress_slot: duressJson,890    })).length;891    expect(legacyCombinedSize).toBeGreaterThan(CEILING);892  });893});894895/**896 * PB-26-603 — adoption frames carry a peer-supplied `device_name` straight into897 * `chrome.storage.local`. The manifest path caps it at 64 chars; adoption did898 * not, so one name could be ~16Kx larger than the manifest bound.899 */900describe("PB-26-603 adoption device_name cap", () => {901  it("caps a 500-char peer device_name to 64 chars on both PairedDevice records", async () => {902    const longName = "X".repeat(500);903    const [receiverChannel, sourceChannel] = memoryPair();904    const rootKeyHex = "13".repeat(32);905    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));906    const receiverUpsert = vi.fn();907    const sourceUpsert = vi.fn();908909    const receiverDeps: AdoptionReceiverDeps = {910      deviceName: "receiver",911      generateDeviceId: () => "receiver-ephemeral",912      confirmSas: vi.fn(async () => true),913      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),914      deriveLocalIdentity: vi.fn(async () => ({915        deviceId: "receiver-id",916        deviceName: longName,917        deviceType: "extension" as const,918        longTermPublicKeyHex: "ab".repeat(32),919        signingPublicKeyHex: "cd".repeat(32),920      })),921      upsertPairedDevice: receiverUpsert,922      applyLicense: vi.fn(),923      onError: vi.fn(),924    };925    const sourceDeps: AdoptionSourceDeps = {926      sourceIdentity: {927        deviceId: "source-id",928        deviceName: longName,929        deviceType: "extension" as const,930        longTermPublicKeyHex: "ef".repeat(32),931        signingPublicKeyHex: "01".repeat(32),932      },933      rootKeyHex: vi.fn(async () => rootKeyHex),934      vaultId: vi.fn(async () => vaultId),935      license: vi.fn(async () => "PPK-test"),936      onConsent: vi.fn(async () => true),937      upsertPairedDevice: sourceUpsert,938    };939940    await Promise.all([941      requestAdoption(receiverChannel, receiverDeps),942      (async () => {943        const request = JSON.parse(944          new TextDecoder().decode(await sourceChannel.receive()),945        ) as AdoptRequest;946        await grantAdoption(sourceChannel, request, sourceDeps);947      })(),948    ]);949950    const storedReceiver = receiverUpsert.mock.calls[0]?.[0] as { deviceName: string };951    const storedSource = sourceUpsert.mock.calls[0]?.[0] as { deviceName: string };952    expect(storedReceiver.deviceName).toBe(longName.slice(0, 64));953    expect(storedSource.deviceName).toBe(longName.slice(0, 64));954  });955});