chirp-sync-vault-adapter.test.ts

19.6 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("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    // vault_id derives from the stable root key, so it must not move.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    // Plaintext title must not be visible in the envelope.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    // Simulate a remote vault by bumping modifiedAt on the envelope's source147    // and then applying it back — we can't easily fork the module's storage,148    // so we just verify decrypt path by overwriting modifiedAt locally.149    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries150    // will re-add it because the local copy is tombstoned with an OLDER ts (since151    // we set modifiedAt forward by 1s in the new envelope payload).152    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.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    // applyEntries should be a no-op when local mtime >= envelope mtime158    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    // Manually produce a fresher remote envelope by encrypting an updated entry shape.166    // We can't easily synthesise this without the entry key — so we use a second167    // adapter instance backed by the same vault, after mutating the underlying entry.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    // Now revert the local entry to an older mtime by re-creating from scratch via173    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the174    // decrypt+merge path is that applyEntries on a fresher envelope keeps the175    // newer state, and on an older envelope is a no-op. We've covered the no-op176    // path above; for the apply path, simulate by replacing the in-memory entry177    // with an older copy and re-running applyEntries.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    // Re-key the envelope under a different id — adapter must refuse to merge it.190    const swapped: Record<string, string> = { "wrong-id": env[a.id] };191    await adapter.applyEntries(swapped);192    // Local entry remains unchanged; no new entry under "wrong-id"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  // --- MV-3: sync receive bounds ---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    // Make the local copy stale so the valid envelope would win on merge.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    // The valid envelope applied; the oversized one was skipped (never decrypted).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("applyIncomingDeviceManifest accepts extension-vouched devices and preserves existing direct trust", 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");294295    const issuerId = "extension-issuer";296    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);297    await upsertPairedDevice({298      deviceId: issuerId,299      deviceName: "Firefox",300      deviceType: "extension",301      longTermPublicKeyHex: "c".repeat(64),302      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),303      pairedAt: 1_700_000_000_000,304      trustSource: "direct",305    });306    await upsertPairedDevice({307      deviceId: "android-existing",308      deviceName: "Existing Pixel",309      deviceType: "android",310      longTermPublicKeyHex: "d".repeat(64),311      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),312      pairedAt: 1_700_000_000_010,313      trustSource: "direct",314    });315316    const manifest = await buildSignedDeviceManifest(317      issuerId,318      [319        {320          deviceId: "android-new",321          deviceName: "New Pixel",322          deviceType: "android",323          longTermPublicKeyHex: "e".repeat(64),324          signingPublicKeyHex: await signingPubkeyHex("android-new"),325          wsPort: 51234,326          pairedAt: 1_700_000_000_020,327        },328        {329          deviceId: "android-existing",330          deviceName: "Existing Pixel renamed",331          deviceType: "android",332          longTermPublicKeyHex: "f".repeat(64),333          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),334          wsPort: 52345,335          pairedAt: 1_700_000_000_030,336        },337      ],338      issuerKp.privateKey,339    );340341    await adapter.applyIncomingDeviceManifest?.(manifest);342343    const added = await findPairedDeviceById("android-new");344    expect(added).toMatchObject({345      deviceName: "New Pixel",346      deviceType: "android",347      longTermPublicKeyHex: "e".repeat(64),348      trustSource: "vouched",349      wsPort: 51234,350    });351    const existing = await findPairedDeviceById("android-existing");352    expect(existing).toMatchObject({353      deviceName: "Existing Pixel renamed",354      longTermPublicKeyHex: "f".repeat(64),355      trustSource: "direct",356      wsPort: 52345,357    });358    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([359      "android-existing",360      "android-new",361      "extension-issuer",362    ]);363  });364365  it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {366    const { adapter } = await setupVault();367    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");368    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");369    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");370371    const mobileIssuerId = "android-issuer";372    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);373    await upsertPairedDevice({374      deviceId: mobileIssuerId,375      deviceName: "Issuer Pixel",376      deviceType: "android",377      longTermPublicKeyHex: "1".repeat(64),378      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),379      pairedAt: 1_700_000_000_000,380      trustSource: "direct",381    });382    const mobileManifest = await buildSignedDeviceManifest(383      mobileIssuerId,384      [{385        deviceId: "should-not-add",386        deviceName: "Other Pixel",387        deviceType: "android",388        longTermPublicKeyHex: "2".repeat(64),389        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),390        pairedAt: 1_700_000_000_010,391      }],392      mobileIssuerKp.privateKey,393    );394    await adapter.applyIncomingDeviceManifest?.(mobileManifest);395    expect(await findPairedDeviceById("should-not-add")).toBeNull();396397    const extensionIssuerId = "extension-bad-sig";398    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);399    await upsertPairedDevice({400      deviceId: extensionIssuerId,401      deviceName: "Chrome",402      deviceType: "extension",403      longTermPublicKeyHex: "3".repeat(64),404      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),405      pairedAt: 1_700_000_000_020,406      trustSource: "direct",407    });408    const good = await buildSignedDeviceManifest(409      extensionIssuerId,410      [{411        deviceId: "forged-add",412        deviceName: "Forged Pixel",413        deviceType: "android",414        longTermPublicKeyHex: "4".repeat(64),415        signingPublicKeyHex: await signingPubkeyHex("forged-add"),416        pairedAt: 1_700_000_000_030,417      }],418      extensionKp.privateKey,419    );420    const forged = {421      ...good,422      signatureHex:423        good.signatureHex.slice(0, 2) +424        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +425        good.signatureHex.slice(4),426    };427    await adapter.applyIncomingDeviceManifest?.(forged);428    expect(await findPairedDeviceById("forged-add")).toBeNull();429  });430431  it("methods throw VaultLockedError when the vault is locked", async () => {432    const { vault, adapter } = await setupVault();433    vault.lock();434    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });435    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });436    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });437    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });438    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });439    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });440    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });441  });442});