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("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {65 const { adapter } = await setupVault();66 const id1 = await adapter.getOurDeviceId();67 const id2 = await adapter.getOurDeviceId();68 expect(id1).toBe(id2);69 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}$/);70 });7172 it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {73 const { vault, adapter } = await setupVault();74 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });75 const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });76 await vault.deleteEntry(b.id);77 const m = await adapter.buildEntryManifest();78 expect(Object.keys(m).sort()).toEqual([a.id]);79 expect(m[a.id]).toBe(a.modifiedAt);80 });8182 it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {83 const { vault, adapter } = await setupVault();84 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });85 await vault.deleteEntry(a.id);86 const t = await adapter.buildTombstoneManifest();87 expect(Object.keys(t)).toEqual([a.id]);88 expect(typeof t[a.id]).toBe("number");89 expect(t[a.id]).toBeGreaterThan(0);90 });9192 it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {93 const { vault, adapter } = await setupVault();94 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });95 const env = await adapter.getEncryptedEntries([a.id]);96 expect(Object.keys(env)).toEqual([a.id]);97 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);98 99 expect(env[a.id]).not.toContain("Alpha");100 });101102 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {103 const { adapter } = await setupVault();104 const env = await adapter.getEncryptedEntries(["does-not-exist"]);105 expect(env).toEqual({});106 });107108 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {109 const { vault, adapter } = await setupVault();110 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });111 const e1 = await adapter.getEncryptedEntries([a.id]);112 const e2 = await adapter.getEncryptedEntries([a.id]);113 expect(e1[a.id]).not.toBe(e2[a.id]);114 });115116 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {117 const { vault, adapter } = await setupVault();118 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });119 const envelopes = await adapter.getEncryptedEntries([a.id]);120 121 122 123 124 125 126 127 vault.lock();128 await vault.unlock("correct horse battery staple");129 const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter");130 const adapter2 = createChirpSyncVaultAdapter();131 132 await adapter2.applyEntries(envelopes);133 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");134 });135136 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {137 const { vault, adapter } = await setupVault();138 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });139 140 141 142 await vault.updateEntry(local.id, { title: "Updated" });143 const newer = vault.getEntries().find((e) => e.id === local.id);144 expect(newer?.title).toBe("Updated");145 const envelopes = await adapter.getEncryptedEntries([local.id]);146 147 148 149 150 151 152 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };153 await vault.restoreEntries([older], "replace");154 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");155 await adapter.applyEntries(envelopes);156 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");157 });158159 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {160 const { vault, adapter } = await setupVault();161 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });162 const env = await adapter.getEncryptedEntries([a.id]);163 164 const swapped: Record<string, string> = { "wrong-id": env[a.id] };165 await adapter.applyEntries(swapped);166 167 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);168 });169170 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {171 const { vault, adapter } = await setupVault();172 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });173 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });174 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();175 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();176 });177178 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {179 const { vault, adapter } = await setupVault();180 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });181 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });182 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();183 });184185 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {186 const { adapter } = await setupVault();187 const { upsertPairedDevice } = await import("./paired-devices");188 await upsertPairedDevice({189 deviceId: "android-1",190 deviceName: "Pixel",191 deviceType: "android",192 longTermPublicKeyHex: "a".repeat(64),193 signingPublicKeyHex: await signingPubkeyHex("android-1"),194 wsPort: 49152,195 pairedAt: 1_700_000_000_000,196 trustSource: "direct",197 });198 await upsertPairedDevice({199 deviceId: "legacy-android",200 deviceName: "Legacy Pixel",201 deviceType: "android",202 longTermPublicKeyHex: "b".repeat(64),203 pairedAt: 1_700_000_000_100,204 trustSource: "direct",205 });206207 const manifest = await adapter.buildOutgoingDeviceManifest?.();208 expect(manifest).toBeDefined();209 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);210 expect(manifest?.body.devices).toHaveLength(1);211 expect(manifest?.body.devices[0]).toMatchObject({212 deviceId: "android-1",213 deviceName: "Pixel",214 deviceType: "android",215 longTermPublicKeyHex: "a".repeat(64),216 wsPort: 49152,217 });218 });219220 it("applyIncomingDeviceManifest accepts extension-vouched devices and preserves existing direct trust", async () => {221 const { adapter } = await setupVault();222 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");223 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");224 const {225 findPairedDeviceById,226 listPairedDevices,227 upsertPairedDevice,228 } = await import("./paired-devices");229230 const issuerId = "extension-issuer";231 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);232 await upsertPairedDevice({233 deviceId: issuerId,234 deviceName: "Firefox",235 deviceType: "extension",236 longTermPublicKeyHex: "c".repeat(64),237 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),238 pairedAt: 1_700_000_000_000,239 trustSource: "direct",240 });241 await upsertPairedDevice({242 deviceId: "android-existing",243 deviceName: "Existing Pixel",244 deviceType: "android",245 longTermPublicKeyHex: "d".repeat(64),246 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),247 pairedAt: 1_700_000_000_010,248 trustSource: "direct",249 });250251 const manifest = await buildSignedDeviceManifest(252 issuerId,253 [254 {255 deviceId: "android-new",256 deviceName: "New Pixel",257 deviceType: "android",258 longTermPublicKeyHex: "e".repeat(64),259 signingPublicKeyHex: await signingPubkeyHex("android-new"),260 wsPort: 51234,261 pairedAt: 1_700_000_000_020,262 },263 {264 deviceId: "android-existing",265 deviceName: "Existing Pixel renamed",266 deviceType: "android",267 longTermPublicKeyHex: "f".repeat(64),268 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),269 wsPort: 52345,270 pairedAt: 1_700_000_000_030,271 },272 ],273 issuerKp.privateKey,274 );275276 await adapter.applyIncomingDeviceManifest?.(manifest);277278 const added = await findPairedDeviceById("android-new");279 expect(added).toMatchObject({280 deviceName: "New Pixel",281 deviceType: "android",282 longTermPublicKeyHex: "e".repeat(64),283 trustSource: "vouched",284 wsPort: 51234,285 });286 const existing = await findPairedDeviceById("android-existing");287 expect(existing).toMatchObject({288 deviceName: "Existing Pixel renamed",289 longTermPublicKeyHex: "f".repeat(64),290 trustSource: "direct",291 wsPort: 52345,292 });293 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([294 "android-existing",295 "android-new",296 "extension-issuer",297 ]);298 });299300 it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {301 const { adapter } = await setupVault();302 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");303 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");304 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");305306 const mobileIssuerId = "android-issuer";307 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);308 await upsertPairedDevice({309 deviceId: mobileIssuerId,310 deviceName: "Issuer Pixel",311 deviceType: "android",312 longTermPublicKeyHex: "1".repeat(64),313 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),314 pairedAt: 1_700_000_000_000,315 trustSource: "direct",316 });317 const mobileManifest = await buildSignedDeviceManifest(318 mobileIssuerId,319 [{320 deviceId: "should-not-add",321 deviceName: "Other Pixel",322 deviceType: "android",323 longTermPublicKeyHex: "2".repeat(64),324 signingPublicKeyHex: await signingPubkeyHex("should-not-add"),325 pairedAt: 1_700_000_000_010,326 }],327 mobileIssuerKp.privateKey,328 );329 await adapter.applyIncomingDeviceManifest?.(mobileManifest);330 expect(await findPairedDeviceById("should-not-add")).toBeNull();331332 const extensionIssuerId = "extension-bad-sig";333 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);334 await upsertPairedDevice({335 deviceId: extensionIssuerId,336 deviceName: "Chrome",337 deviceType: "extension",338 longTermPublicKeyHex: "3".repeat(64),339 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),340 pairedAt: 1_700_000_000_020,341 trustSource: "direct",342 });343 const good = await buildSignedDeviceManifest(344 extensionIssuerId,345 [{346 deviceId: "forged-add",347 deviceName: "Forged Pixel",348 deviceType: "android",349 longTermPublicKeyHex: "4".repeat(64),350 signingPublicKeyHex: await signingPubkeyHex("forged-add"),351 pairedAt: 1_700_000_000_030,352 }],353 extensionKp.privateKey,354 );355 const forged = {356 ...good,357 signatureHex:358 good.signatureHex.slice(0, 2) +359 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +360 good.signatureHex.slice(4),361 };362 await adapter.applyIncomingDeviceManifest?.(forged);363 expect(await findPairedDeviceById("forged-add")).toBeNull();364 });365366 it("methods throw VaultLockedError when the vault is locked", async () => {367 const { vault, adapter } = await setupVault();368 vault.lock();369 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });370 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });371 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });372 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });373 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });374 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });375 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });376 });377});