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}53545556function 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(); 132 await sourceChannel.receive(); 133 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(); 156157 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(); 393 await sourceChannel.receive(); 394 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(); 406 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(); 433 await sourceChannel.receive(); 434 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 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 562 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 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(); 577 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 600 }, 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(); 616 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 638 639 await sourceChannel.receive(); 640 await sourceChannel.receive(); 641 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(); 650 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });651 await sourceChannel.receive(); 652653 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(); 676 await sourceChannel.receive(); 677 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 684 });685 await sourceChannel.receive(); 686 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });687 await sourceChannel.receive(); 688689 const result = await receiverPromise;690 expect(result).not.toBeNull();691 expect(result!.snapshotCapable).toBe(false);692 });693694 695 696 697 698 699 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 705 706 707 708 709 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 729 730 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 761 expect(observed[0].hasDuress).toBe(false);762 expect(observed[0].entriesKeys).toBe(1);763 expect(observed[0].size).toBeLessThan(CEILING);764 765 expect(observed[1].hasDuress).toBe(true);766 expect(observed[1].entriesKeys).toBe(0);767 expect(observed[1].size).toBeLessThan(CEILING);768 769 expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);770 771 772 for (const frame of observed) {773 expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4));774 }775 });776777 778 779 780 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 791 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 807 808 809 810 811 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 825 826 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 837 838 839 840 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 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 869 870 871 872 873 874 875 876 it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => {877 const [source, receiver] = memoryPair();878 879 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 892 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 920 expect(result.entries).toEqual(entries);921 expect(result.tombstones).toEqual(tombstones);922 expect(result.duressSlot).toBe(duressJson);923924 925 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});944945946947948949950describe("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});