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