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-A11: SAS reject aborts before ADOPT_GRANT", async () => {103 const [receiverChannel, sourceChannel] = memoryPair();104 const rootKeyHex = vi.fn(async () => "11".repeat(32));105 const createVaultFromRootKey = vi.fn();106 const receiver = requestAdoption(receiverChannel, {107 deviceName: "Receiver",108 generateDeviceId: () => "receiver-ephemeral",109 confirmSas: vi.fn(async () => false),110 createVaultFromRootKey,111 deriveLocalIdentity: vi.fn(),112 upsertPairedDevice: vi.fn(),113 onError: vi.fn(),114 });115 const request = JSON.parse(116 new TextDecoder().decode(await sourceChannel.receive()),117 ) as AdoptRequest;118 const source = grantAdoption(sourceChannel, request, {119 sourceIdentity: identity("source"),120 rootKeyHex,121 vaultId: vi.fn(),122 onConsent: vi.fn(async () => true),123 upsertPairedDevice: vi.fn(),124 });125126 await expect(receiver).resolves.toBeNull();127 await expect(source).resolves.toBeNull();128 expect(rootKeyHex).not.toHaveBeenCalled();129 expect(createVaultFromRootKey).not.toHaveBeenCalled();130 });131132 it("T-A12: consent reject aborts before ADOPT_GRANT and receiver remains vault-less", async () => {133 const [receiverChannel, sourceChannel] = memoryPair();134 const createVaultFromRootKey = vi.fn();135 const errors: string[] = [];136 const receiver = requestAdoption(receiverChannel, {137 deviceName: "Receiver",138 generateDeviceId: () => "receiver-ephemeral",139 confirmSas: vi.fn(async () => true),140 createVaultFromRootKey,141 deriveLocalIdentity: vi.fn(),142 upsertPairedDevice: vi.fn(),143 onError: (err) => errors.push(err),144 });145 const request = JSON.parse(new TextDecoder().decode(await sourceChannel.receive())) as AdoptRequest;146 await grantAdoption(sourceChannel, request, {147 sourceIdentity: identity("source"),148 rootKeyHex: vi.fn(),149 vaultId: vi.fn(),150 onConsent: vi.fn(async () => false),151 upsertPairedDevice: vi.fn(),152 });153 await Promise.race([receiver, Promise.resolve()]);154155 expect(createVaultFromRootKey).not.toHaveBeenCalled();156 expect(errors).toEqual([]);157 });158159 it("T-A13: invalid root_key_hex aborts gracefully", async () => {160 const [receiverChannel, sourceChannel] = memoryPair();161 const onError = vi.fn();162 const createVaultFromRootKey = vi.fn();163 const receiver = requestAdoption(receiverChannel, {164 deviceName: "Receiver",165 generateDeviceId: () => "receiver-ephemeral",166 confirmSas: vi.fn(async () => true),167 createVaultFromRootKey,168 deriveLocalIdentity: vi.fn(),169 upsertPairedDevice: vi.fn(),170 onError,171 });172 await sourceChannel.receive();173 await sendFrame(sourceChannel, {174 type: "ADOPT_GRANT",175 root_key_hex: "bad",176 vault_id: "00".repeat(32),177 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },178 } as never);179 await receiver;180181 expect(onError).toHaveBeenCalledWith("ADOPT_UNEXPECTED_FRAME");182 expect(createVaultFromRootKey).not.toHaveBeenCalled();183 });184185 it("T-A14: vault_id mismatch aborts before vault creation", async () => {186 const [receiverChannel, sourceChannel] = memoryPair();187 const onError = vi.fn();188 const createVaultFromRootKey = vi.fn();189 const receiver = requestAdoption(receiverChannel, {190 deviceName: "Receiver",191 generateDeviceId: () => "receiver-ephemeral",192 confirmSas: vi.fn(async () => true),193 createVaultFromRootKey,194 deriveLocalIdentity: vi.fn(),195 upsertPairedDevice: vi.fn(),196 onError,197 });198 await sourceChannel.receive();199 await sendFrame(sourceChannel, {200 type: "ADOPT_GRANT",201 root_key_hex: "22".repeat(32),202 vault_id: "00".repeat(32),203 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },204 });205 await receiver;206207 expect(onError).toHaveBeenCalledWith("ADOPT_VAULT_ID_MISMATCH");208 expect(createVaultFromRootKey).not.toHaveBeenCalled();209 });210211 it("T-A15: source does not persist receiver until ADOPT_IDENTITY arrives", async () => {212 const [receiverChannel, sourceChannel] = memoryPair();213 const upsert = vi.fn();214 const promise = grantAdoption(sourceChannel, {215 type: "ADOPT_REQUEST",216 device_id: "receiver-ephemeral",217 device_name: "Receiver",218 device_type: "extension",219 }, {220 sourceIdentity: identity("source"),221 rootKeyHex: vi.fn(async () => "33".repeat(32)),222 vaultId: vi.fn(async () => await computeVaultId(Uint8Array.from(Buffer.from("33".repeat(32), "hex")))),223 onConsent: vi.fn(async () => true),224 upsertPairedDevice: upsert,225 });226 await sendFrame(receiverChannel, {227 type: "ADOPT_CONFIRM",228 confirmed: true,229 });230231 await Promise.race([promise, Promise.resolve()]);232 expect(upsert).not.toHaveBeenCalled();233 });234235 it("T-A16: receiver does not persist source without ADOPT_COMPLETE", async () => {236 const [receiverChannel, sourceChannel] = memoryPair();237 const rootKeyHex = "66".repeat(32);238 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));239 const receiverUpsert = vi.fn();240 const onError = vi.fn();241 const receiver = requestAdoption(receiverChannel, {242 deviceName: "Receiver",243 generateDeviceId: () => "receiver-ephemeral",244 confirmSas: vi.fn(async () => true),245 createVaultFromRootKey: vi.fn(async () => ({ vaultId })),246 deriveLocalIdentity: vi.fn(async () => identity("receiver")),247 upsertPairedDevice: receiverUpsert,248 onError,249 });250251 await sourceChannel.receive(); 252 await sourceChannel.receive(); 253 await sendFrame(sourceChannel, {254 type: "ADOPT_GRANT",255 root_key_hex: rootKeyHex,256 vault_id: vaultId,257 source: {258 device_id: "source-id",259 device_name: "Source",260 device_type: "extension",261 long_term_public_key_hex: "ab".repeat(32),262 },263 });264 await sourceChannel.receive(); 265 await sendFrame(sourceChannel, { type: "ADOPT_CONFIRM", confirmed: true });266267 await expect(receiver).resolves.toBeNull();268 expect(onError).toHaveBeenCalledWith("ADOPT_COMPLETION_MISSING");269 expect(receiverUpsert).not.toHaveBeenCalled();270 });271272 it("T-A17: receiver safety policy runs before vault creation", async () => {273 const [receiverChannel, sourceChannel] = memoryPair();274 const rootKeyHex = "77".repeat(32);275 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));276 const createVaultFromRootKey = vi.fn();277 const receiver = requestAdoption(receiverChannel, {278 deviceName: "Receiver",279 generateDeviceId: () => "receiver-ephemeral",280 confirmSas: vi.fn(async () => true),281 validateSource: vi.fn(async () => {282 throw new Error("JOIN_POPULATED_VAULT_BLOCKED");283 }),284 createVaultFromRootKey,285 deriveLocalIdentity: vi.fn(),286 upsertPairedDevice: vi.fn(),287 onError: vi.fn(),288 });289290 await sourceChannel.receive(); 291 await sourceChannel.receive(); 292 await sendFrame(sourceChannel, {293 type: "ADOPT_GRANT",294 root_key_hex: rootKeyHex,295 vault_id: vaultId,296 source: {297 device_id: "new-source",298 device_name: "Source",299 device_type: "extension",300 long_term_public_key_hex: "ab".repeat(32),301 },302 });303304 await expect(receiver).rejects.toThrow("JOIN_POPULATED_VAULT_BLOCKED");305 expect(createVaultFromRootKey).not.toHaveBeenCalled();306 });307308 it("T-A31: does not apply a license if vault creation fails", async () => {309 const [receiverChannel, sourceChannel] = memoryPair();310 const rootKeyHex = "44".repeat(32);311 const vaultId = await computeVaultId(Uint8Array.from(Buffer.from(rootKeyHex, "hex")));312 const applyLicense = vi.fn();313 const receiver = requestAdoption(receiverChannel, {314 deviceName: "Receiver",315 generateDeviceId: () => "receiver-ephemeral",316 confirmSas: vi.fn(async () => true),317 createVaultFromRootKey: vi.fn(async () => {318 throw new Error("commit_failed");319 }),320 deriveLocalIdentity: vi.fn(),321 upsertPairedDevice: vi.fn(),322 applyLicense,323 onError: vi.fn(),324 });325 await sourceChannel.receive();326 await sendFrame(sourceChannel, {327 type: "ADOPT_GRANT",328 root_key_hex: rootKeyHex,329 vault_id: vaultId,330 license: "PPK-test",331 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },332 });333334 await expect(receiver).rejects.toThrow("commit_failed");335 expect(applyLicense).not.toHaveBeenCalled();336 });337338 it("T-A32: never creates a vault when the grant vault_id mismatches the root key", async () => {339 const [receiverChannel, sourceChannel] = memoryPair();340 const createVaultFromRootKey = vi.fn();341 const receiver = requestAdoption(receiverChannel, {342 deviceName: "Receiver",343 generateDeviceId: () => "receiver-ephemeral",344 confirmSas: vi.fn(async () => true),345 createVaultFromRootKey,346 deriveLocalIdentity: vi.fn(),347 upsertPairedDevice: vi.fn(),348 applyLicense: vi.fn(),349 onError: vi.fn(),350 });351 await sourceChannel.receive();352 await sendFrame(sourceChannel, {353 type: "ADOPT_GRANT",354 root_key_hex: "55".repeat(32),355 vault_id: "99".repeat(32),356 license: "PPK-test",357 source: { device_id: "source", device_name: "Source", device_type: "extension", long_term_public_key_hex: "ab".repeat(32) },358 });359360 await receiver;361 expect(createVaultFromRootKey).not.toHaveBeenCalled();362 });363});