1import { describe, expect, it, vi } from "vitest";2import { computeVaultId } from "./sync-identity";3import {4 grantAdoption,5 requestAdoption,6 sendFrame,7 type AdoptRequest,8 type AdoptionReceiverDeps,9 type AdoptionSourceDeps,10} from "./vault-adopt";11import type { PairingTransport } from "./pairing-handshake";1213function memoryPair(): [PairingTransport, PairingTransport] {14 const a: Uint8Array[] = [];15 const b: Uint8Array[] = [];16 const ar: Array<(v: Uint8Array) => void> = [];17 const br: Array<(v: Uint8Array) => void> = [];18 function make(19 inbox: Uint8Array[],20 outbox: Uint8Array[],21 readers: Array<(v: Uint8Array) => void>,22 outReaders: Array<(v: Uint8Array) => void>,23 ): PairingTransport {24 return {25 async send(msg) {26 const reader = outReaders.shift();27 if (reader) reader(msg);28 else outbox.push(msg);29 },30 receive() {31 const msg = inbox.shift();32 if (msg) return Promise.resolve(msg);33 return new Promise((resolve) => readers.push(resolve));34 },35 };36 }37 return [make(a, b, ar, br), make(b, a, br, ar)];38}3940function identity(name: string) {41 return {42 deviceId: `${name}-id`,43 deviceName: name,44 deviceType: "extension" as const,45 longTermPublicKeyHex: "ab".repeat(32),46 signingPublicKeyHex: "cd".repeat(32),47 };48}4950describe("vault adoption sub-protocol", () => {51 it("T-A10: completes ADOPT_REQUEST through mutual completion acknowledgements", async () => {52 const [receiverChannel, sourceChannel] = memoryPair();53 const rootKeyHex = "11".repeat(32);54 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));55 const sourceUpsert = vi.fn();56 const receiverUpsert = vi.fn();57 const pairState = {58 rendezvousSecretHex: "42".repeat(32),59 relayUrl: "wss://relay.example.test",60 };61 const receiverDeps: AdoptionReceiverDeps = {62 deviceName: "Receiver",63 generateDeviceId: () => "receiver-ephemeral",64 confirmSas: vi.fn(async () => true),65 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),66 deriveLocalIdentity: vi.fn(async () => identity("receiver")),67 upsertPairedDevice: receiverUpsert,68 applyLicense: vi.fn(),69 pairState,70 onError: vi.fn(),71 };72 const sourceDeps: AdoptionSourceDeps = {73 sourceIdentity: identity("source"),74 rootKeyHex: vi.fn(async () => rootKeyHex),75 vaultId: vi.fn(async () => vaultId),76 license: vi.fn(async () => "PPK-test"),77 onConsent: vi.fn(async () => true),78 upsertPairedDevice: sourceUpsert,79 pairState,80 };8182 await Promise.all([83 requestAdoption(receiverChannel, receiverDeps),84 (async () => {85 const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;86 await grantAdoption(sourceChannel, request, sourceDeps);87 })(),88 ]);8990 expect(receiverDeps.createVaultFromRootKey).toHaveBeenCalledWith(rootKeyHex);91 expect(receiverUpsert).toHaveBeenCalledWith(expect.objectContaining({92 deviceId: "source-id",93 ...pairState,94 }));95 expect(sourceUpsert).toHaveBeenCalledWith(expect.objectContaining({96 deviceId: "receiver-id",97 ...pairState,98 }));99 expect(receiverDeps.applyLicense).toHaveBeenCalledWith("PPK-test");100 });101102 it("T-A10b: rejects without creating or pairing when neither peer has Pro", async () => {103 const [receiverChannel, sourceChannel] = memoryPair();104 const rootKeyHex = "12".repeat(32);105 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));106 const createVault = vi.fn();107 const receiverUpsert = vi.fn();108 const sourceUpsert = vi.fn();109 const onError = vi.fn();110111 const receiver = requestAdoption(receiverChannel, {112 deviceName: "Free receiver",113 generateDeviceId: () => "free-receiver",114 confirmSas: vi.fn(async () => true),115 license: vi.fn(async () => undefined),116 createVaultFromRootKey: createVault,117 deriveLocalIdentity: vi.fn(),118 upsertPairedDevice: receiverUpsert,119 onError,120 });121 const request = JSON.parse(122 new TextDecoder().decode(await sourceChannel.receive()),123 ) as AdoptRequest;124 const source = grantAdoption(sourceChannel, request, {125 sourceIdentity: identity("free-source"),126 rootKeyHex: vi.fn(async () => rootKeyHex),127 vaultId: vi.fn(async () => vaultId),128 license: vi.fn(async () => undefined),129 onConsent: vi.fn(async () => true),130 upsertPairedDevice: sourceUpsert,131 });132133 await expect(receiver).resolves.toBeNull();134 await expect(source).rejects.toThrow("ADOPT_PRO_REQUIRED");135 expect(onError).toHaveBeenCalledWith("ADOPT_PRO_REQUIRED");136 expect(createVault).not.toHaveBeenCalled();137 expect(receiverUpsert).not.toHaveBeenCalled();138 expect(sourceUpsert).not.toHaveBeenCalled();139 });140141 it("T-A10c: accepts and transfers a Pro license offered by the receiver", async () => {142 const [receiverChannel, sourceChannel] = memoryPair();143 const rootKeyHex = "13".repeat(32);144 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));145 let sourceLicense: string | undefined;146 const sourceApply = vi.fn(async (license: string) => {147 sourceLicense = license;148 });149150 const receiver = requestAdoption(receiverChannel, {151 deviceName: "Pro receiver",152 generateDeviceId: () => "pro-receiver",153 confirmSas: vi.fn(async () => true),154 license: vi.fn(async () => "PPK-receiver"),155 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),156 deriveLocalIdentity: vi.fn(async () => identity("receiver")),157 upsertPairedDevice: vi.fn(),158 applyLicense: vi.fn(),159 onError: vi.fn(),160 });161 const request = JSON.parse(162 new TextDecoder().decode(await sourceChannel.receive()),163 ) as AdoptRequest;164 const source = grantAdoption(sourceChannel, request, {165 sourceIdentity: identity("free-source"),166 rootKeyHex: vi.fn(async () => rootKeyHex),167 vaultId: vi.fn(async () => vaultId),168 license: vi.fn(async () => sourceLicense),169 applyLicense: sourceApply,170 onConsent: vi.fn(async () => true),171 upsertPairedDevice: vi.fn(),172 });173174 await expect(Promise.all([receiver, source])).resolves.toEqual([175 expect.objectContaining({ deviceId: "free-source-id" }),176 expect.objectContaining({ deviceId: "receiver-id" }),177 ]);178 expect(sourceApply).toHaveBeenCalledWith("PPK-receiver");179 });180181 it("T-A11: SAS reject aborts before ADOPT_GRANT", async () => {182 const [receiverChannel, sourceChannel] = memoryPair();183 const rootKeyHex = vi.fn(async () => "11".repeat(32));184 const createVaultFromRootKey = vi.fn();185 const receiver = requestAdoption(receiverChannel, {186 deviceName: "Receiver",187 generateDeviceId: () => "receiver-ephemeral",188 confirmSas: vi.fn(async () => false),189 createVaultFromRootKey,190 deriveLocalIdentity: vi.fn(),191 upsertPairedDevice: vi.fn(),192 onError: vi.fn(),193 });194 const request = JSON.parse(195 new TextDecoder().decode(await sourceChannel.receive()),196 ) as AdoptRequest;197 const source = grantAdoption(sourceChannel, request, {198 sourceIdentity: identity("source"),199 rootKeyHex,200 vaultId: vi.fn(),201 onConsent: vi.fn(async () => true),202 upsertPairedDevice: vi.fn(),203 });204205 await expect(receiver).resolves.toBeNull();206 await expect(source).resolves.toBeNull();207 expect(rootKeyHex).not.toHaveBeenCalled();208 expect(createVaultFromRootKey).not.toHaveBeenCalled();209 });210211 it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {212 const [receiverChannel, sourceChannel] = memoryPair();213 const createVaultFromRootKey = vi.fn();214 const errors: string[] = [];215 const receiver = requestAdoption(receiverChannel, {216 deviceName: "Receiver",217 generateDeviceId: () => "receiver-ephemeral",218 confirmSas: vi.fn(async () => true),219 createVaultFromRootKey,220 deriveLocalIdentity: vi.fn(),221 upsertPairedDevice: vi.fn(),222 onError: (err) => errors.push(err),223 });224 const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;225 await grantAdoption(sourceChannel, request, {226 sourceIdentity: identity("source"),227 rootKeyHex: vi.fn(),228 vaultId: vi.fn(),229 onConsent: vi.fn(async () => false),230 upsertPairedDevice: vi.fn(),231 });232 await Promise.race([receiver, Promise.resolve()]);233234 expect(createVaultFromRootKey).not.toHaveBeenCalled();235 expect(errors).toEqual([]);236 });237238 it("T-A13: invalid root_key_hex aborts gracefully", async () => {239 const [receiverChannel, sourceChannel] = memoryPair();240 const onError = vi.fn();241 const createVaultFromRootKey = vi.fn();242 const receiver = requestAdoption(receiverChannel, {243 deviceName: "Receiver",244 generateDeviceId: () => "receiver-ephemeral",245 confirmSas: vi.fn(async () => true),246 createVaultFromRootKey,247 deriveLocalIdentity: vi.fn(),248 upsertPairedDevice: vi.fn(),249 onError,250 });251 await sourceChannel.receive();252 await sendFrame(sourceChannel, {253 type: "ADOPT_GRANT",254 root_key_hex: "bad",255 vault_id: "00".repeat(32),256 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },257 } as never);258 await receiver;259260 expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");261 expect(createVaultFromRootKey).not.toHaveBeenCalled();262 });263264 it("T-A14: vault_id mismatch aborts before vault creation", async () => {265 const [receiverChannel, sourceChannel] = memoryPair();266 const onError = vi.fn();267 const createVaultFromRootKey = vi.fn();268 const receiver = requestAdoption(receiverChannel, {269 deviceName: "Receiver",270 generateDeviceId: () => "receiver-ephemeral",271 confirmSas: vi.fn(async () => true),272 createVaultFromRootKey,273 deriveLocalIdentity: vi.fn(),274 upsertPairedDevice: vi.fn(),275 onError,276 });277 await sourceChannel.receive();278 await sendFrame(sourceChannel, {279 type: "ADOPT_GRANT",280 root_key_hex: "22".repeat(32),281 vault_id: "00".repeat(32),282 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },283 });284 await receiver;285286 expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");287 expect(createVaultFromRootKey).not.toHaveBeenCalled();288 });289290 it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {291 const [receiverChannel, sourceChannel] = memoryPair();292 const upsert = vi.fn();293 const promise = grantAdoption(sourceChannel, {294 type: "ADOPT_REQUEST",295 device_id: "receiver-ephemeral",296 device_name: "Receiver",297 device_type: "extension",298 }, {299 sourceIdentity: identity("source"),300 rootKeyHex: vi.fn(async () => "33".repeat(32)),301 vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),302 license: vi.fn(async () => "PPK-test"),303 onConsent: vi.fn(async () => true),304 upsertPairedDevice: upsert,305 });306 await sendFrame(receiverChannel, {307 type: "ADOPT_CONFIRM",308 confirmed: true,309 });310311 await Promise.race([promise, Promise.resolve()]);312 expect(upsert).not.toHaveBeenCalled();313 });314315 it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {316 const [receiverChannel, sourceChannel] = memoryPair();317 const rootKeyHex = "66".repeat(32);318 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));319 const receiverUpsert = vi.fn();320 const onError = vi.fn();321 const receiver = requestAdoption(receiverChannel, {322 deviceName: "Receiver",323 generateDeviceId: () => "receiver-ephemeral",324 confirmSas: vi.fn(async () => true),325 license: vi.fn(async () => "PPK-local"),326 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),327 deriveLocalIdentity: vi.fn(async () => identity("receiver")),328 upsertPairedDevice: receiverUpsert,329 onError,330 });331332 await sourceChannel.receive(); 333 await sourceChannel.receive(); 334 await sendFrame(sourceChannel, {335 type: "ADOPT_GRANT",336 root_key_hex: rootKeyHex,337 vault_id: vaultId,338 source: {339 device_id: "source-id",340 device_name: "Source",341 device_type: "extension",342 long_term_public_key_hex: "ab".repeat(32),343 },344 });345 await sourceChannel.receive(); 346 await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });347348 await expect(receiver).resolves.toBeNull();349 expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");350 expect(receiverUpsert).not.toHaveBeenCalled();351 });352353 it("T-A17: receiver safety policy runs before vault creation", async () => {354 const [receiverChannel, sourceChannel] = memoryPair();355 const rootKeyHex = "77".repeat(32);356 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));357 const createVaultFromRootKey = vi.fn();358 const receiver = requestAdoption(receiverChannel, {359 deviceName: "Receiver",360 generateDeviceId: () => "receiver-ephemeral",361 confirmSas: vi.fn(async () => true),362 license: vi.fn(async () => "PPK-local"),363 validateSource: vi.fn(async () => {364 throw new Error("JOIN_POPULATED_VAULT_BLOCKED");365 }),366 createVaultFromRootKey,367 deriveLocalIdentity: vi.fn(),368 upsertPairedDevice: vi.fn(),369 onError: vi.fn(),370 });371372 await sourceChannel.receive(); 373 await sourceChannel.receive(); 374 await sendFrame(sourceChannel, {375 type: "ADOPT_GRANT",376 root_key_hex: rootKeyHex,377 vault_id: vaultId,378 source: {379 device_id: "new-source",380 device_name: "Source",381 device_type: "extension",382 long_term_public_key_hex: "ab".repeat(32),383 },384 });385386 await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");387 expect(createVaultFromRootKey).not.toHaveBeenCalled();388 });389390 it("T-A31: does not apply a license if vault creation fails", async () => {391 const [receiverChannel, sourceChannel] = memoryPair();392 const rootKeyHex = "44".repeat(32);393 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));394 const applyLicense = vi.fn();395 const receiver = requestAdoption(receiverChannel, {396 deviceName: "Receiver",397 generateDeviceId: () => "receiver-ephemeral",398 confirmSas: vi.fn(async () => true),399 createVaultFromRootKey: vi.fn(async () => {400 throw new Error("commit_failed");401 }),402 deriveLocalIdentity: vi.fn(),403 upsertPairedDevice: vi.fn(),404 applyLicense,405 onError: vi.fn(),406 });407 await sourceChannel.receive();408 await sendFrame(sourceChannel, {409 type: "ADOPT_GRANT",410 root_key_hex: rootKeyHex,411 vault_id: vaultId,412 license: "PPK-test",413 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },414 });415416 await expect(receiver).rejects.toThrow("commit_failed");417 expect(applyLicense).not.toHaveBeenCalled();418 });419420 it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {421 const [receiverChannel, sourceChannel] = memoryPair();422 const createVaultFromRootKey = vi.fn();423 const receiver = requestAdoption(receiverChannel, {424 deviceName: "Receiver",425 generateDeviceId: () => "receiver-ephemeral",426 confirmSas: vi.fn(async () => true),427 createVaultFromRootKey,428 deriveLocalIdentity: vi.fn(),429 upsertPairedDevice: vi.fn(),430 applyLicense: vi.fn(),431 onError: vi.fn(),432 });433 await sourceChannel.receive();434 await sendFrame(sourceChannel, {435 type: "ADOPT_GRANT",436 root_key_hex: "55".repeat(32),437 vault_id: "99".repeat(32),438 license: "PPK-test",439 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },440 });441442 await receiver;443 expect(createVaultFromRootKey).not.toHaveBeenCalled();444 });445});