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-A10d: receiver rejects a revoked source identity and source rotates", async () => {163 const [receiverChannel, sourceChannel] = memoryPair();164 const rootKeyHex = "14".repeat(32);165 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));166 const revokedSourceIdentity = identity("chrome");167 const freshSourceIdentity = identity("chrome-fresh");168 const receiverUpsert = vi.fn();169 const receiverDeps: AdoptionReceiverDeps = {170 deviceName: "Android",171 generateDeviceId: () => "receiver-ephemeral",172 confirmSas: vi.fn(async () => true),173 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),174 deriveLocalIdentity: vi.fn(async () => identity("receiver")),175 upsertPairedDevice: receiverUpsert,176 hasSourceIdentityCollision: vi.fn(async (source) =>177 source.device_id === revokedSourceIdentity.deviceId,178 ),179 onError: vi.fn(),180 };181182 const receiverPromise = requestAdoption(receiverChannel, receiverDeps);183 await sourceChannel.receive(); 184 await sourceChannel.receive(); 185186 187 await sendFrame(sourceChannel, {188 type: "ADOPT_GRANT",189 root_key_hex: rootKeyHex,190 vault_id: vaultId,191 license: "PPK-test",192 source: {193 device_id: revokedSourceIdentity.deviceId,194 device_name: revokedSourceIdentity.deviceName,195 device_type: revokedSourceIdentity.deviceType,196 long_term_public_key_hex: revokedSourceIdentity.longTermPublicKeyHex,197 },198 });199200 201 const firstResponse = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));202 expect(firstResponse.type).toBe("ADOPT_REJECT");203 expect(firstResponse.reason).toBe("ADOPT_DEVICE_ID_COLLISION");204205 206 await sendFrame(sourceChannel, {207 type: "ADOPT_GRANT",208 root_key_hex: rootKeyHex,209 vault_id: vaultId,210 license: "PPK-test",211 source: {212 device_id: freshSourceIdentity.deviceId,213 device_name: freshSourceIdentity.deviceName,214 device_type: freshSourceIdentity.deviceType,215 long_term_public_key_hex: freshSourceIdentity.longTermPublicKeyHex,216 },217 });218219 220 const identityFrame = JSON.parse(new TextDecoder().decode(await sourceChannel.receive()));221 expect(identityFrame.type).toBe("ADOPT_IDENTITY");222 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });223 await sourceChannel.receive(); 224225 const result = await receiverPromise;226 expect(result).not.toBeNull();227 expect(result!.device.deviceId).toBe(freshSourceIdentity.deviceId);228 expect(receiverDeps.hasSourceIdentityCollision).toHaveBeenCalledTimes(2);229 expect(receiverDeps.onError).not.toHaveBeenCalled();230 });231232 it("T-A10e: source detects a revoked receiver identity and forces rotation", async () => {233 const [receiverChannel, sourceChannel] = memoryPair();234 const rootKeyHex = "15".repeat(32);235 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));236 const collidingIdentity = identity("chrome");237 const freshIdentity = identity("firefox");238 const sourceUpsert = vi.fn();239 const sourceDeps: AdoptionSourceDeps = {240 sourceIdentity: identity("android"),241 rootKeyHex: vi.fn(async () => rootKeyHex),242 vaultId: vi.fn(async () => vaultId),243 license: vi.fn(async () => "PPK-test"),244 onConsent: vi.fn(async () => true),245 upsertPairedDevice: sourceUpsert,246 hasDeviceIdentityCollision: vi.fn(async (device) =>247 device.deviceId === collidingIdentity.deviceId,248 ),249 };250251 const sourcePromise = (async () => {252 const request = JSON.parse(253 new TextDecoder().decode(await sourceChannel.receive()),254 ) as AdoptRequest;255 return grantAdoption(sourceChannel, request, sourceDeps);256 })();257258 259 await sendFrame(receiverChannel, {260 type: "ADOPT_REQUEST",261 device_id: "receiver-ephemeral",262 device_name: "Chrome",263 device_type: "extension",264 license: "PPK-test",265 snapshot_capable: true,266 });267 await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });268269 270 const grant = JSON.parse(new TextDecoder().decode(await receiverChannel.receive()));271 expect(grant.type).toBe("ADOPT_GRANT");272273 274 await sendFrame(receiverChannel, {275 type: "ADOPT_IDENTITY",276 device_id: collidingIdentity.deviceId,277 device_name: collidingIdentity.deviceName,278 device_type: collidingIdentity.deviceType,279 long_term_public_key_hex: collidingIdentity.longTermPublicKeyHex,280 signing_public_key_hex: collidingIdentity.signingPublicKeyHex,281 });282283 284 const reject = JSON.parse(new TextDecoder().decode(await receiverChannel.receive()));285 expect(reject.type).toBe("ADOPT_REJECT");286 expect(reject.reason).toBe("ADOPT_DEVICE_ID_COLLISION");287288 289 await sendFrame(receiverChannel, {290 type: "ADOPT_IDENTITY",291 device_id: freshIdentity.deviceId,292 device_name: freshIdentity.deviceName,293 device_type: freshIdentity.deviceType,294 long_term_public_key_hex: freshIdentity.longTermPublicKeyHex,295 signing_public_key_hex: freshIdentity.signingPublicKeyHex,296 });297298 299 const complete = JSON.parse(new TextDecoder().decode(await receiverChannel.receive()));300 expect(complete.type).toBe("ADOPT_COMPLETE");301 await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });302303 const result = await sourcePromise;304 expect(result).not.toBeNull();305 expect(result!.deviceId).toBe(freshIdentity.deviceId);306 expect(sourceDeps.hasDeviceIdentityCollision).toHaveBeenCalledTimes(2);307 });308309 it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {310 const [receiverChannel, sourceChannel] = memoryPair();311 const rootKeyHex = "12".repeat(32);312 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));313 const createVault = vi.fn();314 const receiverUpsert = vi.fn();315 const sourceUpsert = vi.fn();316 const onError = vi.fn();317318 const receiver = requestAdoption(receiverChannel, {319 deviceName: "Free receiver",320 generateDeviceId: () => "free-receiver",321 confirmSas: vi.fn(async () => true),322 license: vi.fn(async () => undefined),323 createVaultFromRootKey: createVault,324 deriveLocalIdentity: vi.fn(),325 upsertPairedDevice: receiverUpsert,326 onError,327 });328 const request = JSON.parse(329 new TextDecoder().decode(await sourceChannel.receive()),330 ) as AdoptRequest;331 const source = grantAdoption(sourceChannel, request, {332 sourceIdentity: identity("free-source"),333 rootKeyHex: vi.fn(async () => rootKeyHex),334 vaultId: vi.fn(async () => vaultId),335 license: vi.fn(async () => undefined),336 onConsent: vi.fn(async () => true),337 upsertPairedDevice: sourceUpsert,338 });339340 await expect(receiver).resolves.toBeNull();341 await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");342 expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");343 expect(createVault).not.toHaveBeenCalled();344 expect(receiverUpsert).not.toHaveBeenCalled();345 expect(sourceUpsert).not.toHaveBeenCalled();346 });347348 it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {349 const [receiverChannel, sourceChannel] = memoryPair();350 const rootKeyHex = "13".repeat(32);351 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));352 let sourceLicense: string | undefined;353 const sourceApply = vi.fn(async (license: string) => {354 sourceLicense = license;355 });356357 const receiver = requestAdoption(receiverChannel, {358 deviceName: "Pro receiver",359 generateDeviceId: () => "pro-receiver",360 confirmSas: vi.fn(async () => true),361 license: vi.fn(async () => "PPK-receiver"),362 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),363 deriveLocalIdentity: vi.fn(async () => identity("receiver")),364 upsertPairedDevice: vi.fn(),365 applyLicense: vi.fn(),366 onError: vi.fn(),367 });368 const request = JSON.parse(369 new TextDecoder().decode(await sourceChannel.receive()),370 ) as AdoptRequest;371 const source = grantAdoption(sourceChannel, request, {372 sourceIdentity: identity("free-source"),373 rootKeyHex: vi.fn(async () => rootKeyHex),374 vaultId: vi.fn(async () => vaultId),375 license: vi.fn(async () => sourceLicense),376 applyLicense: sourceApply,377 onConsent: vi.fn(async () => true),378 upsertPairedDevice: vi.fn(),379 });380381 await expect(Promise.all([receiver, source])).resolves.toEqual([382 { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },383 expect.objectContaining({ deviceId: "receiver-id" }),384 ]);385 expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");386 });387388 it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {389 const [receiverChannel, sourceChannel] = memoryPair();390 const rootKeyHex = vi.fn(async () => "11".repeat(32));391 const createVaultFromRootKey = vi.fn();392 const receiver = requestAdoption(receiverChannel, {393 deviceName: "Receiver",394 generateDeviceId: () => "receiver-ephemeral",395 confirmSas: vi.fn(async () => false),396 createVaultFromRootKey,397 deriveLocalIdentity: vi.fn(),398 upsertPairedDevice: vi.fn(),399 onError: vi.fn(),400 });401 const request = JSON.parse(402 new TextDecoder().decode(await sourceChannel.receive()),403 ) as AdoptRequest;404 const source = grantAdoption(sourceChannel, request, {405 sourceIdentity: identity("source"),406 rootKeyHex,407 vaultId: vi.fn(),408 onConsent: vi.fn(async () => true),409 upsertPairedDevice: vi.fn(),410 });411412 await expect(receiver).resolves.toBeNull();413 await expect(source).resolves.toBeNull();414 expect(rootKeyHex).not.toHaveBeenCalled();415 expect(createVaultFromRootKey).not.toHaveBeenCalled();416 });417418 it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {419 const [receiverChannel, sourceChannel] = memoryPair();420 const createVaultFromRootKey = vi.fn();421 const errors: string[] = [];422 const receiver = requestAdoption(receiverChannel, {423 deviceName: "Receiver",424 generateDeviceId: () => "receiver-ephemeral",425 confirmSas: vi.fn(async () => true),426 createVaultFromRootKey,427 deriveLocalIdentity: vi.fn(),428 upsertPairedDevice: vi.fn(),429 onError: (err) => errors.push(err),430 });431 const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;432 await grantAdoption(sourceChannel, request, {433 sourceIdentity: identity("source"),434 rootKeyHex: vi.fn(),435 vaultId: vi.fn(),436 onConsent: vi.fn(async () => false),437 upsertPairedDevice: vi.fn(),438 });439 await Promise.race([receiver, Promise.resolve()]);440441 expect(createVaultFromRootKey).not.toHaveBeenCalled();442 expect(errors).toEqual([]);443 });444445 it("T-A13: invalid root_key_hex aborts gracefully", async () => {446 const [receiverChannel, sourceChannel] = memoryPair();447 const onError = vi.fn();448 const createVaultFromRootKey = vi.fn();449 const receiver = requestAdoption(receiverChannel, {450 deviceName: "Receiver",451 generateDeviceId: () => "receiver-ephemeral",452 confirmSas: vi.fn(async () => true),453 createVaultFromRootKey,454 deriveLocalIdentity: vi.fn(),455 upsertPairedDevice: vi.fn(),456 onError,457 });458 await sourceChannel.receive();459 await sendFrame(sourceChannel, {460 type: "ADOPT_GRANT",461 root_key_hex: "bad",462 vault_id: "00".repeat(32),463 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },464 } as never);465 await receiver;466467 expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");468 expect(createVaultFromRootKey).not.toHaveBeenCalled();469 });470471 it("T-A14: vault_id mismatch aborts before vault creation", async () => {472 const [receiverChannel, sourceChannel] = memoryPair();473 const onError = vi.fn();474 const createVaultFromRootKey = vi.fn();475 const receiver = requestAdoption(receiverChannel, {476 deviceName: "Receiver",477 generateDeviceId: () => "receiver-ephemeral",478 confirmSas: vi.fn(async () => true),479 createVaultFromRootKey,480 deriveLocalIdentity: vi.fn(),481 upsertPairedDevice: vi.fn(),482 onError,483 });484 await sourceChannel.receive();485 await sendFrame(sourceChannel, {486 type: "ADOPT_GRANT",487 root_key_hex: "22".repeat(32),488 vault_id: "00".repeat(32),489 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },490 });491 await receiver;492493 expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");494 expect(createVaultFromRootKey).not.toHaveBeenCalled();495 });496497 it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {498 const [receiverChannel, sourceChannel] = memoryPair();499 const upsert = vi.fn();500 const promise = grantAdoption(sourceChannel, {501 type: "ADOPT_REQUEST",502 device_id: "receiver-ephemeral",503 device_name: "Receiver",504 device_type: "extension",505 }, {506 sourceIdentity: identity("source"),507 rootKeyHex: vi.fn(async () => "33".repeat(32)),508 vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),509 license: vi.fn(async () => "PPK-test"),510 onConsent: vi.fn(async () => true),511 upsertPairedDevice: upsert,512 });513 await sendFrame(receiverChannel, {514 type: "ADOPT_CONFIRM",515 confirmed: true,516 });517518 await Promise.race([promise, Promise.resolve()]);519 expect(upsert).not.toHaveBeenCalled();520 });521522 it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {523 const [receiverChannel, sourceChannel] = memoryPair();524 const rootKeyHex = "66".repeat(32);525 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));526 const receiverUpsert = vi.fn();527 const onError = vi.fn();528 const receiver = requestAdoption(receiverChannel, {529 deviceName: "Receiver",530 generateDeviceId: () => "receiver-ephemeral",531 confirmSas: vi.fn(async () => true),532 license: vi.fn(async () => "PPK-local"),533 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),534 deriveLocalIdentity: vi.fn(async () => identity("receiver")),535 upsertPairedDevice: receiverUpsert,536 onError,537 });538539 await sourceChannel.receive(); 540 await sourceChannel.receive(); 541 await sendFrame(sourceChannel, {542 type: "ADOPT_GRANT",543 root_key_hex: rootKeyHex,544 vault_id: vaultId,545 source: {546 device_id: "source-id",547 device_name: "Source",548 device_type: "extension",549 long_term_public_key_hex: "ab".repeat(32),550 },551 });552 await sourceChannel.receive(); 553 await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });554555 await expect(receiver).resolves.toBeNull();556 expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");557 expect(receiverUpsert).not.toHaveBeenCalled();558 });559560 it("T-A17: receiver safety policy runs before vault creation", async () => {561 const [receiverChannel, sourceChannel] = memoryPair();562 const rootKeyHex = "77".repeat(32);563 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));564 const createVaultFromRootKey = vi.fn();565 const receiver = requestAdoption(receiverChannel, {566 deviceName: "Receiver",567 generateDeviceId: () => "receiver-ephemeral",568 confirmSas: vi.fn(async () => true),569 license: vi.fn(async () => "PPK-local"),570 validateSource: vi.fn(async () => {571 throw new Error("JOIN_POPULATED_VAULT_BLOCKED");572 }),573 createVaultFromRootKey,574 deriveLocalIdentity: vi.fn(),575 upsertPairedDevice: vi.fn(),576 onError: vi.fn(),577 });578579 await sourceChannel.receive(); 580 await sourceChannel.receive(); 581 await sendFrame(sourceChannel, {582 type: "ADOPT_GRANT",583 root_key_hex: rootKeyHex,584 vault_id: vaultId,585 source: {586 device_id: "new-source",587 device_name: "Source",588 device_type: "extension",589 long_term_public_key_hex: "ab".repeat(32),590 },591 });592593 await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");594 expect(createVaultFromRootKey).not.toHaveBeenCalled();595 });596597 it("T-A31: does not apply a license if vault creation fails", async () => {598 const [receiverChannel, sourceChannel] = memoryPair();599 const rootKeyHex = "44".repeat(32);600 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));601 const applyLicense = vi.fn();602 const receiver = requestAdoption(receiverChannel, {603 deviceName: "Receiver",604 generateDeviceId: () => "receiver-ephemeral",605 confirmSas: vi.fn(async () => true),606 createVaultFromRootKey: vi.fn(async () => {607 throw new Error("commit_failed");608 }),609 deriveLocalIdentity: vi.fn(),610 upsertPairedDevice: vi.fn(),611 applyLicense,612 onError: vi.fn(),613 });614 await sourceChannel.receive();615 await sendFrame(sourceChannel, {616 type: "ADOPT_GRANT",617 root_key_hex: rootKeyHex,618 vault_id: vaultId,619 license: "PPK-test",620 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },621 });622623 await expect(receiver).rejects.toThrow("commit_failed");624 expect(applyLicense).not.toHaveBeenCalled();625 });626627 it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {628 const [receiverChannel, sourceChannel] = memoryPair();629 const createVaultFromRootKey = vi.fn();630 const receiver = requestAdoption(receiverChannel, {631 deviceName: "Receiver",632 generateDeviceId: () => "receiver-ephemeral",633 confirmSas: vi.fn(async () => true),634 createVaultFromRootKey,635 deriveLocalIdentity: vi.fn(),636 upsertPairedDevice: vi.fn(),637 applyLicense: vi.fn(),638 onError: vi.fn(),639 });640 await sourceChannel.receive();641 await sendFrame(sourceChannel, {642 type: "ADOPT_GRANT",643 root_key_hex: "55".repeat(32),644 vault_id: "99".repeat(32),645 license: "PPK-test",646 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },647 });648649 await receiver;650 expect(createVaultFromRootKey).not.toHaveBeenCalled();651 });652});653654describe("snapshot_capable echo on ADOPT_GRANT", () => {655 function validGrant() {656 return {657 type: "ADOPT_GRANT" as const,658 root_key_hex: "11".repeat(32),659 vault_id: "22".repeat(32),660 license: "PPK-test",661 source: {662 device_id: "src-id",663 device_name: "Source",664 device_type: "extension" as const,665 long_term_public_key_hex: "ab".repeat(32),666 },667 };668 }669670 it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {671 expect(isAdoptGrant(validGrant())).toBe(true);672 });673674 it("isAdoptGrant accepts snapshot_capable: true", () => {675 expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);676 });677678 it("isAdoptGrant accepts snapshot_capable: false", () => {679 expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);680 });681682 it("isAdoptGrant rejects snapshot_capable with wrong type", () => {683 expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);684 });685686 it("grantAdoption echoes snapshot_capable: true when request had it", async () => {687 const [receiverChannel, sourceChannel] = memoryPair();688 const rootKeyHex = "33".repeat(32);689 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));690 const sourceDeps: AdoptionSourceDeps = {691 sourceIdentity: identity("source"),692 rootKeyHex: vi.fn(async () => rootKeyHex),693 vaultId: vi.fn(async () => vaultId),694 license: vi.fn(async () => "PPK-test"),695 onConsent: vi.fn(async () => true),696 upsertPairedDevice: vi.fn(),697 };698699 700 const grantPromise = grantAdoption(sourceChannel, {701 type: "ADOPT_REQUEST",702 device_id: "recv-id",703 device_name: "Receiver",704 device_type: "extension",705 snapshot_capable: true,706 }, sourceDeps);707708 709 await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });710 const grantBytes = await receiverChannel.receive();711 const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));712 expect(grantFrame.type).toBe("ADOPT_GRANT");713 expect(grantFrame.snapshot_capable).toBe(true);714715 716 await sendFrame(receiverChannel, {717 type: "ADOPT_IDENTITY",718 device_id: "recv-id",719 device_name: "Receiver",720 device_type: "extension",721 long_term_public_key_hex: "ab".repeat(32),722 });723 await receiverChannel.receive(); 724 await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });725 await grantPromise;726 });727728 it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {729 const [receiverChannel, sourceChannel] = memoryPair();730 const rootKeyHex = "44".repeat(32);731 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));732 const sourceDeps: AdoptionSourceDeps = {733 sourceIdentity: identity("source"),734 rootKeyHex: vi.fn(async () => rootKeyHex),735 vaultId: vi.fn(async () => vaultId),736 license: vi.fn(async () => "PPK-test"),737 onConsent: vi.fn(async () => true),738 upsertPairedDevice: vi.fn(),739 };740741 const grantPromise = grantAdoption(sourceChannel, {742 type: "ADOPT_REQUEST",743 device_id: "recv-id",744 device_name: "Receiver",745 device_type: "extension",746 747 }, sourceDeps);748749 await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });750 const grantBytes = await receiverChannel.receive();751 const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));752 expect(grantFrame.type).toBe("ADOPT_GRANT");753 expect(grantFrame.snapshot_capable).toBe(false);754755 await sendFrame(receiverChannel, {756 type: "ADOPT_IDENTITY",757 device_id: "recv-id",758 device_name: "Receiver",759 device_type: "extension",760 long_term_public_key_hex: "ab".repeat(32),761 });762 await receiverChannel.receive(); 763 await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });764 await grantPromise;765 });766767 it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {768 const [receiverChannel, sourceChannel] = memoryPair();769 const rootKeyHex = "55".repeat(32);770 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));771 const deps: AdoptionReceiverDeps = {772 deviceName: "recv",773 generateDeviceId: () => "recv-id",774 async confirmSas() { return true; },775 async createVaultFromRootKey() { return { vaultId }; },776 async deriveLocalIdentity() {777 return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };778 },779 async upsertPairedDevice() {},780 onError() {},781 };782783 const receiverPromise = requestAdoption(receiverChannel, deps);784 785 786 await sourceChannel.receive(); 787 await sourceChannel.receive(); 788 await sendFrame(sourceChannel, {789 type: "ADOPT_GRANT",790 root_key_hex: rootKeyHex,791 vault_id: vaultId,792 license: "PPK-test",793 source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },794 snapshot_capable: true,795 });796 await sourceChannel.receive(); 797 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });798 await sourceChannel.receive(); 799800 const result = await receiverPromise;801 expect(result).not.toBeNull();802 expect(result!.snapshotCapable).toBe(true);803 });804805 it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {806 const [receiverChannel, sourceChannel] = memoryPair();807 const rootKeyHex = "66".repeat(32);808 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));809 const deps: AdoptionReceiverDeps = {810 deviceName: "recv",811 generateDeviceId: () => "recv-id",812 async confirmSas() { return true; },813 async createVaultFromRootKey() { return { vaultId }; },814 async deriveLocalIdentity() {815 return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };816 },817 async upsertPairedDevice() {},818 onError() {},819 };820821 const receiverPromise = requestAdoption(receiverChannel, deps);822 await sourceChannel.receive(); 823 await sourceChannel.receive(); 824 await sendFrame(sourceChannel, {825 type: "ADOPT_GRANT",826 root_key_hex: rootKeyHex,827 vault_id: vaultId,828 license: "PPK-test",829 source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },830 831 });832 await sourceChannel.receive(); 833 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });834 await sourceChannel.receive(); 835836 const result = await receiverPromise;837 expect(result).not.toBeNull();838 expect(result!.snapshotCapable).toBe(false);839 });840841 842 843 844 845 846 847 it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => {848 const [source, receiver] = memoryPair();849 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };850 const tombstones: Record<string, number> = { "dead-0": 1 };851 852 853 854 855 856 857 const duressJson = JSON.stringify({858 version: 1,859 salt: "aa".repeat(16),860 nonce: "bb".repeat(12),861 ciphertext: "cc".repeat(320 * 1024),862 tag: "dd".repeat(16),863 });864865 const [result] = await Promise.all([866 receiveVaultSnapshot(receiver),867 sendVaultSnapshot(source, entries, tombstones, duressJson),868 ]);869870 expect(result.entries).toEqual(entries);871 expect(result.tombstones).toEqual(tombstones);872 expect(result.duressSlot).toBe(duressJson);873 });874875 876 877 878 it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {879 const [source] = memoryPair();880 const observed: Array<{881 size: number;882 wireSize: number;883 hasDuress: boolean;884 entriesKeys: number;885 }> = [];886 const probe: PairingTransport = {887 async send(msg) {888 const raw = unpadSyncFrame(msg);889 const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;890 observed.push({891 size: raw.length,892 wireSize: msg.length,893 hasDuress: typeof f.duress_slot === "string",894 entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,895 });896 return source.send(msg);897 },898 receive() { return source.receive(); },899 };900 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };901 const duressJson = "y".repeat(320 * 1024);902 const CEILING = 768 * 1024;903904 await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson);905906 expect(observed.length).toBe(2);907 908 expect(observed[0].hasDuress).toBe(false);909 expect(observed[0].entriesKeys).toBe(1);910 expect(observed[0].size).toBeLessThan(CEILING);911 912 expect(observed[1].hasDuress).toBe(true);913 expect(observed[1].entriesKeys).toBe(0);914 expect(observed[1].size).toBeLessThan(CEILING);915 916 expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);917 918 919 for (const frame of observed) {920 expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4));921 }922 });923924 925 926 927 928 it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => {929 const [source, receiver] = memoryPair();930 const duressJson = JSON.stringify({931 version: 1,932 salt: "aa".repeat(16),933 nonce: "bb".repeat(12),934 ciphertext: "cc".repeat(32),935 tag: "dd".repeat(16),936 });937 938 939 await sendSnapshotFrame(source, {940 type: "ADOPT_SNAPSHOT",941 chunk_index: 0,942 chunk_count: 1,943 entries: { "id-0": "ciphertext" },944 tombstones: { "dead-0": 42 },945 duress_slot: duressJson,946 });947 const result = await receiveVaultSnapshot(receiver);948 expect(result.entries).toEqual({ "id-0": "ciphertext" });949 expect(result.tombstones).toEqual({ "dead-0": 42 });950 expect(result.duressSlot).toBe(duressJson);951 });952953 954 955 956 957 958 959 it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {960 const [source, receiver] = memoryPair();961 const duressJson = JSON.stringify({962 version: 1,963 salt: "aa".repeat(16),964 nonce: "bb".repeat(12),965 ciphertext: "cc".repeat(32),966 tag: "dd".repeat(16),967 });968 const big = "x".repeat(40 * 1024);969 const entries: Record<string, string> = {};970 for (let i = 0; i < 199; i++) entries[`id-${i}`] = big;971 972 973 974 const [result] = await Promise.all([975 receiveVaultSnapshot(receiver),976 sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),977 ]);978 expect(result.entries).toEqual(entries);979 expect(result.tombstones).toEqual({ "dead-0": 1 });980 expect(result.duressSlot).toBe(duressJson);981 });982983 984 985 986 987 988 it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => {989 const [source] = memoryPair();990 let frames = 0;991 const countingSource: PairingTransport = {992 async send(msg) {993 frames++;994 return source.send(msg);995 },996 receive() { return source.receive(); },997 };998 const duressJson = JSON.stringify({999 version: 1,1000 salt: "aa".repeat(16),1001 nonce: "bb".repeat(12),1002 ciphertext: "cc".repeat(32),1003 tag: "dd".repeat(16),1004 });1005 1006 const big = "x".repeat(60 * 1024);1007 const entries: Record<string, string> = {};1008 for (let i = 0; i < 200; i++) entries[`id-${i}`] = big;1009 await expect(1010 sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson),1011 ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");1012 expect(frames).toBe(0);1013 });10141015 1016 1017 1018 1019 1020 1021 1022 1023 it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => {1024 const [source, receiver] = memoryPair();1025 1026 1027 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };1028 const tombstones: Record<string, number> = { "dead-0": 1 };1029 const duressJson = JSON.stringify({1030 version: 1,1031 salt: "aa".repeat(16),1032 nonce: "bb".repeat(12),1033 ciphertext: "cc".repeat(320 * 1024),1034 tag: "dd".repeat(16),1035 });1036 const CEILING = 768 * 1024;10371038 1039 1040 const observed: Array<{1041 size: number;1042 wireSize: number;1043 hasDuress: boolean;1044 entriesKeys: number;1045 }> = [];1046 const probe: PairingTransport = {1047 async send(msg) {1048 const raw = unpadSyncFrame(msg);1049 const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;1050 observed.push({1051 size: raw.length,1052 wireSize: msg.length,1053 hasDuress: typeof f.duress_slot === "string",1054 entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,1055 });1056 return source.send(msg);1057 },1058 receive() { return source.receive(); },1059 };10601061 const [result] = await Promise.all([1062 receiveVaultSnapshot(receiver),1063 sendVaultSnapshot(probe, entries, tombstones, duressJson),1064 ]);10651066 1067 expect(result.entries).toEqual(entries);1068 expect(result.tombstones).toEqual(tombstones);1069 expect(result.duressSlot).toBe(duressJson);10701071 1072 1073 expect(observed.length).toBe(2);1074 expect(observed[0].hasDuress).toBe(false);1075 expect(observed[0].entriesKeys).toBe(1);1076 expect(observed[0].size).toBeLessThan(CEILING);1077 expect(observed[1].hasDuress).toBe(true);1078 expect(observed[1].entriesKeys).toBe(0);1079 expect(observed[1].size).toBeLessThan(CEILING);1080 const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({1081 type: "ADOPT_SNAPSHOT",1082 chunk_index: 0,1083 chunk_count: 1,1084 entries,1085 tombstones,1086 duress_slot: duressJson,1087 })).length;1088 expect(legacyCombinedSize).toBeGreaterThan(CEILING);1089 });1090});1091109210931094109510961097describe("PB-26-603 adoption device_name cap", () => {1098 it("caps a 500-char peer device_name to 64 chars on both PairedDevice records", async () => {1099 const longName = "X".repeat(500);1100 const [receiverChannel, sourceChannel] = memoryPair();1101 const rootKeyHex = "13".repeat(32);1102 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));1103 const receiverUpsert = vi.fn();1104 const sourceUpsert = vi.fn();11051106 const receiverDeps: AdoptionReceiverDeps = {1107 deviceName: "receiver",1108 generateDeviceId: () => "receiver-ephemeral",1109 confirmSas: vi.fn(async () => true),1110 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),1111 deriveLocalIdentity: vi.fn(async () => ({1112 deviceId: "receiver-id",1113 deviceName: longName,1114 deviceType: "extension" as const,1115 longTermPublicKeyHex: "ab".repeat(32),1116 signingPublicKeyHex: "cd".repeat(32),1117 })),1118 upsertPairedDevice: receiverUpsert,1119 applyLicense: vi.fn(),1120 onError: vi.fn(),1121 };1122 const sourceDeps: AdoptionSourceDeps = {1123 sourceIdentity: {1124 deviceId: "source-id",1125 deviceName: longName,1126 deviceType: "extension" as const,1127 longTermPublicKeyHex: "ef".repeat(32),1128 signingPublicKeyHex: "01".repeat(32),1129 },1130 rootKeyHex: vi.fn(async () => rootKeyHex),1131 vaultId: vi.fn(async () => vaultId),1132 license: vi.fn(async () => "PPK-test"),1133 onConsent: vi.fn(async () => true),1134 upsertPairedDevice: sourceUpsert,1135 };11361137 await Promise.all([1138 requestAdoption(receiverChannel, receiverDeps),1139 (async () => {1140 const request = JSON.parse(1141 new TextDecoder().decode(await sourceChannel.receive()),1142 ) as AdoptRequest;1143 await grantAdoption(sourceChannel, request, sourceDeps);1144 })(),1145 ]);11461147 const storedReceiver = receiverUpsert.mock.calls[0]?.[0] as { deviceName: string };1148 const storedSource = sourceUpsert.mock.calls[0]?.[0] as { deviceName: string };1149 expect(storedReceiver.deviceName).toBe(longName.slice(0, 64));1150 expect(storedSource.deviceName).toBe(longName.slice(0, 64));1151 });1152});