import { describe, expect, it, vi } from "vitest"; import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding"; import { computeVaultId } from "./sync-identity"; import { grantAdoption, isAdoptGrant, requestAdoption, sendFrame, sendVaultSnapshot, receiveVaultSnapshot, type AdoptRequest, type AdoptionReceiverDeps, type AdoptionSourceDeps, } from "./vault-adopt"; import type { PairingTransport } from "./pairing-handshake"; function memoryPair(): [PairingTransport, PairingTransport] { const a: Uint8Array[] = []; const b: Uint8Array[] = []; const ar: Array<(v: Uint8Array) => void> = []; const br: Array<(v: Uint8Array) => void> = []; function make( inbox: Uint8Array[], outbox: Uint8Array[], readers: Array<(v: Uint8Array) => void>, outReaders: Array<(v: Uint8Array) => void>, ): PairingTransport { return { async send(msg) { const reader = outReaders.shift(); if (reader) reader(msg); else outbox.push(msg); }, receive() { const msg = inbox.shift(); if (msg) return Promise.resolve(msg); return new Promise((resolve) => readers.push(resolve)); }, }; } return [make(a, b, ar, br), make(b, a, br, ar)]; } function identity(name: string) { return { deviceId: `${name}-id`, deviceName: name, deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32), signingPublicKeyHex: "cd".repeat(32), }; } /** Snapshot frames are padded on the wire (PB-26-604), so hand-built ones * must go through padSyncFrame exactly as sendVaultSnapshot does. */ function sendSnapshotFrame(channel: PairingTransport, frame: unknown): Promise { return channel.send(padSyncFrame(new TextEncoder().encode(JSON.stringify(frame)))); } describe("vault adoption sub-protocol", () => { it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "11".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const sourceUpsert = vi.fn(); const receiverUpsert = vi.fn(); const pairState = { rendezvousSecretHex: "42".repeat(32), relayUrl: "wss://relay.example.test", }; const receiverDeps: AdoptionReceiverDeps = { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey: vi.fn(async () => ({ vaultId })), deriveLocalIdentity: vi.fn(async () => identity("receiver")), upsertPairedDevice: receiverUpsert, applyLicense: vi.fn(), pairState, onError: vi.fn(), }; const sourceDeps: AdoptionSourceDeps = { sourceIdentity: identity("source"), rootKeyHex: vi.fn(async () => rootKeyHex), vaultId: vi.fn(async () => vaultId), license: vi.fn(async () => "PPK-test"), onConsent: vi.fn(async () => true), upsertPairedDevice: sourceUpsert, pairState, }; await Promise.all([ requestAdoption(receiverChannel, receiverDeps), (async () => { const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest; await grantAdoption(sourceChannel, request, sourceDeps); })(), ]); expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex); expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "source-id", ...pairState, })); expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({ deviceId: "receiver-id", ...pairState, })); expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test"); }); it("T-A10c: rotates a copied browser identity when the source reports a device ID collision", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "13".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const copiedIdentity = identity("chrome"); const freshIdentity = identity("firefox"); const rotateLocalIdentity = vi.fn(async () => freshIdentity); const receiverDeps: AdoptionReceiverDeps = { deviceName: "Firefox", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey: vi.fn(async () => ({ vaultId })), deriveLocalIdentity: vi.fn(async () => copiedIdentity), rotateLocalIdentity, upsertPairedDevice: vi.fn(), onError: vi.fn(), }; const receiverPromise = requestAdoption(receiverChannel, receiverDeps); await sourceChannel.receive(); // ADOPT_REQUEST await sourceChannel.receive(); // ADOPT_CONFIRM await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "android-id", device_name: "Android", device_type: "android", long_term_public_key_hex: "ef".repeat(32), }, }); const firstIdentity = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())); expect(firstIdentity.device_id).toBe("chrome-id"); await sendFrame(sourceChannel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION", }); const secondIdentity = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())); expect(secondIdentity.device_id).toBe("firefox-id"); await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" }); await sourceChannel.receive(); // ADOPT_APPLIED expect(await receiverPromise).not.toBeNull(); expect(rotateLocalIdentity).toHaveBeenCalledOnce(); expect(receiverDeps.onError).not.toHaveBeenCalled(); }); it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "12".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const createVault = vi.fn(); const receiverUpsert = vi.fn(); const sourceUpsert = vi.fn(); const onError = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Free receiver", generateDeviceId: () => "free-receiver", confirmSas: vi.fn(async () => true), license: vi.fn(async () => undefined), createVaultFromRootKey: createVault, deriveLocalIdentity: vi.fn(), upsertPairedDevice: receiverUpsert, onError, }); const request = JSON.parse( new TextDecoder().decode(await sourceChannel.receive()), ) as AdoptRequest; const source = grantAdoption(sourceChannel, request, { sourceIdentity: identity("free-source"), rootKeyHex: vi.fn(async () => rootKeyHex), vaultId: vi.fn(async () => vaultId), license: vi.fn(async () => undefined), onConsent: vi.fn(async () => true), upsertPairedDevice: sourceUpsert, }); await expect(receiver).resolves.toBeNull(); await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED"); expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED"); expect(createVault).not.toHaveBeenCalled(); expect(receiverUpsert).not.toHaveBeenCalled(); expect(sourceUpsert).not.toHaveBeenCalled(); }); it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "13".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); let sourceLicense: string | undefined; const sourceApply = vi.fn(async (license: string) => { sourceLicense = license; }); const receiver = requestAdoption(receiverChannel, { deviceName: "Pro receiver", generateDeviceId: () => "pro-receiver", confirmSas: vi.fn(async () => true), license: vi.fn(async () => "PPK-receiver"), createVaultFromRootKey: vi.fn(async () => ({ vaultId })), deriveLocalIdentity: vi.fn(async () => identity("receiver")), upsertPairedDevice: vi.fn(), applyLicense: vi.fn(), onError: vi.fn(), }); const request = JSON.parse( new TextDecoder().decode(await sourceChannel.receive()), ) as AdoptRequest; const source = grantAdoption(sourceChannel, request, { sourceIdentity: identity("free-source"), rootKeyHex: vi.fn(async () => rootKeyHex), vaultId: vi.fn(async () => vaultId), license: vi.fn(async () => sourceLicense), applyLicense: sourceApply, onConsent: vi.fn(async () => true), upsertPairedDevice: vi.fn(), }); await expect(Promise.all([receiver, source])).resolves.toEqual([ { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true }, expect.objectContaining({ deviceId: "receiver-id" }), ]); expect(sourceApply).toHaveBeenCalledWith("PPK-receiver"); }); it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = vi.fn(async () => "11".repeat(32)); const createVaultFromRootKey = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => false), createVaultFromRootKey, deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), onError: vi.fn(), }); const request = JSON.parse( new TextDecoder().decode(await sourceChannel.receive()), ) as AdoptRequest; const source = grantAdoption(sourceChannel, request, { sourceIdentity: identity("source"), rootKeyHex, vaultId: vi.fn(), onConsent: vi.fn(async () => true), upsertPairedDevice: vi.fn(), }); await expect(receiver).resolves.toBeNull(); await expect(source).resolves.toBeNull(); expect(rootKeyHex).not.toHaveBeenCalled(); expect(createVaultFromRootKey).not.toHaveBeenCalled(); }); it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const createVaultFromRootKey = vi.fn(); const errors: string[] = []; const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey, deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), onError: (err) => errors.push(err), }); const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest; await grantAdoption(sourceChannel, request, { sourceIdentity: identity("source"), rootKeyHex: vi.fn(), vaultId: vi.fn(), onConsent: vi.fn(async () => false), upsertPairedDevice: vi.fn(), }); await Promise.race([receiver, Promise.resolve()]); expect(createVaultFromRootKey).not.toHaveBeenCalled(); expect(errors).toEqual([]); }); it("T-A13: invalid root_key_hex aborts gracefully", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const onError = vi.fn(); const createVaultFromRootKey = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey, deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), onError, }); await sourceChannel.receive(); await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: "bad", vault_id: "00".repeat(32), source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) }, } as never); await receiver; expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME"); expect(createVaultFromRootKey).not.toHaveBeenCalled(); }); it("T-A14: vault_id mismatch aborts before vault creation", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const onError = vi.fn(); const createVaultFromRootKey = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey, deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), onError, }); await sourceChannel.receive(); await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: "22".repeat(32), vault_id: "00".repeat(32), source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) }, }); await receiver; expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH"); expect(createVaultFromRootKey).not.toHaveBeenCalled(); }); it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const upsert = vi.fn(); const promise = grantAdoption(sourceChannel, { type: "ADOPT_REQUEST", device_id: "receiver-ephemeral", device_name: "Receiver", device_type: "extension", }, { sourceIdentity: identity("source"), rootKeyHex: vi.fn(async () => "33".repeat(32)), vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))), license: vi.fn(async () => "PPK-test"), onConsent: vi.fn(async () => true), upsertPairedDevice: upsert, }); await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true, }); await Promise.race([promise, Promise.resolve()]); expect(upsert).not.toHaveBeenCalled(); }); it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "66".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const receiverUpsert = vi.fn(); const onError = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), license: vi.fn(async () => "PPK-local"), createVaultFromRootKey: vi.fn(async () => ({ vaultId })), deriveLocalIdentity: vi.fn(async () => identity("receiver")), upsertPairedDevice: receiverUpsert, onError, }); await sourceChannel.receive(); // ADOPT_REQUEST await sourceChannel.receive(); // ADOPT_CONFIRM await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, source: { device_id: "source-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32), }, }); await sourceChannel.receive(); // ADOPT_IDENTITY await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true }); await expect(receiver).resolves.toBeNull(); expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING"); expect(receiverUpsert).not.toHaveBeenCalled(); }); it("T-A17: receiver safety policy runs before vault creation", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "77".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const createVaultFromRootKey = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), license: vi.fn(async () => "PPK-local"), validateSource: vi.fn(async () => { throw new Error("JOIN_POPULATED_VAULT_BLOCKED"); }), createVaultFromRootKey, deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), onError: vi.fn(), }); await sourceChannel.receive(); // ADOPT_REQUEST await sourceChannel.receive(); // ADOPT_CONFIRM await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, source: { device_id: "new-source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32), }, }); await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED"); expect(createVaultFromRootKey).not.toHaveBeenCalled(); }); it("T-A31: does not apply a license if vault creation fails", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "44".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const applyLicense = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey: vi.fn(async () => { throw new Error("commit_failed"); }), deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), applyLicense, onError: vi.fn(), }); await sourceChannel.receive(); await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) }, }); await expect(receiver).rejects.toThrow("commit_failed"); expect(applyLicense).not.toHaveBeenCalled(); }); it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const createVaultFromRootKey = vi.fn(); const receiver = requestAdoption(receiverChannel, { deviceName: "Receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey, deriveLocalIdentity: vi.fn(), upsertPairedDevice: vi.fn(), applyLicense: vi.fn(), onError: vi.fn(), }); await sourceChannel.receive(); await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: "55".repeat(32), vault_id: "99".repeat(32), license: "PPK-test", source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) }, }); await receiver; expect(createVaultFromRootKey).not.toHaveBeenCalled(); }); }); describe("snapshot_capable echo on ADOPT_GRANT", () => { function validGrant() { return { type: "ADOPT_GRANT" as const, root_key_hex: "11".repeat(32), vault_id: "22".repeat(32), license: "PPK-test", source: { device_id: "src-id", device_name: "Source", device_type: "extension" as const, long_term_public_key_hex: "ab".repeat(32), }, }; } it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => { expect(isAdoptGrant(validGrant())).toBe(true); }); it("isAdoptGrant accepts snapshot_capable: true", () => { expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true); }); it("isAdoptGrant accepts snapshot_capable: false", () => { expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true); }); it("isAdoptGrant rejects snapshot_capable with wrong type", () => { expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false); }); it("grantAdoption echoes snapshot_capable: true when request had it", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "33".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const sourceDeps: AdoptionSourceDeps = { sourceIdentity: identity("source"), rootKeyHex: vi.fn(async () => rootKeyHex), vaultId: vi.fn(async () => vaultId), license: vi.fn(async () => "PPK-test"), onConsent: vi.fn(async () => true), upsertPairedDevice: vi.fn(), }; // Run grantAdoption concurrently with the test driver that feeds protocol frames. const grantPromise = grantAdoption(sourceChannel, { type: "ADOPT_REQUEST", device_id: "recv-id", device_name: "Receiver", device_type: "extension", snapshot_capable: true, }, sourceDeps); // grantAdoption: consent=true → reads ADOPT_CONFIRM → sends ADOPT_GRANT → reads ADOPT_IDENTITY await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true }); const grantBytes = await receiverChannel.receive(); const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes)); expect(grantFrame.type).toBe("ADOPT_GRANT"); expect(grantFrame.snapshot_capable).toBe(true); // Let grantAdoption complete: send ADOPT_IDENTITY, read ADOPT_COMPLETE, send ADOPT_APPLIED. await sendFrame(receiverChannel, { type: "ADOPT_IDENTITY", device_id: "recv-id", device_name: "Receiver", device_type: "extension", long_term_public_key_hex: "ab".repeat(32), }); await receiverChannel.receive(); // ADOPT_COMPLETE await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" }); await grantPromise; }); it("grantAdoption sets snapshot_capable: false when request lacked it", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "44".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const sourceDeps: AdoptionSourceDeps = { sourceIdentity: identity("source"), rootKeyHex: vi.fn(async () => rootKeyHex), vaultId: vi.fn(async () => vaultId), license: vi.fn(async () => "PPK-test"), onConsent: vi.fn(async () => true), upsertPairedDevice: vi.fn(), }; const grantPromise = grantAdoption(sourceChannel, { type: "ADOPT_REQUEST", device_id: "recv-id", device_name: "Receiver", device_type: "extension", // NO snapshot_capable }, sourceDeps); await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true }); const grantBytes = await receiverChannel.receive(); const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes)); expect(grantFrame.type).toBe("ADOPT_GRANT"); expect(grantFrame.snapshot_capable).toBe(false); await sendFrame(receiverChannel, { type: "ADOPT_IDENTITY", device_id: "recv-id", device_name: "Receiver", device_type: "extension", long_term_public_key_hex: "ab".repeat(32), }); await receiverChannel.receive(); // ADOPT_COMPLETE await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" }); await grantPromise; }); it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "55".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const deps: AdoptionReceiverDeps = { deviceName: "recv", generateDeviceId: () => "recv-id", async confirmSas() { return true; }, async createVaultFromRootKey() { return { vaultId }; }, async deriveLocalIdentity() { return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) }; }, async upsertPairedDevice() {}, onError() {}, }; const receiverPromise = requestAdoption(receiverChannel, deps); // requestAdoption sends: ADOPT_REQUEST, ADOPT_CONFIRM (after SAS auto-accept) // Then reads: ADOPT_GRANT, sends: ADOPT_IDENTITY, reads: ADOPT_COMPLETE, sends: ADOPT_APPLIED await sourceChannel.receive(); // ADOPT_REQUEST await sourceChannel.receive(); // ADOPT_CONFIRM await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) }, snapshot_capable: true, }); await sourceChannel.receive(); // ADOPT_IDENTITY await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" }); await sourceChannel.receive(); // ADOPT_APPLIED const result = await receiverPromise; expect(result).not.toBeNull(); expect(result!.snapshotCapable).toBe(true); }); it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => { const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "66".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const deps: AdoptionReceiverDeps = { deviceName: "recv", generateDeviceId: () => "recv-id", async confirmSas() { return true; }, async createVaultFromRootKey() { return { vaultId }; }, async deriveLocalIdentity() { return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) }; }, async upsertPairedDevice() {}, onError() {}, }; const receiverPromise = requestAdoption(receiverChannel, deps); await sourceChannel.receive(); // ADOPT_REQUEST await sourceChannel.receive(); // ADOPT_CONFIRM await sendFrame(sourceChannel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: vaultId, license: "PPK-test", source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) }, // NO snapshot_capable (pre-C source) }); await sourceChannel.receive(); // ADOPT_IDENTITY await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" }); await sourceChannel.receive(); // ADOPT_APPLIED const result = await receiverPromise; expect(result).not.toBeNull(); expect(result!.snapshotCapable).toBe(false); }); // T-A33: post-fix regression for the SNAPSHOT_FRAME_WIRE_CEILING (768 KiB) // bug. Before v4.6.4, duress rode the entries final chunk, so an entry + // duress whose combined raw exceeded 768 KiB threw // ADOPT_SNAPSHOT_CHUNK_TOO_LARGE on receive. The frame-ceiling fix moves // duress onto its own frame after the entries chunks; this case asserts the // root-cause fix rather than just the new wire path. it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => { const [source, receiver] = memoryPair(); const entries: Record = { "id-0": "x".repeat(450 * 1024) }; const tombstones: Record = { "dead-0": 1 }; // 320 KiB of ciphertext JSON ("cc".repeat(320 * 1024) is ~640 KiB of JSON // text — the receiver treats duress_slot as opaque and concatenates it // raw; ~640 KiB JSON pushes combined raw over 768 KiB once the entry and // envelope are added). Pre-v4.6.4 this duress rode the entries final // chunk and pushed that frame over the ceiling; post-fix the duress // frame is sent on its own and stays under the ceiling. const duressJson = JSON.stringify({ version: 1, salt: "aa".repeat(16), nonce: "bb".repeat(12), ciphertext: "cc".repeat(320 * 1024), tag: "dd".repeat(16), }); const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, entries, tombstones, duressJson), ]); expect(result.entries).toEqual(entries); expect(result.tombstones).toEqual(tombstones); expect(result.duressSlot).toBe(duressJson); }); // T-A34: structural proof of the duress-on-separate-frame wire change. // Duress must leave the entries chunk so the entries frame stays under the // 768 KiB ceiling, even when the combined payload would exceed it. it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => { const [source] = memoryPair(); const observed: Array<{ size: number; wireSize: number; hasDuress: boolean; entriesKeys: number; }> = []; const probe: PairingTransport = { async send(msg) { const raw = unpadSyncFrame(msg); const f = JSON.parse(new TextDecoder().decode(raw)) as Record; observed.push({ size: raw.length, wireSize: msg.length, hasDuress: typeof f.duress_slot === "string", entriesKeys: Object.keys((f.entries as Record) ?? {}).length, }); return source.send(msg); }, receive() { return source.receive(); }, }; const entries: Record = { "id-0": "x".repeat(450 * 1024) }; const duressJson = "y".repeat(320 * 1024); const CEILING = 768 * 1024; await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson); expect(observed.length).toBe(2); // Entries frame: no duress, one entries key, well under 768 KiB. expect(observed[0].hasDuress).toBe(false); expect(observed[0].entriesKeys).toBe(1); expect(observed[0].size).toBeLessThan(CEILING); // Duress frame: duress present, no entries, well under 768 KiB. expect(observed[1].hasDuress).toBe(true); expect(observed[1].entriesKeys).toBe(0); expect(observed[1].size).toBeLessThan(CEILING); // Combined raw exceeds the ceiling, which is the point of the split. expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING); // PB-26-604: both frames left the wire bucket-padded, so the relay sees // neither exact plaintext length. for (const frame of observed) { expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4)); } }); // T-A35: backward compat — a pre-v4.6.4 sender (duress on the entries // final chunk) still decodes correctly. The receiver's per-chunk // Object.assign (entries / tombstones) plus duress-slot capture tolerates // both shapes. it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => { const [source, receiver] = memoryPair(); const duressJson = JSON.stringify({ version: 1, salt: "aa".repeat(16), nonce: "bb".repeat(12), ciphertext: "cc".repeat(32), tag: "dd".repeat(16), }); // Manually construct the legacy wire shape: a single chunk with both // the entries, the tombstones, AND duress_slot on it. await sendSnapshotFrame(source, { type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { "id-0": "ciphertext" }, tombstones: { "dead-0": 42 }, duress_slot: duressJson, }); const result = await receiveVaultSnapshot(receiver); expect(result.entries).toEqual({ "id-0": "ciphertext" }); expect(result.tombstones).toEqual({ "dead-0": 42 }); expect(result.duressSlot).toBe(duressJson); }); // T-A36: cap boundary inclusive. With 199 entries and a duress slot, the // sender must fit everything within MAX_SNAPSHOT_CHUNKS = 200 (199 entries // + 1 duress = 200 frames). Duress MUST arrive intact — sync does not // carry duress, so the adopted device would permanently lack its duress // vault otherwise. Entries are 40 KiB so the ~8 MiB total also fits the // PB-26-206 10 MiB aggregate budget. it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => { const [source, receiver] = memoryPair(); const duressJson = JSON.stringify({ version: 1, salt: "aa".repeat(16), nonce: "bb".repeat(12), ciphertext: "cc".repeat(32), tag: "dd".repeat(16), }); const big = "x".repeat(40 * 1024); const entries: Record = {}; for (let i = 0; i < 199; i++) entries[`id-${i}`] = big; // First sanity: 199 entries needs multiple chunks (each ~40 KiB entry // packs ~12 per 512 KiB chunk). Verify the chunking math is sane before // asserting the duress-reservation behavior. const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson), ]); expect(result.entries).toEqual(entries); expect(result.tombstones).toEqual({ "dead-0": 1 }); expect(result.duressSlot).toBe(duressJson); }); // T-A37: PB-26-206 supersedes the old chunk-cap truncation. An over-budget // snapshot is never partially sent: the sender preflights with the same // aggregate helper the receiver enforces and throws before the first frame, // so the caller falls through to the regular sync pipeline instead of // sending a truncated snapshot. it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => { const [source] = memoryPair(); let frames = 0; const countingSource: PairingTransport = { async send(msg) { frames++; return source.send(msg); }, receive() { return source.receive(); }, }; const duressJson = JSON.stringify({ version: 1, salt: "aa".repeat(16), nonce: "bb".repeat(12), ciphertext: "cc".repeat(32), tag: "dd".repeat(16), }); // 200 entries × 60 KiB ≈ 12 MiB > 10 MiB aggregate budget. const big = "x".repeat(60 * 1024); const entries: Record = {}; for (let i = 0; i < 200; i++) entries[`id-${i}`] = big; await expect( sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson), ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET"); expect(frames).toBe(0); }); // T-A38: the single test that proves the *root-cause fix* for the frame // ceiling bug, not just the new wire path. Combines the high-level // end-to-end success assertion (T-A33) with the structural proof (T-A34): // a payload whose combined raw exceeds the 768 KiB wire ceiling succeeds // because the duress slot rides its own frame instead of piggybacking on // the entries final chunk. Pre-v4.6.4, duress counted against the entries // frame, so a single frame would exceed SNAPSHOT_FRAME_WIRE_CEILING and the // receiver would reject the whole snapshot with ADOPT_SNAPSHOT_CHUNK_TOO_LARGE. it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => { const [source, receiver] = memoryPair(); // Single entry that is ~450 KiB on its own; combined with a ~640 KiB // duress JSON, the total payload exceeds the 768 KiB wire ceiling. const entries: Record = { "id-0": "x".repeat(450 * 1024) }; const tombstones: Record = { "dead-0": 1 }; const duressJson = JSON.stringify({ version: 1, salt: "aa".repeat(16), nonce: "bb".repeat(12), ciphertext: "cc".repeat(320 * 1024), tag: "dd".repeat(16), }); const CEILING = 768 * 1024; // Probe the wire so we can assert the structural shape alongside the // end-to-end success. const observed: Array<{ size: number; wireSize: number; hasDuress: boolean; entriesKeys: number; }> = []; const probe: PairingTransport = { async send(msg) { const raw = unpadSyncFrame(msg); const f = JSON.parse(new TextDecoder().decode(raw)) as Record; observed.push({ size: raw.length, wireSize: msg.length, hasDuress: typeof f.duress_slot === "string", entriesKeys: Object.keys((f.entries as Record) ?? {}).length, }); return source.send(msg); }, receive() { return source.receive(); }, }; const [result] = await Promise.all([ receiveVaultSnapshot(receiver), sendVaultSnapshot(probe, entries, tombstones, duressJson), ]); // High-level success: the receiver accepts the >768 KiB combined payload. expect(result.entries).toEqual(entries); expect(result.tombstones).toEqual(tombstones); expect(result.duressSlot).toBe(duressJson); // Root-cause proof: duress is on its own frame, not on the entries frame. // Every frame stays under the ceiling; only the combined raw exceeds it. expect(observed.length).toBe(2); expect(observed[0].hasDuress).toBe(false); expect(observed[0].entriesKeys).toBe(1); expect(observed[0].size).toBeLessThan(CEILING); expect(observed[1].hasDuress).toBe(true); expect(observed[1].entriesKeys).toBe(0); expect(observed[1].size).toBeLessThan(CEILING); const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries, tombstones, duress_slot: duressJson, })).length; expect(legacyCombinedSize).toBeGreaterThan(CEILING); }); }); /** * PB-26-603 — adoption frames carry a peer-supplied `device_name` straight into * `chrome.storage.local`. The manifest path caps it at 64 chars; adoption did * not, so one name could be ~16Kx larger than the manifest bound. */ describe("PB-26-603 adoption device_name cap", () => { it("caps a 500-char peer device_name to 64 chars on both PairedDevice records", async () => { const longName = "X".repeat(500); const [receiverChannel, sourceChannel] = memoryPair(); const rootKeyHex = "13".repeat(32); const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex"))); const receiverUpsert = vi.fn(); const sourceUpsert = vi.fn(); const receiverDeps: AdoptionReceiverDeps = { deviceName: "receiver", generateDeviceId: () => "receiver-ephemeral", confirmSas: vi.fn(async () => true), createVaultFromRootKey: vi.fn(async () => ({ vaultId })), deriveLocalIdentity: vi.fn(async () => ({ deviceId: "receiver-id", deviceName: longName, deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32), signingPublicKeyHex: "cd".repeat(32), })), upsertPairedDevice: receiverUpsert, applyLicense: vi.fn(), onError: vi.fn(), }; const sourceDeps: AdoptionSourceDeps = { sourceIdentity: { deviceId: "source-id", deviceName: longName, deviceType: "extension" as const, longTermPublicKeyHex: "ef".repeat(32), signingPublicKeyHex: "01".repeat(32), }, rootKeyHex: vi.fn(async () => rootKeyHex), vaultId: vi.fn(async () => vaultId), license: vi.fn(async () => "PPK-test"), onConsent: vi.fn(async () => true), upsertPairedDevice: sourceUpsert, }; await Promise.all([ requestAdoption(receiverChannel, receiverDeps), (async () => { const request = JSON.parse( new TextDecoder().decode(await sourceChannel.receive()), ) as AdoptRequest; await grantAdoption(sourceChannel, request, sourceDeps); })(), ]); const storedReceiver = receiverUpsert.mock.calls[0]?.[0] as { deviceName: string }; const storedSource = sourceUpsert.mock.calls[0]?.[0] as { deviceName: string }; expect(storedReceiver.deviceName).toBe(longName.slice(0, 64)); expect(storedSource.deviceName).toBe(longName.slice(0, 64)); }); });