1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {6 const area = (store: StorageShape) => ({7 get: vi.fn(async (key?: string | string[]) => {8 if (!key) return store;9 if (typeof key === "string") return { [key]: store[key] };10 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});11 }),12 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),13 remove: vi.fn(async (keys: string | string[]) => {14 const list = Array.isArray(keys) ? keys : [keys];15 for (const k of list) delete store[k];16 }),17 });18 return {19 storage: { local: area(local), sync: area(sync) },20 alarms: {21 clear: vi.fn(),22 create: vi.fn(),23 onAlarm: { addListener: vi.fn() },24 },25 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },26 runtime: { sendMessage: vi.fn(async () => undefined) },27 } as unknown as typeof chrome;28}2930describe("ChirpSyncVaultAdapter", () => {31 let local: StorageShape;32 let sync: StorageShape;3334 beforeEach(() => {35 vi.resetModules();36 local = {};37 sync = {};38 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);39 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));40 });4142 async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./chirp-sync-coordinator").ChirpSyncVaultAdapter }> {43 const vault = await import("./vault");44 await vault.createVault("correct horse battery staple");45 const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");46 const adapter = createChirpSyncVaultAdapter();47 return { vault, adapter };48 }4950 async function signingPubkeyHex(deviceId: string): Promise<string> {51 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");52 const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);53 return pubkeyToHex(kp.publicKeyRaw);54 }5556 it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {57 const { adapter } = await setupVault();58 const id1 = await adapter.computeVaultId();59 const id2 = await adapter.computeVaultId();60 expect(id1).toBe(id2);61 expect(id1).toMatch(/^[0-9a-f]{64}$/);62 });6364 it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {65 const { vault, adapter } = await setupVault();66 const deviceId = await adapter.getOurDeviceId();67 const vaultIdBefore = await adapter.computeVaultId();6869 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");70 const rootBefore = vault.getRootKeyHex()!;71 const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));72 const signBefore = pubkeyToHex(73 (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,74 );7576 expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);77 vault.lock();78 expect(await vault.unlock("a different passphrase")).toBe(true);7980 81 expect(await adapter.computeVaultId()).toBe(vaultIdBefore);82 const rootAfter = vault.getRootKeyHex()!;83 expect(rootAfter).toBe(rootBefore);84 const signAfter = pubkeyToHex(85 (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,86 );87 expect(signAfter).toBe(signBefore);88 });8990 it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {91 const { adapter } = await setupVault();92 const id1 = await adapter.getOurDeviceId();93 const id2 = await adapter.getOurDeviceId();94 expect(id1).toBe(id2);95 expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);96 });9798 it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {99 const { vault, adapter } = await setupVault();100 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });101 const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });102 await vault.deleteEntry(b.id);103 const m = await adapter.buildEntryManifest();104 expect(Object.keys(m).sort()).toEqual([a.id]);105 expect(m[a.id]).toBe(a.modifiedAt);106 });107108 it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {109 const { vault, adapter } = await setupVault();110 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });111 await vault.deleteEntry(a.id);112 const t = await adapter.buildTombstoneManifest();113 expect(Object.keys(t)).toEqual([a.id]);114 expect(typeof t[a.id]).toBe("number");115 expect(t[a.id]).toBeGreaterThan(0);116 });117118 it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {119 const { vault, adapter } = await setupVault();120 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });121 const env = await adapter.getEncryptedEntries([a.id]);122 expect(Object.keys(env)).toEqual([a.id]);123 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);124 125 expect(env[a.id]).not.toContain("Alpha");126 });127128 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {129 const { adapter } = await setupVault();130 const env = await adapter.getEncryptedEntries(["does-not-exist"]);131 expect(env).toEqual({});132 });133134 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {135 const { vault, adapter } = await setupVault();136 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });137 const e1 = await adapter.getEncryptedEntries([a.id]);138 const e2 = await adapter.getEncryptedEntries([a.id]);139 expect(e1[a.id]).not.toBe(e2[a.id]);140 });141142 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {143 const { vault, adapter } = await setupVault();144 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });145 const envelopes = await adapter.getEncryptedEntries([a.id]);146 147 148 149 150 151 152 153 vault.lock();154 await vault.unlock("correct horse battery staple");155 const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");156 const adapter2 = createChirpSyncVaultAdapter();157 158 await adapter2.applyEntries(envelopes);159 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");160 });161162 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {163 const { vault, adapter } = await setupVault();164 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });165 166 167 168 await vault.updateEntry(local.id, { title: "Updated" });169 const newer = vault.getEntries().find((e) => e.id === local.id);170 expect(newer?.title).toBe("Updated");171 const envelopes = await adapter.getEncryptedEntries([local.id]);172 173 174 175 176 177 178 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };179 await vault.restoreEntries([older], "replace");180 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");181 await adapter.applyEntries(envelopes);182 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");183 });184185 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {186 const { vault, adapter } = await setupVault();187 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });188 const env = await adapter.getEncryptedEntries([a.id]);189 190 const swapped: Record<string, string> = { "wrong-id": env[a.id] };191 await adapter.applyEntries(swapped);192 193 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);194 });195196 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {197 const { vault, adapter } = await setupVault();198 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });199 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });200 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();201 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();202 });203204 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {205 const { vault, adapter } = await setupVault();206 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });207 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });208 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();209 });210211 212213 it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {214 const { adapter } = await setupVault();215 const { SYNC_LIMITS } = await import("../shared/sync-limits");216 const tooMany: Record<string, string> = {};217 for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";218 await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);219 });220221 it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {222 const { vault, adapter } = await setupVault();223 const { SYNC_LIMITS } = await import("../shared/sync-limits");224 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });225 await vault.updateEntry(local.id, { title: "Updated" });226 const envelopes = await adapter.getEncryptedEntries([local.id]);227 228 const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };229 await vault.restoreEntries([older], "replace");230 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");231232 const oversized = "A".repeat(233 Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,234 );235 await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });236237 238 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");239 expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);240 });241242 it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {243 const { adapter } = await setupVault();244 const { SYNC_LIMITS } = await import("../shared/sync-limits");245 const tooMany: Record<string, number> = {};246 for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;247 await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);248 });249250 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {251 const { adapter } = await setupVault();252 const { upsertPairedDevice } = await import("./paired-devices");253 await upsertPairedDevice({254 deviceId: "android-1",255 deviceName: "Pixel",256 deviceType: "android",257 longTermPublicKeyHex: "a".repeat(64),258 signingPublicKeyHex: await signingPubkeyHex("android-1"),259 wsPort: 49152,260 pairedAt: 1_700_000_000_000,261 trustSource: "direct",262 });263 await upsertPairedDevice({264 deviceId: "legacy-android",265 deviceName: "Legacy Pixel",266 deviceType: "android",267 longTermPublicKeyHex: "b".repeat(64),268 pairedAt: 1_700_000_000_100,269 trustSource: "direct",270 });271272 const manifest = await adapter.buildOutgoingDeviceManifest?.();273 expect(manifest).toBeDefined();274 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);275 expect(manifest?.body.devices).toHaveLength(1);276 expect(manifest?.body.devices[0]).toMatchObject({277 deviceId: "android-1",278 deviceName: "Pixel",279 deviceType: "android",280 longTermPublicKeyHex: "a".repeat(64),281 wsPort: 49152,282 });283 });284285 it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {286 const { adapter } = await setupVault();287 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");288 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");289 const {290 findPairedDeviceById,291 listPairedDevices,292 upsertPairedDevice,293 } = await import("./paired-devices");294 const { listPendingVouchedDevices } = await import(295 "./pending-vouched-devices"296 );297298 const issuerId = "extension-issuer";299 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);300 await upsertPairedDevice({301 deviceId: issuerId,302 deviceName: "Firefox",303 deviceType: "extension",304 longTermPublicKeyHex: "c".repeat(64),305 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),306 pairedAt: 1_700_000_000_000,307 trustSource: "direct",308 });309 await upsertPairedDevice({310 deviceId: "android-existing",311 deviceName: "Existing Pixel",312 deviceType: "android",313 longTermPublicKeyHex: "d".repeat(64),314 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),315 pairedAt: 1_700_000_000_010,316 trustSource: "direct",317 });318319 const manifest = await buildSignedDeviceManifest(320 issuerId,321 [322 {323 deviceId: "android-new",324 deviceName: "New Pixel",325 deviceType: "android",326 longTermPublicKeyHex: "e".repeat(64),327 signingPublicKeyHex: await signingPubkeyHex("android-new"),328 wsPort: 51234,329 pairedAt: 1_700_000_000_020,330 },331 {332 deviceId: "android-existing",333 deviceName: "Existing Pixel renamed",334 deviceType: "android",335 longTermPublicKeyHex: "f".repeat(64),336 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),337 wsPort: 52345,338 pairedAt: 1_700_000_000_030,339 },340 ],341 issuerKp.privateKey,342 );343344 await adapter.applyIncomingDeviceManifest?.(manifest);345346 347 348 expect(await findPairedDeviceById("android-new")).toBeNull();349 const pending = await listPendingVouchedDevices();350 expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);351 expect(pending[0]).toMatchObject({352 deviceName: "New Pixel",353 longTermPublicKeyHex: "e".repeat(64),354 vouchedByDeviceId: "extension-issuer",355 });356357 358 const existing = await findPairedDeviceById("android-existing");359 expect(existing).toMatchObject({360 deviceName: "Existing Pixel renamed",361 longTermPublicKeyHex: "f".repeat(64),362 trustSource: "direct",363 wsPort: 52345,364 });365 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([366 "android-existing",367 "extension-issuer",368 ]);369 });370371 it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {372 const { adapter } = await setupVault();373 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");374 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");375 const { upsertPairedDevice } = await import("./paired-devices");376 const { getPendingSyncAlerts } = await import("./sync-alerts");377378 const issuerId = "extension-issuer";379 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);380 await upsertPairedDevice({381 deviceId: issuerId,382 deviceName: "Firefox",383 deviceType: "extension",384 longTermPublicKeyHex: "c".repeat(64),385 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),386 pairedAt: 1,387 trustSource: "direct",388 });389 await upsertPairedDevice({390 deviceId: "android-existing",391 deviceName: "Pixel",392 deviceType: "android",393 longTermPublicKeyHex: "d".repeat(64),394 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),395 pairedAt: 2,396 trustSource: "direct",397 });398399 const manifest = await buildSignedDeviceManifest(400 issuerId,401 [402 {403 deviceId: "android-existing",404 deviceName: "Pixel",405 deviceType: "android",406 longTermPublicKeyHex: "f".repeat(64),407 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),408 wsPort: 52345,409 pairedAt: 3,410 },411 ],412 issuerKp.privateKey,413 );414415 await adapter.applyIncomingDeviceManifest?.(manifest);416 417 for (let i = 0; i < 25; i++) await Promise.resolve();418419 const alerts = await getPendingSyncAlerts();420 expect(421 alerts.some(422 (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",423 ),424 ).toBe(true);425 });426427 it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {428 const { adapter } = await setupVault();429 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");430 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");431 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");432433 const mobileIssuerId = "android-issuer";434 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);435 await upsertPairedDevice({436 deviceId: mobileIssuerId,437 deviceName: "Issuer Pixel",438 deviceType: "android",439 longTermPublicKeyHex: "1".repeat(64),440 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),441 pairedAt: 1_700_000_000_000,442 trustSource: "direct",443 });444 const mobileManifest = await buildSignedDeviceManifest(445 mobileIssuerId,446 [{447 deviceId: "should-not-add",448 deviceName: "Other Pixel",449 deviceType: "android",450 longTermPublicKeyHex: "2".repeat(64),451 signingPublicKeyHex: await signingPubkeyHex("should-not-add"),452 pairedAt: 1_700_000_000_010,453 }],454 mobileIssuerKp.privateKey,455 );456 await adapter.applyIncomingDeviceManifest?.(mobileManifest);457 expect(await findPairedDeviceById("should-not-add")).toBeNull();458459 const extensionIssuerId = "extension-bad-sig";460 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);461 await upsertPairedDevice({462 deviceId: extensionIssuerId,463 deviceName: "Chrome",464 deviceType: "extension",465 longTermPublicKeyHex: "3".repeat(64),466 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),467 pairedAt: 1_700_000_000_020,468 trustSource: "direct",469 });470 const good = await buildSignedDeviceManifest(471 extensionIssuerId,472 [{473 deviceId: "forged-add",474 deviceName: "Forged Pixel",475 deviceType: "android",476 longTermPublicKeyHex: "4".repeat(64),477 signingPublicKeyHex: await signingPubkeyHex("forged-add"),478 pairedAt: 1_700_000_000_030,479 }],480 extensionKp.privateKey,481 );482 const forged = {483 ...good,484 signatureHex:485 good.signatureHex.slice(0, 2) +486 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +487 good.signatureHex.slice(4),488 };489 await adapter.applyIncomingDeviceManifest?.(forged);490 expect(await findPairedDeviceById("forged-add")).toBeNull();491 });492493 it("methods throw VaultLockedError when the vault is locked", async () => {494 const { vault, adapter } = await setupVault();495 vault.lock();496 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });497 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });498 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });499 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });500 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });501 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });502 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });503 });504});