chirp-sync-vault-adapter.test.ts

21.9 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("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    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending347    // queue for explicit user confirmation.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    // The already-paired device is still updated in place.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    // recordSyncAlerts is fire-and-forget — flush microtasks.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});