import { beforeEach, describe, expect, it, vi } from "vitest"; type StorageShape = Record; function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome { const area = (store: StorageShape) => ({ get: vi.fn(async (key?: string | string[]) => { if (!key) return store; if (typeof key === "string") return { [key]: store[key] }; return key.reduce>((acc, k) => { acc[k] = store[k]; return acc; }, {}); }), set: vi.fn(async (next: Record) => { Object.assign(store, next); }), remove: vi.fn(async (keys: string | string[]) => { const list = Array.isArray(keys) ? keys : [keys]; for (const k of list) delete store[k]; }), }); return { storage: { local: area(local), sync: area(sync) }, alarms: { clear: vi.fn(), create: vi.fn(), onAlarm: { addListener: vi.fn() }, }, idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } }, runtime: { sendMessage: vi.fn(async () => undefined) }, } as unknown as typeof chrome; } describe("ChirpSyncVaultAdapter", () => { let local: StorageShape; let sync: StorageShape; beforeEach(() => { vi.resetModules(); local = {}; sync = {}; (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync); vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); })); }); async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./chirp-sync-coordinator").ChirpSyncVaultAdapter }> { const vault = await import("./vault"); await vault.createVault("correct horse battery staple"); const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter"); const adapter = createChirpSyncVaultAdapter(); return { vault, adapter }; } async function signingPubkeyHex(deviceId: string): Promise { const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId); return pubkeyToHex(kp.publicKeyRaw); } it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => { const { adapter } = await setupVault(); const id1 = await adapter.computeVaultId(); const id2 = await adapter.computeVaultId(); expect(id1).toBe(id2); expect(id1).toMatch(/^[0-9a-f]{64}$/); }); it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => { const { vault, adapter } = await setupVault(); const deviceId = await adapter.getOurDeviceId(); const vaultIdBefore = await adapter.computeVaultId(); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const rootBefore = vault.getRootKeyHex()!; const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16))); const signBefore = pubkeyToHex( (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw, ); expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true); vault.lock(); expect(await vault.unlock("a different passphrase")).toBe(true); // vault_id derives from the stable root key, so it must not move. expect(await adapter.computeVaultId()).toBe(vaultIdBefore); const rootAfter = vault.getRootKeyHex()!; expect(rootAfter).toBe(rootBefore); const signAfter = pubkeyToHex( (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw, ); expect(signAfter).toBe(signBefore); }); it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => { const { adapter } = await setupVault(); const id1 = await adapter.getOurDeviceId(); const id2 = await adapter.getOurDeviceId(); expect(id1).toBe(id2); 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}$/); }); it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" }); await vault.deleteEntry(b.id); const m = await adapter.buildEntryManifest(); expect(Object.keys(m).sort()).toEqual([a.id]); expect(m[a.id]).toBe(a.modifiedAt); }); it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); await vault.deleteEntry(a.id); const t = await adapter.buildTombstoneManifest(); expect(Object.keys(t)).toEqual([a.id]); expect(typeof t[a.id]).toBe("number"); expect(t[a.id]).toBeGreaterThan(0); }); it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const env = await adapter.getEncryptedEntries([a.id]); expect(Object.keys(env)).toEqual([a.id]); expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/); // Plaintext title must not be visible in the envelope. expect(env[a.id]).not.toContain("Alpha"); }); it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => { const { adapter } = await setupVault(); const env = await adapter.getEncryptedEntries(["does-not-exist"]); expect(env).toEqual({}); }); it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const e1 = await adapter.getEncryptedEntries([a.id]); const e2 = await adapter.getEncryptedEntries([a.id]); expect(e1[a.id]).not.toBe(e2[a.id]); }); it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" }); const envelopes = await adapter.getEncryptedEntries([a.id]); // Simulate a remote vault by bumping modifiedAt on the envelope's source // and then applying it back — we can't easily fork the module's storage, // so we just verify decrypt path by overwriting modifiedAt locally. // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries // will re-add it because the local copy is tombstoned with an OLDER ts (since // we set modifiedAt forward by 1s in the new envelope payload). // Instead, simpler proof: apply the envelope as-is to a fresh adapter import. vault.lock(); await vault.unlock("correct horse battery staple"); const { createChirpSyncVaultAdapter } = await import("./chirp-sync-vault-adapter"); const adapter2 = createChirpSyncVaultAdapter(); // applyEntries should be a no-op when local mtime >= envelope mtime await adapter2.applyEntries(envelopes); expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha"); }); it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => { const { vault, adapter } = await setupVault(); const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" }); // Manually produce a fresher remote envelope by encrypting an updated entry shape. // We can't easily synthesise this without the entry key — so we use a second // adapter instance backed by the same vault, after mutating the underlying entry. await vault.updateEntry(local.id, { title: "Updated" }); const newer = vault.getEntries().find((e) => e.id === local.id); expect(newer?.title).toBe("Updated"); const envelopes = await adapter.getEncryptedEntries([local.id]); // Now revert the local entry to an older mtime by re-creating from scratch via // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the // decrypt+merge path is that applyEntries on a fresher envelope keeps the // newer state, and on an older envelope is a no-op. We've covered the no-op // path above; for the apply path, simulate by replacing the in-memory entry // with an older copy and re-running applyEntries. const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" }; await vault.restoreEntries([older], "replace"); expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale"); await adapter.applyEntries(envelopes); expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated"); }); it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); const env = await adapter.getEncryptedEntries([a.id]); // Re-key the envelope under a different id — adapter must refuse to merge it. const swapped: Record = { "wrong-id": env[a.id] }; await adapter.applyEntries(swapped); // Local entry remains unchanged; no new entry under "wrong-id" expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]); }); it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 }); expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined(); expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined(); }); it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => { const { vault, adapter } = await setupVault(); const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" }); await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 }); expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined(); }); it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => { const { adapter } = await setupVault(); const { upsertPairedDevice } = await import("./paired-devices"); await upsertPairedDevice({ deviceId: "android-1", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-1"), wsPort: 49152, pairedAt: 1_700_000_000_000, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "legacy-android", deviceName: "Legacy Pixel", deviceType: "android", longTermPublicKeyHex: "b".repeat(64), pairedAt: 1_700_000_000_100, trustSource: "direct", }); const manifest = await adapter.buildOutgoingDeviceManifest?.(); expect(manifest).toBeDefined(); expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/); expect(manifest?.body.devices).toHaveLength(1); expect(manifest?.body.devices[0]).toMatchObject({ deviceId: "android-1", deviceName: "Pixel", deviceType: "android", longTermPublicKeyHex: "a".repeat(64), wsPort: 49152, }); }); it("applyIncomingDeviceManifest accepts extension-vouched devices and preserves existing direct trust", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, listPairedDevices, upsertPairedDevice, } = await import("./paired-devices"); const issuerId = "extension-issuer"; const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId); await upsertPairedDevice({ deviceId: issuerId, deviceName: "Firefox", deviceType: "extension", longTermPublicKeyHex: "c".repeat(64), signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw), pairedAt: 1_700_000_000_000, trustSource: "direct", }); await upsertPairedDevice({ deviceId: "android-existing", deviceName: "Existing Pixel", deviceType: "android", longTermPublicKeyHex: "d".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-old"), pairedAt: 1_700_000_000_010, trustSource: "direct", }); const manifest = await buildSignedDeviceManifest( issuerId, [ { deviceId: "android-new", deviceName: "New Pixel", deviceType: "android", longTermPublicKeyHex: "e".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-new"), wsPort: 51234, pairedAt: 1_700_000_000_020, }, { deviceId: "android-existing", deviceName: "Existing Pixel renamed", deviceType: "android", longTermPublicKeyHex: "f".repeat(64), signingPublicKeyHex: await signingPubkeyHex("android-existing-new"), wsPort: 52345, pairedAt: 1_700_000_000_030, }, ], issuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.(manifest); const added = await findPairedDeviceById("android-new"); expect(added).toMatchObject({ deviceName: "New Pixel", deviceType: "android", longTermPublicKeyHex: "e".repeat(64), trustSource: "vouched", wsPort: 51234, }); const existing = await findPairedDeviceById("android-existing"); expect(existing).toMatchObject({ deviceName: "Existing Pixel renamed", longTermPublicKeyHex: "f".repeat(64), trustSource: "direct", wsPort: 52345, }); expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([ "android-existing", "android-new", "extension-issuer", ]); }); it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => { const { adapter } = await setupVault(); const { buildSignedDeviceManifest } = await import("../shared/device-manifest"); const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity"); const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices"); const mobileIssuerId = "android-issuer"; const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId); await upsertPairedDevice({ deviceId: mobileIssuerId, deviceName: "Issuer Pixel", deviceType: "android", longTermPublicKeyHex: "1".repeat(64), signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw), pairedAt: 1_700_000_000_000, trustSource: "direct", }); const mobileManifest = await buildSignedDeviceManifest( mobileIssuerId, [{ deviceId: "should-not-add", deviceName: "Other Pixel", deviceType: "android", longTermPublicKeyHex: "2".repeat(64), signingPublicKeyHex: await signingPubkeyHex("should-not-add"), pairedAt: 1_700_000_000_010, }], mobileIssuerKp.privateKey, ); await adapter.applyIncomingDeviceManifest?.(mobileManifest); expect(await findPairedDeviceById("should-not-add")).toBeNull(); const extensionIssuerId = "extension-bad-sig"; const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId); await upsertPairedDevice({ deviceId: extensionIssuerId, deviceName: "Chrome", deviceType: "extension", longTermPublicKeyHex: "3".repeat(64), signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw), pairedAt: 1_700_000_000_020, trustSource: "direct", }); const good = await buildSignedDeviceManifest( extensionIssuerId, [{ deviceId: "forged-add", deviceName: "Forged Pixel", deviceType: "android", longTermPublicKeyHex: "4".repeat(64), signingPublicKeyHex: await signingPubkeyHex("forged-add"), pairedAt: 1_700_000_000_030, }], extensionKp.privateKey, ); const forged = { ...good, signatureHex: good.signatureHex.slice(0, 2) + ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) + good.signatureHex.slice(4), }; await adapter.applyIncomingDeviceManifest?.(forged); expect(await findPairedDeviceById("forged-add")).toBeNull(); }); it("methods throw VaultLockedError when the vault is locked", async () => { const { vault, adapter } = await setupVault(); vault.lock(); await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" }); await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" }); }); });