chirp-sync-vault-adapter.test.ts

16.4 KB · raw

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    // Plaintext title must not be visible in the envelope.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    // Simulate a remote vault by bumping modifiedAt on the envelope's source121    // and then applying it back — we can't easily fork the module's storage,122    // so we just verify decrypt path by overwriting modifiedAt locally.123    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries124    // will re-add it because the local copy is tombstoned with an OLDER ts (since125    // we set modifiedAt forward by 1s in the new envelope payload).126    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.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    // applyEntries should be a no-op when local mtime >= envelope mtime132    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    // Manually produce a fresher remote envelope by encrypting an updated entry shape.140    // We can't easily synthesise this without the entry key — so we use a second141    // adapter instance backed by the same vault, after mutating the underlying entry.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    // Now revert the local entry to an older mtime by re-creating from scratch via147    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the148    // decrypt+merge path is that applyEntries on a fresher envelope keeps the149    // newer state, and on an older envelope is a no-op. We've covered the no-op150    // path above; for the apply path, simulate by replacing the in-memory entry151    // with an older copy and re-running applyEntries.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    // Re-key the envelope under a different id — adapter must refuse to merge it.164    const swapped: Record<string, string> = { "wrong-id": env[a.id] };165    await adapter.applyEntries(swapped);166    // Local entry remains unchanged; no new entry under "wrong-id"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});