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-A10b: rejects without creating or pairing when neither peer has Pro", async () => {113 const [receiverChannel, sourceChannel] = memoryPair();114 const rootKeyHex = "12".repeat(32);115 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));116 const createVault = vi.fn();117 const receiverUpsert = vi.fn();118 const sourceUpsert = vi.fn();119 const onError = vi.fn();120121 const receiver = requestAdoption(receiverChannel, {122 deviceName: "Free receiver",123 generateDeviceId: () => "free-receiver",124 confirmSas: vi.fn(async () => true),125 license: vi.fn(async () => undefined),126 createVaultFromRootKey: createVault,127 deriveLocalIdentity: vi.fn(),128 upsertPairedDevice: receiverUpsert,129 onError,130 });131 const request = JSON.parse(132 new TextDecoder().decode(await sourceChannel.receive()),133 ) as AdoptRequest;134 const source = grantAdoption(sourceChannel, request, {135 sourceIdentity: identity("free-source"),136 rootKeyHex: vi.fn(async () => rootKeyHex),137 vaultId: vi.fn(async () => vaultId),138 license: vi.fn(async () => undefined),139 onConsent: vi.fn(async () => true),140 upsertPairedDevice: sourceUpsert,141 });142143 await expect(receiver).resolves.toBeNull();144 await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");145 expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");146 expect(createVault).not.toHaveBeenCalled();147 expect(receiverUpsert).not.toHaveBeenCalled();148 expect(sourceUpsert).not.toHaveBeenCalled();149 });150151 it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {152 const [receiverChannel, sourceChannel] = memoryPair();153 const rootKeyHex = "13".repeat(32);154 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));155 let sourceLicense: string | undefined;156 const sourceApply = vi.fn(async (license: string) => {157 sourceLicense = license;158 });159160 const receiver = requestAdoption(receiverChannel, {161 deviceName: "Pro receiver",162 generateDeviceId: () => "pro-receiver",163 confirmSas: vi.fn(async () => true),164 license: vi.fn(async () => "PPK-receiver"),165 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),166 deriveLocalIdentity: vi.fn(async () => identity("receiver")),167 upsertPairedDevice: vi.fn(),168 applyLicense: vi.fn(),169 onError: vi.fn(),170 });171 const request = JSON.parse(172 new TextDecoder().decode(await sourceChannel.receive()),173 ) as AdoptRequest;174 const source = grantAdoption(sourceChannel, request, {175 sourceIdentity: identity("free-source"),176 rootKeyHex: vi.fn(async () => rootKeyHex),177 vaultId: vi.fn(async () => vaultId),178 license: vi.fn(async () => sourceLicense),179 applyLicense: sourceApply,180 onConsent: vi.fn(async () => true),181 upsertPairedDevice: vi.fn(),182 });183184 await expect(Promise.all([receiver, source])).resolves.toEqual([185 { device: expect.objectContaining({ deviceId: "free-source-id" }), snapshotCapable: true },186 expect.objectContaining({ deviceId: "receiver-id" }),187 ]);188 expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");189 });190191 it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {192 const [receiverChannel, sourceChannel] = memoryPair();193 const rootKeyHex = vi.fn(async () => "11".repeat(32));194 const createVaultFromRootKey = vi.fn();195 const receiver = requestAdoption(receiverChannel, {196 deviceName: "Receiver",197 generateDeviceId: () => "receiver-ephemeral",198 confirmSas: vi.fn(async () => false),199 createVaultFromRootKey,200 deriveLocalIdentity: vi.fn(),201 upsertPairedDevice: vi.fn(),202 onError: vi.fn(),203 });204 const request = JSON.parse(205 new TextDecoder().decode(await sourceChannel.receive()),206 ) as AdoptRequest;207 const source = grantAdoption(sourceChannel, request, {208 sourceIdentity: identity("source"),209 rootKeyHex,210 vaultId: vi.fn(),211 onConsent: vi.fn(async () => true),212 upsertPairedDevice: vi.fn(),213 });214215 await expect(receiver).resolves.toBeNull();216 await expect(source).resolves.toBeNull();217 expect(rootKeyHex).not.toHaveBeenCalled();218 expect(createVaultFromRootKey).not.toHaveBeenCalled();219 });220221 it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {222 const [receiverChannel, sourceChannel] = memoryPair();223 const createVaultFromRootKey = vi.fn();224 const errors: string[] = [];225 const receiver = requestAdoption(receiverChannel, {226 deviceName: "Receiver",227 generateDeviceId: () => "receiver-ephemeral",228 confirmSas: vi.fn(async () => true),229 createVaultFromRootKey,230 deriveLocalIdentity: vi.fn(),231 upsertPairedDevice: vi.fn(),232 onError: (err) => errors.push(err),233 });234 const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;235 await grantAdoption(sourceChannel, request, {236 sourceIdentity: identity("source"),237 rootKeyHex: vi.fn(),238 vaultId: vi.fn(),239 onConsent: vi.fn(async () => false),240 upsertPairedDevice: vi.fn(),241 });242 await Promise.race([receiver, Promise.resolve()]);243244 expect(createVaultFromRootKey).not.toHaveBeenCalled();245 expect(errors).toEqual([]);246 });247248 it("T-A13: invalid root_key_hex aborts gracefully", async () => {249 const [receiverChannel, sourceChannel] = memoryPair();250 const onError = vi.fn();251 const createVaultFromRootKey = vi.fn();252 const receiver = requestAdoption(receiverChannel, {253 deviceName: "Receiver",254 generateDeviceId: () => "receiver-ephemeral",255 confirmSas: vi.fn(async () => true),256 createVaultFromRootKey,257 deriveLocalIdentity: vi.fn(),258 upsertPairedDevice: vi.fn(),259 onError,260 });261 await sourceChannel.receive();262 await sendFrame(sourceChannel, {263 type: "ADOPT_GRANT",264 root_key_hex: "bad",265 vault_id: "00".repeat(32),266 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },267 } as never);268 await receiver;269270 expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");271 expect(createVaultFromRootKey).not.toHaveBeenCalled();272 });273274 it("T-A14: vault_id mismatch aborts before vault creation", async () => {275 const [receiverChannel, sourceChannel] = memoryPair();276 const onError = vi.fn();277 const createVaultFromRootKey = vi.fn();278 const receiver = requestAdoption(receiverChannel, {279 deviceName: "Receiver",280 generateDeviceId: () => "receiver-ephemeral",281 confirmSas: vi.fn(async () => true),282 createVaultFromRootKey,283 deriveLocalIdentity: vi.fn(),284 upsertPairedDevice: vi.fn(),285 onError,286 });287 await sourceChannel.receive();288 await sendFrame(sourceChannel, {289 type: "ADOPT_GRANT",290 root_key_hex: "22".repeat(32),291 vault_id: "00".repeat(32),292 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },293 });294 await receiver;295296 expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");297 expect(createVaultFromRootKey).not.toHaveBeenCalled();298 });299300 it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {301 const [receiverChannel, sourceChannel] = memoryPair();302 const upsert = vi.fn();303 const promise = grantAdoption(sourceChannel, {304 type: "ADOPT_REQUEST",305 device_id: "receiver-ephemeral",306 device_name: "Receiver",307 device_type: "extension",308 }, {309 sourceIdentity: identity("source"),310 rootKeyHex: vi.fn(async () => "33".repeat(32)),311 vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),312 license: vi.fn(async () => "PPK-test"),313 onConsent: vi.fn(async () => true),314 upsertPairedDevice: upsert,315 });316 await sendFrame(receiverChannel, {317 type: "ADOPT_CONFIRM",318 confirmed: true,319 });320321 await Promise.race([promise, Promise.resolve()]);322 expect(upsert).not.toHaveBeenCalled();323 });324325 it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {326 const [receiverChannel, sourceChannel] = memoryPair();327 const rootKeyHex = "66".repeat(32);328 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));329 const receiverUpsert = vi.fn();330 const onError = vi.fn();331 const receiver = requestAdoption(receiverChannel, {332 deviceName: "Receiver",333 generateDeviceId: () => "receiver-ephemeral",334 confirmSas: vi.fn(async () => true),335 license: vi.fn(async () => "PPK-local"),336 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),337 deriveLocalIdentity: vi.fn(async () => identity("receiver")),338 upsertPairedDevice: receiverUpsert,339 onError,340 });341342 await sourceChannel.receive(); 343 await sourceChannel.receive(); 344 await sendFrame(sourceChannel, {345 type: "ADOPT_GRANT",346 root_key_hex: rootKeyHex,347 vault_id: vaultId,348 source: {349 device_id: "source-id",350 device_name: "Source",351 device_type: "extension",352 long_term_public_key_hex: "ab".repeat(32),353 },354 });355 await sourceChannel.receive(); 356 await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });357358 await expect(receiver).resolves.toBeNull();359 expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");360 expect(receiverUpsert).not.toHaveBeenCalled();361 });362363 it("T-A17: receiver safety policy runs before vault creation", async () => {364 const [receiverChannel, sourceChannel] = memoryPair();365 const rootKeyHex = "77".repeat(32);366 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));367 const createVaultFromRootKey = vi.fn();368 const receiver = requestAdoption(receiverChannel, {369 deviceName: "Receiver",370 generateDeviceId: () => "receiver-ephemeral",371 confirmSas: vi.fn(async () => true),372 license: vi.fn(async () => "PPK-local"),373 validateSource: vi.fn(async () => {374 throw new Error("JOIN_POPULATED_VAULT_BLOCKED");375 }),376 createVaultFromRootKey,377 deriveLocalIdentity: vi.fn(),378 upsertPairedDevice: vi.fn(),379 onError: vi.fn(),380 });381382 await sourceChannel.receive(); 383 await sourceChannel.receive(); 384 await sendFrame(sourceChannel, {385 type: "ADOPT_GRANT",386 root_key_hex: rootKeyHex,387 vault_id: vaultId,388 source: {389 device_id: "new-source",390 device_name: "Source",391 device_type: "extension",392 long_term_public_key_hex: "ab".repeat(32),393 },394 });395396 await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");397 expect(createVaultFromRootKey).not.toHaveBeenCalled();398 });399400 it("T-A31: does not apply a license if vault creation fails", async () => {401 const [receiverChannel, sourceChannel] = memoryPair();402 const rootKeyHex = "44".repeat(32);403 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));404 const applyLicense = vi.fn();405 const receiver = requestAdoption(receiverChannel, {406 deviceName: "Receiver",407 generateDeviceId: () => "receiver-ephemeral",408 confirmSas: vi.fn(async () => true),409 createVaultFromRootKey: vi.fn(async () => {410 throw new Error("commit_failed");411 }),412 deriveLocalIdentity: vi.fn(),413 upsertPairedDevice: vi.fn(),414 applyLicense,415 onError: vi.fn(),416 });417 await sourceChannel.receive();418 await sendFrame(sourceChannel, {419 type: "ADOPT_GRANT",420 root_key_hex: rootKeyHex,421 vault_id: vaultId,422 license: "PPK-test",423 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },424 });425426 await expect(receiver).rejects.toThrow("commit_failed");427 expect(applyLicense).not.toHaveBeenCalled();428 });429430 it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {431 const [receiverChannel, sourceChannel] = memoryPair();432 const createVaultFromRootKey = vi.fn();433 const receiver = requestAdoption(receiverChannel, {434 deviceName: "Receiver",435 generateDeviceId: () => "receiver-ephemeral",436 confirmSas: vi.fn(async () => true),437 createVaultFromRootKey,438 deriveLocalIdentity: vi.fn(),439 upsertPairedDevice: vi.fn(),440 applyLicense: vi.fn(),441 onError: vi.fn(),442 });443 await sourceChannel.receive();444 await sendFrame(sourceChannel, {445 type: "ADOPT_GRANT",446 root_key_hex: "55".repeat(32),447 vault_id: "99".repeat(32),448 license: "PPK-test",449 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },450 });451452 await receiver;453 expect(createVaultFromRootKey).not.toHaveBeenCalled();454 });455});456457describe("snapshot_capable echo on ADOPT_GRANT", () => {458 function validGrant() {459 return {460 type: "ADOPT_GRANT" as const,461 root_key_hex: "11".repeat(32),462 vault_id: "22".repeat(32),463 license: "PPK-test",464 source: {465 device_id: "src-id",466 device_name: "Source",467 device_type: "extension" as const,468 long_term_public_key_hex: "ab".repeat(32),469 },470 };471 }472473 it("isAdoptGrant accepts a grant without snapshot_capable (pre-C source)", () => {474 expect(isAdoptGrant(validGrant())).toBe(true);475 });476477 it("isAdoptGrant accepts snapshot_capable: true", () => {478 expect(isAdoptGrant({ ...validGrant(), snapshot_capable: true })).toBe(true);479 });480481 it("isAdoptGrant accepts snapshot_capable: false", () => {482 expect(isAdoptGrant({ ...validGrant(), snapshot_capable: false })).toBe(true);483 });484485 it("isAdoptGrant rejects snapshot_capable with wrong type", () => {486 expect(isAdoptGrant({ ...validGrant(), snapshot_capable: "yes" })).toBe(false);487 });488489 it("grantAdoption echoes snapshot_capable: true when request had it", async () => {490 const [receiverChannel, sourceChannel] = memoryPair();491 const rootKeyHex = "33".repeat(32);492 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));493 const sourceDeps: AdoptionSourceDeps = {494 sourceIdentity: identity("source"),495 rootKeyHex: vi.fn(async () => rootKeyHex),496 vaultId: vi.fn(async () => vaultId),497 license: vi.fn(async () => "PPK-test"),498 onConsent: vi.fn(async () => true),499 upsertPairedDevice: vi.fn(),500 };501502 503 const grantPromise = grantAdoption(sourceChannel, {504 type: "ADOPT_REQUEST",505 device_id: "recv-id",506 device_name: "Receiver",507 device_type: "extension",508 snapshot_capable: true,509 }, sourceDeps);510511 512 await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });513 const grantBytes = await receiverChannel.receive();514 const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));515 expect(grantFrame.type).toBe("ADOPT_GRANT");516 expect(grantFrame.snapshot_capable).toBe(true);517518 519 await sendFrame(receiverChannel, {520 type: "ADOPT_IDENTITY",521 device_id: "recv-id",522 device_name: "Receiver",523 device_type: "extension",524 long_term_public_key_hex: "ab".repeat(32),525 });526 await receiverChannel.receive(); 527 await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });528 await grantPromise;529 });530531 it("grantAdoption sets snapshot_capable: false when request lacked it", async () => {532 const [receiverChannel, sourceChannel] = memoryPair();533 const rootKeyHex = "44".repeat(32);534 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));535 const sourceDeps: AdoptionSourceDeps = {536 sourceIdentity: identity("source"),537 rootKeyHex: vi.fn(async () => rootKeyHex),538 vaultId: vi.fn(async () => vaultId),539 license: vi.fn(async () => "PPK-test"),540 onConsent: vi.fn(async () => true),541 upsertPairedDevice: vi.fn(),542 };543544 const grantPromise = grantAdoption(sourceChannel, {545 type: "ADOPT_REQUEST",546 device_id: "recv-id",547 device_name: "Receiver",548 device_type: "extension",549 550 }, sourceDeps);551552 await sendFrame(receiverChannel, { type: "ADOPT_CONFIRM", confirmed: true });553 const grantBytes = await receiverChannel.receive();554 const grantFrame = JSON.parse(new TextDecoder().decode(grantBytes));555 expect(grantFrame.type).toBe("ADOPT_GRANT");556 expect(grantFrame.snapshot_capable).toBe(false);557558 await sendFrame(receiverChannel, {559 type: "ADOPT_IDENTITY",560 device_id: "recv-id",561 device_name: "Receiver",562 device_type: "extension",563 long_term_public_key_hex: "ab".repeat(32),564 });565 await receiverChannel.receive(); 566 await sendFrame(receiverChannel, { type: "ADOPT_APPLIED" });567 await grantPromise;568 });569570 it("requestAdoption returns snapshotCapable: true when grant echoes it", async () => {571 const [receiverChannel, sourceChannel] = memoryPair();572 const rootKeyHex = "55".repeat(32);573 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));574 const deps: AdoptionReceiverDeps = {575 deviceName: "recv",576 generateDeviceId: () => "recv-id",577 async confirmSas() { return true; },578 async createVaultFromRootKey() { return { vaultId }; },579 async deriveLocalIdentity() {580 return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };581 },582 async upsertPairedDevice() {},583 onError() {},584 };585586 const receiverPromise = requestAdoption(receiverChannel, deps);587 588 589 await sourceChannel.receive(); 590 await sourceChannel.receive(); 591 await sendFrame(sourceChannel, {592 type: "ADOPT_GRANT",593 root_key_hex: rootKeyHex,594 vault_id: vaultId,595 license: "PPK-test",596 source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },597 snapshot_capable: true,598 });599 await sourceChannel.receive(); 600 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });601 await sourceChannel.receive(); 602603 const result = await receiverPromise;604 expect(result).not.toBeNull();605 expect(result!.snapshotCapable).toBe(true);606 });607608 it("requestAdoption returns snapshotCapable: false when grant lacks the field", async () => {609 const [receiverChannel, sourceChannel] = memoryPair();610 const rootKeyHex = "66".repeat(32);611 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));612 const deps: AdoptionReceiverDeps = {613 deviceName: "recv",614 generateDeviceId: () => "recv-id",615 async confirmSas() { return true; },616 async createVaultFromRootKey() { return { vaultId }; },617 async deriveLocalIdentity() {618 return { deviceId: "recv-id", deviceName: "recv", deviceType: "extension" as const, longTermPublicKeyHex: "ab".repeat(32) };619 },620 async upsertPairedDevice() {},621 onError() {},622 };623624 const receiverPromise = requestAdoption(receiverChannel, deps);625 await sourceChannel.receive(); 626 await sourceChannel.receive(); 627 await sendFrame(sourceChannel, {628 type: "ADOPT_GRANT",629 root_key_hex: rootKeyHex,630 vault_id: vaultId,631 license: "PPK-test",632 source: { device_id: "src-id", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },633 634 });635 await sourceChannel.receive(); 636 await sendFrame(sourceChannel, { type: "ADOPT_COMPLETE" });637 await sourceChannel.receive(); 638639 const result = await receiverPromise;640 expect(result).not.toBeNull();641 expect(result!.snapshotCapable).toBe(false);642 });643644 645 646 647 648 649 650 it("T-A33: >768 KiB snapshot (entry + duress) succeeds because duress no longer counts against the entries frame", async () => {651 const [source, receiver] = memoryPair();652 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };653 const tombstones: Record<string, number> = { "dead-0": 1 };654 655 656 657 658 659 660 const duressJson = JSON.stringify({661 version: 1,662 salt: "aa".repeat(16),663 nonce: "bb".repeat(12),664 ciphertext: "cc".repeat(320 * 1024),665 tag: "dd".repeat(16),666 });667668 const [result] = await Promise.all([669 receiveVaultSnapshot(receiver),670 sendVaultSnapshot(source, entries, tombstones, duressJson),671 ]);672673 expect(result.entries).toEqual(entries);674 expect(result.tombstones).toEqual(tombstones);675 expect(result.duressSlot).toBe(duressJson);676 });677678 679 680 681 it("T-A34: duress rides its own frame so intermediate entries frames stay under the wire ceiling", async () => {682 const [source] = memoryPair();683 const observed: Array<{684 size: number;685 wireSize: number;686 hasDuress: boolean;687 entriesKeys: number;688 }> = [];689 const probe: PairingTransport = {690 async send(msg) {691 const raw = unpadSyncFrame(msg);692 const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;693 observed.push({694 size: raw.length,695 wireSize: msg.length,696 hasDuress: typeof f.duress_slot === "string",697 entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,698 });699 return source.send(msg);700 },701 receive() { return source.receive(); },702 };703 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };704 const duressJson = "y".repeat(320 * 1024);705 const CEILING = 768 * 1024;706707 await sendVaultSnapshot(probe, entries, { "dead-0": 1 }, duressJson);708709 expect(observed.length).toBe(2);710 711 expect(observed[0].hasDuress).toBe(false);712 expect(observed[0].entriesKeys).toBe(1);713 expect(observed[0].size).toBeLessThan(CEILING);714 715 expect(observed[1].hasDuress).toBe(true);716 expect(observed[1].entriesKeys).toBe(0);717 expect(observed[1].size).toBeLessThan(CEILING);718 719 expect(observed[0].size + observed[1].size).toBeGreaterThan(CEILING);720 721 722 for (const frame of observed) {723 expect(frame.wireSize).toBe(syncFrameBucket(frame.size + 4));724 }725 });726727 728 729 730 731 it("T-A35: receiver accepts duress_slot on the entries final chunk (pre-v4.6.4 sender shape)", async () => {732 const [source, receiver] = memoryPair();733 const duressJson = JSON.stringify({734 version: 1,735 salt: "aa".repeat(16),736 nonce: "bb".repeat(12),737 ciphertext: "cc".repeat(32),738 tag: "dd".repeat(16),739 });740 741 742 await sendSnapshotFrame(source, {743 type: "ADOPT_SNAPSHOT",744 chunk_index: 0,745 chunk_count: 1,746 entries: { "id-0": "ciphertext" },747 tombstones: { "dead-0": 42 },748 duress_slot: duressJson,749 });750 const result = await receiveVaultSnapshot(receiver);751 expect(result.entries).toEqual({ "id-0": "ciphertext" });752 expect(result.tombstones).toEqual({ "dead-0": 42 });753 expect(result.duressSlot).toBe(duressJson);754 });755756 757 758 759 760 761 762 it("T-A36: duress is reserved a frame so 199 entries + duress fits in MAX_SNAPSHOT_CHUNKS", async () => {763 const [source, receiver] = memoryPair();764 const duressJson = JSON.stringify({765 version: 1,766 salt: "aa".repeat(16),767 nonce: "bb".repeat(12),768 ciphertext: "cc".repeat(32),769 tag: "dd".repeat(16),770 });771 const big = "x".repeat(40 * 1024);772 const entries: Record<string, string> = {};773 for (let i = 0; i < 199; i++) entries[`id-${i}`] = big;774 775 776 777 const [result] = await Promise.all([778 receiveVaultSnapshot(receiver),779 sendVaultSnapshot(source, entries, { "dead-0": 1 }, duressJson),780 ]);781 expect(result.entries).toEqual(entries);782 expect(result.tombstones).toEqual({ "dead-0": 1 });783 expect(result.duressSlot).toBe(duressJson);784 });785786 787 788 789 790 791 it("T-A37: over-budget snapshot preflights with zero frames sent (sync fallback, never truncation)", async () => {792 const [source] = memoryPair();793 let frames = 0;794 const countingSource: PairingTransport = {795 async send(msg) {796 frames++;797 return source.send(msg);798 },799 receive() { return source.receive(); },800 };801 const duressJson = JSON.stringify({802 version: 1,803 salt: "aa".repeat(16),804 nonce: "bb".repeat(12),805 ciphertext: "cc".repeat(32),806 tag: "dd".repeat(16),807 });808 809 const big = "x".repeat(60 * 1024);810 const entries: Record<string, string> = {};811 for (let i = 0; i < 200; i++) entries[`id-${i}`] = big;812 await expect(813 sendVaultSnapshot(countingSource, entries, { "dead-0": 1 }, duressJson),814 ).rejects.toThrow("ADOPT_SNAPSHOT_OVER_BUDGET");815 expect(frames).toBe(0);816 });817818 819 820 821 822 823 824 825 826 it("T-A38: >768 KiB snapshot succeeds because duress no longer counts against the entries frame", async () => {827 const [source, receiver] = memoryPair();828 829 830 const entries: Record<string, string> = { "id-0": "x".repeat(450 * 1024) };831 const tombstones: Record<string, number> = { "dead-0": 1 };832 const duressJson = JSON.stringify({833 version: 1,834 salt: "aa".repeat(16),835 nonce: "bb".repeat(12),836 ciphertext: "cc".repeat(320 * 1024),837 tag: "dd".repeat(16),838 });839 const CEILING = 768 * 1024;840841 842 843 const observed: Array<{844 size: number;845 wireSize: number;846 hasDuress: boolean;847 entriesKeys: number;848 }> = [];849 const probe: PairingTransport = {850 async send(msg) {851 const raw = unpadSyncFrame(msg);852 const f = JSON.parse(new TextDecoder().decode(raw)) as Record<string, unknown>;853 observed.push({854 size: raw.length,855 wireSize: msg.length,856 hasDuress: typeof f.duress_slot === "string",857 entriesKeys: Object.keys((f.entries as Record<string, unknown>) ?? {}).length,858 });859 return source.send(msg);860 },861 receive() { return source.receive(); },862 };863864 const [result] = await Promise.all([865 receiveVaultSnapshot(receiver),866 sendVaultSnapshot(probe, entries, tombstones, duressJson),867 ]);868869 870 expect(result.entries).toEqual(entries);871 expect(result.tombstones).toEqual(tombstones);872 expect(result.duressSlot).toBe(duressJson);873874 875 876 expect(observed.length).toBe(2);877 expect(observed[0].hasDuress).toBe(false);878 expect(observed[0].entriesKeys).toBe(1);879 expect(observed[0].size).toBeLessThan(CEILING);880 expect(observed[1].hasDuress).toBe(true);881 expect(observed[1].entriesKeys).toBe(0);882 expect(observed[1].size).toBeLessThan(CEILING);883 const legacyCombinedSize = new TextEncoder().encode(JSON.stringify({884 type: "ADOPT_SNAPSHOT",885 chunk_index: 0,886 chunk_count: 1,887 entries,888 tombstones,889 duress_slot: duressJson,890 })).length;891 expect(legacyCombinedSize).toBeGreaterThan(CEILING);892 });893});894895896897898899900describe("PB-26-603 adoption device_name cap", () => {901 it("caps a 500-char peer device_name to 64 chars on both PairedDevice records", async () => {902 const longName = "X".repeat(500);903 const [receiverChannel, sourceChannel] = memoryPair();904 const rootKeyHex = "13".repeat(32);905 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));906 const receiverUpsert = vi.fn();907 const sourceUpsert = vi.fn();908909 const receiverDeps: AdoptionReceiverDeps = {910 deviceName: "receiver",911 generateDeviceId: () => "receiver-ephemeral",912 confirmSas: vi.fn(async () => true),913 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),914 deriveLocalIdentity: vi.fn(async () => ({915 deviceId: "receiver-id",916 deviceName: longName,917 deviceType: "extension" as const,918 longTermPublicKeyHex: "ab".repeat(32),919 signingPublicKeyHex: "cd".repeat(32),920 })),921 upsertPairedDevice: receiverUpsert,922 applyLicense: vi.fn(),923 onError: vi.fn(),924 };925 const sourceDeps: AdoptionSourceDeps = {926 sourceIdentity: {927 deviceId: "source-id",928 deviceName: longName,929 deviceType: "extension" as const,930 longTermPublicKeyHex: "ef".repeat(32),931 signingPublicKeyHex: "01".repeat(32),932 },933 rootKeyHex: vi.fn(async () => rootKeyHex),934 vaultId: vi.fn(async () => vaultId),935 license: vi.fn(async () => "PPK-test"),936 onConsent: vi.fn(async () => true),937 upsertPairedDevice: sourceUpsert,938 };939940 await Promise.all([941 requestAdoption(receiverChannel, receiverDeps),942 (async () => {943 const request = JSON.parse(944 new TextDecoder().decode(await sourceChannel.receive()),945 ) as AdoptRequest;946 await grantAdoption(sourceChannel, request, sourceDeps);947 })(),948 ]);949950 const storedReceiver = receiverUpsert.mock.calls[0]?.[0] as { deviceName: string };951 const storedSource = sourceUpsert.mock.calls[0]?.[0] as { deviceName: string };952 expect(storedReceiver.deviceName).toBe(longName.slice(0, 64));953 expect(storedSource.deviceName).toBe(longName.slice(0, 64));954 });955});