vault-adopt.test.ts

39.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-A10c: rotates a copied browser identity when the source reports a device ID collision", async () => {113    const [receiverChannel, sourceChannel] = memoryPair();114    const rootKeyHex = "13".repeat(32);115    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));116    const copiedIdentity = identity("chrome");117    const freshIdentity = identity("firefox");118    const rotateLocalIdentity = vi.fn(async () => freshIdentity);119    const receiverDeps: AdoptionReceiverDeps = {120      deviceName: "Firefox",121      generateDeviceId: () => "receiver-ephemeral",122      confirmSas: vi.fn(async () => true),123      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),124      deriveLocalIdentity: vi.fn(async () => copiedIdentity),125      rotateLocalIdentity,126      upsertPairedDevice: vi.fn(),127      onError: vi.fn(),128    };129130    const receiverPromise = requestAdoption(receiverChannel, receiverDeps);131    await sourceChannel.receive(); // ADOPT_REQUEST132    await sourceChannel.receive(); // ADOPT_CONFIRM133    await sendFrame(sourceChannel, {134      type: "ADOPT_GRANT",135      root_key_hex: rootKeyHex,136      vault_id: vaultId,137      license: "PPK-test",138      source: {139        device_id: "android-id",140        device_name: "Android",141        device_type: "android",142        long_term_public_key_hex: "ef".repeat(32),143      },144    });145146    const firstIdentity = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));147    expect(firstIdentity.device_id).toBe("chrome-id");148    await sendFrame(sourceChannel, {149      type: "ADOPT_REJECT",150      reason: "ADOPT_DEVICE_ID_COLLISION",151    });152    const secondIdentity = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));153    expect(secondIdentity.device_id).toBe("firefox-id");154    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });155    await sourceChannel.receive(); // ADOPT_APPLIED156157    expect(await receiverPromise).not.toBeNull();158    expect(rotateLocalIdentity).toHaveBeenCalledOnce();159    expect(receiverDeps.onError).not.toHaveBeenCalled();160  });161162  it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {163    const [receiverChannel, sourceChannel] = memoryPair();164    const rootKeyHex = "12".repeat(32);165    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));166    const createVault = vi.fn();167    const receiverUpsert = vi.fn();168    const sourceUpsert = vi.fn();169    const onError = vi.fn();170171    const receiver = requestAdoption(receiverChannel, {172      deviceName: "Free receiver",173      generateDeviceId: () => "free-receiver",174      confirmSas: vi.fn(async () => true),175      license: vi.fn(async () => undefined),176      createVaultFromRootKey: createVault,177      deriveLocalIdentity: vi.fn(),178      upsertPairedDevice: receiverUpsert,179      onError,180    });181    const request = JSON.parse(182      new TextDecoder().decode(await sourceChannel.receive()),183    ) as AdoptRequest;184    const source = grantAdoption(sourceChannel, request, {185      sourceIdentity: identity("free-source"),186      rootKeyHex: vi.fn(async () => rootKeyHex),187      vaultId: vi.fn(async () => vaultId),188      license: vi.fn(async () => undefined),189      onConsent: vi.fn(async () => true),190      upsertPairedDevice: sourceUpsert,191    });192193    await expect(receiver).resolves.toBeNull();194    await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");195    expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");196    expect(createVault).not.toHaveBeenCalled();197    expect(receiverUpsert).not.toHaveBeenCalled();198    expect(sourceUpsert).not.toHaveBeenCalled();199  });200201  it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {202    const [receiverChannel, sourceChannel] = memoryPair();203    const rootKeyHex = "13".repeat(32);204    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));205    let sourceLicense: string | undefined;206    const sourceApply = vi.fn(async (license: string) => {207      sourceLicense = license;208    });209210    const receiver = requestAdoption(receiverChannel, {211      deviceName: "Pro receiver",212      generateDeviceId: () => "pro-receiver",213      confirmSas: vi.fn(async () => true),214      license: vi.fn(async () => "PPK-receiver"),215      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),216      deriveLocalIdentity: vi.fn(async () => identity("receiver")),217      upsertPairedDevice: vi.fn(),218      applyLicense: vi.fn(),219      onError: vi.fn(),220    });221    const request = JSON.parse(222      new TextDecoder().decode(await sourceChannel.receive()),223    ) as AdoptRequest;224    const source = grantAdoption(sourceChannel, request, {225      sourceIdentity: identity("free-source"),226      rootKeyHex: vi.fn(async () => rootKeyHex),227      vaultId: vi.fn(async () => vaultId),228      license: vi.fn(async () => sourceLicense),229      applyLicense: sourceApply,230      onConsent: vi.fn(async () => true),231      upsertPairedDevice: vi.fn(),232    });233234    await expect(Promise.all([receiver, source])).resolves.toEqual([235      { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },236      expect.objectContaining({ deviceId: "receiver-id" }),237    ]);238    expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");239  });240241  it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {242    const [receiverChannel, sourceChannel] = memoryPair();243    const rootKeyHex = vi.fn(async () => "11".repeat(32));244    const createVaultFromRootKey = vi.fn();245    const receiver = requestAdoption(receiverChannel, {246      deviceName: "Receiver",247      generateDeviceId: () => "receiver-ephemeral",248      confirmSas: vi.fn(async () => false),249      createVaultFromRootKey,250      deriveLocalIdentity: vi.fn(),251      upsertPairedDevice: vi.fn(),252      onError: vi.fn(),253    });254    const request = JSON.parse(255      new TextDecoder().decode(await sourceChannel.receive()),256    ) as AdoptRequest;257    const source = grantAdoption(sourceChannel, request, {258      sourceIdentity: identity("source"),259      rootKeyHex,260      vaultId: vi.fn(),261      onConsent: vi.fn(async () => true),262      upsertPairedDevice: vi.fn(),263    });264265    await expect(receiver).resolves.toBeNull();266    await expect(source).resolves.toBeNull();267    expect(rootKeyHex).not.toHaveBeenCalled();268    expect(createVaultFromRootKey).not.toHaveBeenCalled();269  });270271  it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {272    const [receiverChannel, sourceChannel] = memoryPair();273    const createVaultFromRootKey = vi.fn();274    const errors: string[] = [];275    const receiver = requestAdoption(receiverChannel, {276      deviceName: "Receiver",277      generateDeviceId: () => "receiver-ephemeral",278      confirmSas: vi.fn(async () => true),279      createVaultFromRootKey,280      deriveLocalIdentity: vi.fn(),281      upsertPairedDevice: vi.fn(),282      onError: (err) => errors.push(err),283    });284    const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;285    await grantAdoption(sourceChannel, request, {286      sourceIdentity: identity("source"),287      rootKeyHex: vi.fn(),288      vaultId: vi.fn(),289      onConsent: vi.fn(async () => false),290      upsertPairedDevice: vi.fn(),291    });292    await Promise.race([receiver, Promise.resolve()]);293294    expect(createVaultFromRootKey).not.toHaveBeenCalled();295    expect(errors).toEqual([]);296  });297298  it("T-A13: invalid root_key_hex aborts gracefully", async () => {299    const [receiverChannel, sourceChannel] = memoryPair();300    const onError = vi.fn();301    const createVaultFromRootKey = vi.fn();302    const receiver = requestAdoption(receiverChannel, {303      deviceName: "Receiver",304      generateDeviceId: () => "receiver-ephemeral",305      confirmSas: vi.fn(async () => true),306      createVaultFromRootKey,307      deriveLocalIdentity: vi.fn(),308      upsertPairedDevice: vi.fn(),309      onError,310    });311    await sourceChannel.receive();312    await sendFrame(sourceChannel, {313      type: "ADOPT_GRANT",314      root_key_hex: "bad",315      vault_id: "00".repeat(32),316      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },317    } as never);318    await receiver;319320    expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");321    expect(createVaultFromRootKey).not.toHaveBeenCalled();322  });323324  it("T-A14: vault_id mismatch aborts before vault creation", async () => {325    const [receiverChannel, sourceChannel] = memoryPair();326    const onError = vi.fn();327    const createVaultFromRootKey = vi.fn();328    const receiver = requestAdoption(receiverChannel, {329      deviceName: "Receiver",330      generateDeviceId: () => "receiver-ephemeral",331      confirmSas: vi.fn(async () => true),332      createVaultFromRootKey,333      deriveLocalIdentity: vi.fn(),334      upsertPairedDevice: vi.fn(),335      onError,336    });337    await sourceChannel.receive();338    await sendFrame(sourceChannel, {339      type: "ADOPT_GRANT",340      root_key_hex: "22".repeat(32),341      vault_id: "00".repeat(32),342      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },343    });344    await receiver;345346    expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");347    expect(createVaultFromRootKey).not.toHaveBeenCalled();348  });349350  it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {351    const [receiverChannel, sourceChannel] = memoryPair();352    const upsert = vi.fn();353    const promise = grantAdoption(sourceChannel, {354      type: "ADOPT_REQUEST",355      device_id: "receiver-ephemeral",356      device_name: "Receiver",357      device_type: "extension",358    }, {359      sourceIdentity: identity("source"),360      rootKeyHex: vi.fn(async () => "33".repeat(32)),361      vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),362      license: vi.fn(async () => "PPK-test"),363      onConsent: vi.fn(async () => true),364      upsertPairedDevice: upsert,365    });366    await sendFrame(receiverChannel, {367      type: "ADOPT_CONFIRM",368      confirmed: true,369    });370371    await Promise.race([promise, Promise.resolve()]);372    expect(upsert).not.toHaveBeenCalled();373  });374375  it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {376    const [receiverChannel, sourceChannel] = memoryPair();377    const rootKeyHex = "66".repeat(32);378    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));379    const receiverUpsert = vi.fn();380    const onError = vi.fn();381    const receiver = requestAdoption(receiverChannel, {382      deviceName: "Receiver",383      generateDeviceId: () => "receiver-ephemeral",384      confirmSas: vi.fn(async () => true),385      license: vi.fn(async () => "PPK-local"),386      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),387      deriveLocalIdentity: vi.fn(async () => identity("receiver")),388      upsertPairedDevice: receiverUpsert,389      onError,390    });391392    await sourceChannel.receive(); // ADOPT_REQUEST393    await sourceChannel.receive(); // ADOPT_CONFIRM394    await sendFrame(sourceChannel, {395      type: "ADOPT_GRANT",396      root_key_hex: rootKeyHex,397      vault_id: vaultId,398      source: {399        device_id: "source-id",400        device_name: "Source",401        device_type: "extension",402        long_term_public_key_hex: "ab".repeat(32),403      },404    });405    await sourceChannel.receive(); // ADOPT_IDENTITY406    await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });407408    await expect(receiver).resolves.toBeNull();409    expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");410    expect(receiverUpsert).not.toHaveBeenCalled();411  });412413  it("T-A17: receiver safety policy runs before vault creation", async () => {414    const [receiverChannel, sourceChannel] = memoryPair();415    const rootKeyHex = "77".repeat(32);416    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));417    const createVaultFromRootKey = vi.fn();418    const receiver = requestAdoption(receiverChannel, {419      deviceName: "Receiver",420      generateDeviceId: () => "receiver-ephemeral",421      confirmSas: vi.fn(async () => true),422      license: vi.fn(async () => "PPK-local"),423      validateSource: vi.fn(async () => {424        throw new Error("JOIN_POPULATED_VAULT_BLOCKED");425      }),426      createVaultFromRootKey,427      deriveLocalIdentity: vi.fn(),428      upsertPairedDevice: vi.fn(),429      onError: vi.fn(),430    });431432    await sourceChannel.receive(); // ADOPT_REQUEST433    await sourceChannel.receive(); // ADOPT_CONFIRM434    await sendFrame(sourceChannel, {435      type: "ADOPT_GRANT",436      root_key_hex: rootKeyHex,437      vault_id: vaultId,438      source: {439        device_id: "new-source",440        device_name: "Source",441        device_type: "extension",442        long_term_public_key_hex: "ab".repeat(32),443      },444    });445446    await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");447    expect(createVaultFromRootKey).not.toHaveBeenCalled();448  });449450  it("T-A31: does not apply a license if vault creation fails", async () => {451    const [receiverChannel, sourceChannel] = memoryPair();452    const rootKeyHex = "44".repeat(32);453    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));454    const applyLicense = vi.fn();455    const receiver = requestAdoption(receiverChannel, {456      deviceName: "Receiver",457      generateDeviceId: () => "receiver-ephemeral",458      confirmSas: vi.fn(async () => true),459      createVaultFromRootKey: vi.fn(async () => {460        throw new Error("commit_failed");461      }),462      deriveLocalIdentity: vi.fn(),463      upsertPairedDevice: vi.fn(),464      applyLicense,465      onError: vi.fn(),466    });467    await sourceChannel.receive();468    await sendFrame(sourceChannel, {469      type: "ADOPT_GRANT",470      root_key_hex: rootKeyHex,471      vault_id: vaultId,472      license: "PPK-test",473      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },474    });475476    await expect(receiver).rejects.toThrow("commit_failed");477    expect(applyLicense).not.toHaveBeenCalled();478  });479480  it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {481    const [receiverChannel, sourceChannel] = memoryPair();482    const createVaultFromRootKey = vi.fn();483    const receiver = requestAdoption(receiverChannel, {484      deviceName: "Receiver",485      generateDeviceId: () => "receiver-ephemeral",486      confirmSas: vi.fn(async () => true),487      createVaultFromRootKey,488      deriveLocalIdentity: vi.fn(),489      upsertPairedDevice: vi.fn(),490      applyLicense: vi.fn(),491      onError: vi.fn(),492    });493    await sourceChannel.receive();494    await sendFrame(sourceChannel, {495      type: "ADOPT_GRANT",496      root_key_hex: "55".repeat(32),497      vault_id: "99".repeat(32),498      license: "PPK-test",499      source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },500    });501502    await receiver;503    expect(createVaultFromRootKey).not.toHaveBeenCalled();504  });505});506507describe("snapshot_capable echo on ADOPT_GRANT", () => {508  function validGrant() {509    return {510      type: "ADOPT_GRANT" as const,511      root_key_hex: "11".repeat(32),512      vault_id: "22".repeat(32),513      license: "PPK-test",514      source: {515        device_id: "src-id",516        device_name: "Source",517        device_type: "extension" as const,518        long_term_public_key_hex: "ab".repeat(32),519      },520    };521  }522523  it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {524    expect(isAdoptGrant(validGrant())).toBe(true);525  });526527  it("isAdoptGrant accepts snapshot_capable: true", () => {528    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);529  });530531  it("isAdoptGrant accepts snapshot_capable: false", () => {532    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);533  });534535  it("isAdoptGrant rejects snapshot_capable with wrong type", () => {536    expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);537  });538539  it("grantAdoption echoes snapshot_capable: true when request had it", async () => {540    const [receiverChannel, sourceChannel] = memoryPair();541    const rootKeyHex = "33".repeat(32);542    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));543    const sourceDeps: AdoptionSourceDeps = {544      sourceIdentity: identity("source"),545      rootKeyHex: vi.fn(async () => rootKeyHex),546      vaultId: vi.fn(async () => vaultId),547      license: vi.fn(async () => "PPK-test"),548      onConsent: vi.fn(async () => true),549      upsertPairedDevice: vi.fn(),550    };551552    // Run grantAdoption concurrently with the test driver that feeds protocol frames.553    const grantPromise = grantAdoption(sourceChannel, {554      type: "ADOPT_REQUEST",555      device_id: "recv-id",556      device_name: "Receiver",557      device_type: "extension",558      snapshot_capable: true,559    }, sourceDeps);560561    // grantAdoption: consent=true → reads ADOPT_CONFIRM → sends ADOPT_GRANT → reads ADOPT_IDENTITY562    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });563    const grantBytes = await receiverChannel.receive();564    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));565    expect(grantFrame.type).toBe("ADOPT_GRANT");566    expect(grantFrame.snapshot_capable).toBe(true);567568    // Let grantAdoption complete: send ADOPT_IDENTITY, read ADOPT_COMPLETE, send ADOPT_APPLIED.569    await sendFrame(receiverChannel, {570      type: "ADOPT_IDENTITY",571      device_id: "recv-id",572      device_name: "Receiver",573      device_type: "extension",574      long_term_public_key_hex: "ab".repeat(32),575    });576    await receiverChannel.receive(); // ADOPT_COMPLETE577    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });578    await grantPromise;579  });580581  it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {582    const [receiverChannel, sourceChannel] = memoryPair();583    const rootKeyHex = "44".repeat(32);584    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));585    const sourceDeps: AdoptionSourceDeps = {586      sourceIdentity: identity("source"),587      rootKeyHex: vi.fn(async () => rootKeyHex),588      vaultId: vi.fn(async () => vaultId),589      license: vi.fn(async () => "PPK-test"),590      onConsent: vi.fn(async () => true),591      upsertPairedDevice: vi.fn(),592    };593594    const grantPromise = grantAdoption(sourceChannel, {595      type: "ADOPT_REQUEST",596      device_id: "recv-id",597      device_name: "Receiver",598      device_type: "extension",599      // NO snapshot_capable600    }, sourceDeps);601602    await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });603    const grantBytes = await receiverChannel.receive();604    const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));605    expect(grantFrame.type).toBe("ADOPT_GRANT");606    expect(grantFrame.snapshot_capable).toBe(false);607608    await sendFrame(receiverChannel, {609      type: "ADOPT_IDENTITY",610      device_id: "recv-id",611      device_name: "Receiver",612      device_type: "extension",613      long_term_public_key_hex: "ab".repeat(32),614    });615    await receiverChannel.receive(); // ADOPT_COMPLETE616    await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });617    await grantPromise;618  });619620  it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {621    const [receiverChannel, sourceChannel] = memoryPair();622    const rootKeyHex = "55".repeat(32);623    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));624    const deps: AdoptionReceiverDeps = {625      deviceName: "recv",626      generateDeviceId: () => "recv-id",627      async confirmSas() { return true; },628      async createVaultFromRootKey() { return { vaultId }; },629      async deriveLocalIdentity() {630        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };631      },632      async upsertPairedDevice() {},633      onError() {},634    };635636    const receiverPromise = requestAdoption(receiverChannel, deps);637    // requestAdoption sends: ADOPT_REQUEST, ADOPT_CONFIRM (after SAS auto-accept)638    // Then reads: ADOPT_GRANT, sends: ADOPT_IDENTITY, reads: ADOPT_COMPLETE, sends: ADOPT_APPLIED639    await sourceChannel.receive(); // ADOPT_REQUEST640    await sourceChannel.receive(); // ADOPT_CONFIRM641    await sendFrame(sourceChannel, {642      type: "ADOPT_GRANT",643      root_key_hex: rootKeyHex,644      vault_id: vaultId,645      license: "PPK-test",646      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },647      snapshot_capable: true,648    });649    await sourceChannel.receive(); // ADOPT_IDENTITY650    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });651    await sourceChannel.receive(); // ADOPT_APPLIED652653    const result = await receiverPromise;654    expect(result).not.toBeNull();655    expect(result!.snapshotCapable).toBe(true);656  });657658  it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {659    const [receiverChannel, sourceChannel] = memoryPair();660    const rootKeyHex = "66".repeat(32);661    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));662    const deps: AdoptionReceiverDeps = {663      deviceName: "recv",664      generateDeviceId: () => "recv-id",665      async confirmSas() { return true; },666      async createVaultFromRootKey() { return { vaultId }; },667      async deriveLocalIdentity() {668        return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };669      },670      async upsertPairedDevice() {},671      onError() {},672    };673674    const receiverPromise = requestAdoption(receiverChannel, deps);675    await sourceChannel.receive(); // ADOPT_REQUEST676    await sourceChannel.receive(); // ADOPT_CONFIRM677    await sendFrame(sourceChannel, {678      type: "ADOPT_GRANT",679      root_key_hex: rootKeyHex,680      vault_id: vaultId,681      license: "PPK-test",682      source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },683      // NO snapshot_capable (pre-C source)684    });685    await sourceChannel.receive(); // ADOPT_IDENTITY686    await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });687    await sourceChannel.receive(); // ADOPT_APPLIED688689    const result = await receiverPromise;690    expect(result).not.toBeNull();691    expect(result!.snapshotCapable).toBe(false);692  });693694  // T-A33: post-fix regression for the SNAPSHOT_FRAME_WIRE_CEILING (768 KiB)695  // bug. Before v4.6.4, duress rode the entries final chunk, so an entry +696  // duress whose combined raw exceeded 768 KiB threw697  // ADOPT_SNAPSHOT_CHUNK_TOO_LARGE on receive. The frame-ceiling fix moves698  // duress onto its own frame after the entries chunks; this case asserts the699  // root-cause fix rather than just the new wire path.700  it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => {701    const [source, receiver] = memoryPair();702    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };703    const tombstones: Record<string, number> = { "dead-0": 1 };704    // 320 KiB of ciphertext JSON ("cc".repeat(320 * 1024) is ~640 KiB of JSON705    // text — the receiver treats duress_slot as opaque and concatenates it706    // raw; ~640 KiB JSON pushes combined raw over 768 KiB once the entry and707    // envelope are added). Pre-v4.6.4 this duress rode the entries final708    // chunk and pushed that frame over the ceiling; post-fix the duress709    // frame is sent on its own and stays under the ceiling.710    const duressJson = JSON.stringify({711      version: 1,712      salt: "aa".repeat(16),713      nonce: "bb".repeat(12),714      ciphertext: "cc".repeat(320 * 1024),715      tag: "dd".repeat(16),716    });717718    const [result] = await Promise.all([719      receiveVaultSnapshot(receiver),720      sendVaultSnapshot(source, entries, tombstones, duressJson),721    ]);722723    expect(result.entries).toEqual(entries);724    expect(result.tombstones).toEqual(tombstones);725    expect(result.duressSlot).toBe(duressJson);726  });727728  // T-A34: structural proof of the duress-on-separate-frame wire change.729  // Duress must leave the entries chunk so the entries frame stays under the730  // 768 KiB ceiling, even when the combined payload would exceed it.731  it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {732    const [source] = memoryPair();733    const observed: Array<{734      size: number;735      wireSize: number;736      hasDuress: boolean;737      entriesKeys: number;738    }> = [];739    const probe: PairingTransport = {740      async send(msg) {741        const raw = unpadSyncFrame(msg);742        const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;743        observed.push({744          size: raw.length,745          wireSize: msg.length,746          hasDuress: typeof f.duress_slot === "string",747          entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,748        });749        return source.send(msg);750      },751      receive() { return source.receive(); },752    };753    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };754    const duressJson = "y".repeat(320 * 1024);755    const CEILING = 768 * 1024;756757    await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson);758759    expect(observed.length).toBe(2);760    // Entries frame: no duress, one entries key, well under 768 KiB.761    expect(observed[0].hasDuress).toBe(false);762    expect(observed[0].entriesKeys).toBe(1);763    expect(observed[0].size).toBeLessThan(CEILING);764    // Duress frame: duress present, no entries, well under 768 KiB.765    expect(observed[1].hasDuress).toBe(true);766    expect(observed[1].entriesKeys).toBe(0);767    expect(observed[1].size).toBeLessThan(CEILING);768    // Combined raw exceeds the ceiling, which is the point of the split.769    expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);770    // PB-26-604: both frames left the wire bucket-padded, so the relay sees771    // neither exact plaintext length.772    for (const frame of observed) {773      expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4));774    }775  });776777  // T-A35: backward compat — a pre-v4.6.4 sender (duress on the entries778  // final chunk) still decodes correctly. The receiver's per-chunk779  // Object.assign (entries / tombstones) plus duress-slot capture tolerates780  // both shapes.781  it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => {782    const [source, receiver] = memoryPair();783    const duressJson = JSON.stringify({784      version: 1,785      salt: "aa".repeat(16),786      nonce: "bb".repeat(12),787      ciphertext: "cc".repeat(32),788      tag: "dd".repeat(16),789    });790    // Manually construct the legacy wire shape: a single chunk with both791    // the entries, the tombstones, AND duress_slot on it.792    await sendSnapshotFrame(source, {793      type: "ADOPT_SNAPSHOT",794      chunk_index: 0,795      chunk_count: 1,796      entries: { "id-0": "ciphertext" },797      tombstones: { "dead-0": 42 },798      duress_slot: duressJson,799    });800    const result = await receiveVaultSnapshot(receiver);801    expect(result.entries).toEqual({ "id-0": "ciphertext" });802    expect(result.tombstones).toEqual({ "dead-0": 42 });803    expect(result.duressSlot).toBe(duressJson);804  });805806  // T-A36: cap boundary inclusive. With 199 entries and a duress slot, the807  // sender must fit everything within MAX_SNAPSHOT_CHUNKS = 200 (199 entries808  // + 1 duress = 200 frames). Duress MUST arrive intact — sync does not809  // carry duress, so the adopted device would permanently lack its duress810  // vault otherwise. Entries are 40 KiB so the ~8 MiB total also fits the811  // PB-26-206 10 MiB aggregate budget.812  it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {813    const [source, receiver] = memoryPair();814    const duressJson = JSON.stringify({815      version: 1,816      salt: "aa".repeat(16),817      nonce: "bb".repeat(12),818      ciphertext: "cc".repeat(32),819      tag: "dd".repeat(16),820    });821    const big = "x".repeat(40 * 1024);822    const entries: Record<string, string> = {};823    for (let i = 0; i < 199; i++) entries[`id-${i}`] = big;824    // First sanity: 199 entries needs multiple chunks (each ~40 KiB entry825    // packs ~12 per 512 KiB chunk). Verify the chunking math is sane before826    // asserting the duress-reservation behavior.827    const [result] = await Promise.all([828      receiveVaultSnapshot(receiver),829      sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),830    ]);831    expect(result.entries).toEqual(entries);832    expect(result.tombstones).toEqual({ "dead-0": 1 });833    expect(result.duressSlot).toBe(duressJson);834  });835836  // T-A37: PB-26-206 supersedes the old chunk-cap truncation. An over-budget837  // snapshot is never partially sent: the sender preflights with the same838  // aggregate helper the receiver enforces and throws before the first frame,839  // so the caller falls through to the regular sync pipeline instead of840  // sending a truncated snapshot.841  it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => {842    const [source] = memoryPair();843    let frames = 0;844    const countingSource: PairingTransport = {845      async send(msg) {846        frames++;847        return source.send(msg);848      },849      receive() { return source.receive(); },850    };851    const duressJson = JSON.stringify({852      version: 1,853      salt: "aa".repeat(16),854      nonce: "bb".repeat(12),855      ciphertext: "cc".repeat(32),856      tag: "dd".repeat(16),857    });858    // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget.859    const big = "x".repeat(60 * 1024);860    const entries: Record<string, string> = {};861    for (let i = 0; i < 200; i++) entries[`id-${i}`] = big;862    await expect(863      sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson),864    ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");865    expect(frames).toBe(0);866  });867868  // T-A38: the single test that proves the *root-cause fix* for the frame869  // ceiling bug, not just the new wire path. Combines the high-level870  // end-to-end success assertion (T-A33) with the structural proof (T-A34):871  // a payload whose combined raw exceeds the 768 KiB wire ceiling succeeds872  // because the duress slot rides its own frame instead of piggybacking on873  // the entries final chunk. Pre-v4.6.4, duress counted against the entries874  // frame, so a single frame would exceed SNAPSHOT_FRAME_WIRE_CEILING and the875  // receiver would reject the whole snapshot with ADOPT_SNAPSHOT_CHUNK_TOO_LARGE.876  it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => {877    const [source, receiver] = memoryPair();878    // Single entry that is ~450 KiB on its own; combined with a ~640 KiB879    // duress JSON, the total payload exceeds the 768 KiB wire ceiling.880    const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };881    const tombstones: Record<string, number> = { "dead-0": 1 };882    const duressJson = JSON.stringify({883      version: 1,884      salt: "aa".repeat(16),885      nonce: "bb".repeat(12),886      ciphertext: "cc".repeat(320 * 1024),887      tag: "dd".repeat(16),888    });889    const CEILING = 768 * 1024;890891    // Probe the wire so we can assert the structural shape alongside the892    // end-to-end success.893    const observed: Array<{894      size: number;895      wireSize: number;896      hasDuress: boolean;897      entriesKeys: number;898    }> = [];899    const probe: PairingTransport = {900      async send(msg) {901        const raw = unpadSyncFrame(msg);902        const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;903        observed.push({904          size: raw.length,905          wireSize: msg.length,906          hasDuress: typeof f.duress_slot === "string",907          entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,908        });909        return source.send(msg);910      },911      receive() { return source.receive(); },912    };913914    const [result] = await Promise.all([915      receiveVaultSnapshot(receiver),916      sendVaultSnapshot(probe, entries, tombstones, duressJson),917    ]);918919    // High-level success: the receiver accepts the >768 KiB combined payload.920    expect(result.entries).toEqual(entries);921    expect(result.tombstones).toEqual(tombstones);922    expect(result.duressSlot).toBe(duressJson);923924    // Root-cause proof: duress is on its own frame, not on the entries frame.925    // Every frame stays under the ceiling; only the combined raw exceeds it.926    expect(observed.length).toBe(2);927    expect(observed[0].hasDuress).toBe(false);928    expect(observed[0].entriesKeys).toBe(1);929    expect(observed[0].size).toBeLessThan(CEILING);930    expect(observed[1].hasDuress).toBe(true);931    expect(observed[1].entriesKeys).toBe(0);932    expect(observed[1].size).toBeLessThan(CEILING);933    const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({934      type: "ADOPT_SNAPSHOT",935      chunk_index: 0,936      chunk_count: 1,937      entries,938      tombstones,939      duress_slot: duressJson,940    })).length;941    expect(legacyCombinedSize).toBeGreaterThan(CEILING);942  });943});944945/**946 * PB-26-603 — adoption frames carry a peer-supplied `device_name` straight into947 * `chrome.storage.local`. The manifest path caps it at 64 chars; adoption did948 * not, so one name could be ~16Kx larger than the manifest bound.949 */950describe("PB-26-603 adoption device_name cap", () => {951  it("caps a 500-char peer device_name to 64 chars on both PairedDevice records", async () => {952    const longName = "X".repeat(500);953    const [receiverChannel, sourceChannel] = memoryPair();954    const rootKeyHex = "13".repeat(32);955    const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));956    const receiverUpsert = vi.fn();957    const sourceUpsert = vi.fn();958959    const receiverDeps: AdoptionReceiverDeps = {960      deviceName: "receiver",961      generateDeviceId: () => "receiver-ephemeral",962      confirmSas: vi.fn(async () => true),963      createVaultFromRootKey: vi.fn(async () => ({ vaultId })),964      deriveLocalIdentity: vi.fn(async () => ({965        deviceId: "receiver-id",966        deviceName: longName,967        deviceType: "extension" as const,968        longTermPublicKeyHex: "ab".repeat(32),969        signingPublicKeyHex: "cd".repeat(32),970      })),971      upsertPairedDevice: receiverUpsert,972      applyLicense: vi.fn(),973      onError: vi.fn(),974    };975    const sourceDeps: AdoptionSourceDeps = {976      sourceIdentity: {977        deviceId: "source-id",978        deviceName: longName,979        deviceType: "extension" as const,980        longTermPublicKeyHex: "ef".repeat(32),981        signingPublicKeyHex: "01".repeat(32),982      },983      rootKeyHex: vi.fn(async () => rootKeyHex),984      vaultId: vi.fn(async () => vaultId),985      license: vi.fn(async () => "PPK-test"),986      onConsent: vi.fn(async () => true),987      upsertPairedDevice: sourceUpsert,988    };989990    await Promise.all([991      requestAdoption(receiverChannel, receiverDeps),992      (async () => {993        const request = JSON.parse(994          new TextDecoder().decode(await sourceChannel.receive()),995        ) as AdoptRequest;996        await grantAdoption(sourceChannel, request, sourceDeps);997      })(),998    ]);9991000    const storedReceiver = receiverUpsert.mock.calls[0]?.[0] as { deviceName: string };1001    const storedSource = sourceUpsert.mock.calls[0]?.[0] as { deviceName: string };1002    expect(storedReceiver.deviceName).toBe(longName.slice(0, 64));1003    expect(storedSource.deviceName).toBe(longName.slice(0, 64));1004  });1005});