chirp-sync-vault-adapter.test.ts

17.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  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {212    const { adapter } = await setupVault();213    const { upsertPairedDevice } = await import("./paired-devices");214    await upsertPairedDevice({215      deviceId: "android-1",216      deviceName: "Pixel",217      deviceType: "android",218      longTermPublicKeyHex: "a".repeat(64),219      signingPublicKeyHex: await signingPubkeyHex("android-1"),220      wsPort: 49152,221      pairedAt: 1_700_000_000_000,222      trustSource: "direct",223    });224    await upsertPairedDevice({225      deviceId: "legacy-android",226      deviceName: "Legacy Pixel",227      deviceType: "android",228      longTermPublicKeyHex: "b".repeat(64),229      pairedAt: 1_700_000_000_100,230      trustSource: "direct",231    });232233    const manifest = await adapter.buildOutgoingDeviceManifest?.();234    expect(manifest).toBeDefined();235    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);236    expect(manifest?.body.devices).toHaveLength(1);237    expect(manifest?.body.devices[0]).toMatchObject({238      deviceId: "android-1",239      deviceName: "Pixel",240      deviceType: "android",241      longTermPublicKeyHex: "a".repeat(64),242      wsPort: 49152,243    });244  });245246  it("applyIncomingDeviceManifest accepts extension-vouched devices and preserves existing direct trust", async () => {247    const { adapter } = await setupVault();248    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");249    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");250    const {251      findPairedDeviceById,252      listPairedDevices,253      upsertPairedDevice,254    } = await import("./paired-devices");255256    const issuerId = "extension-issuer";257    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);258    await upsertPairedDevice({259      deviceId: issuerId,260      deviceName: "Firefox",261      deviceType: "extension",262      longTermPublicKeyHex: "c".repeat(64),263      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),264      pairedAt: 1_700_000_000_000,265      trustSource: "direct",266    });267    await upsertPairedDevice({268      deviceId: "android-existing",269      deviceName: "Existing Pixel",270      deviceType: "android",271      longTermPublicKeyHex: "d".repeat(64),272      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),273      pairedAt: 1_700_000_000_010,274      trustSource: "direct",275    });276277    const manifest = await buildSignedDeviceManifest(278      issuerId,279      [280        {281          deviceId: "android-new",282          deviceName: "New Pixel",283          deviceType: "android",284          longTermPublicKeyHex: "e".repeat(64),285          signingPublicKeyHex: await signingPubkeyHex("android-new"),286          wsPort: 51234,287          pairedAt: 1_700_000_000_020,288        },289        {290          deviceId: "android-existing",291          deviceName: "Existing Pixel renamed",292          deviceType: "android",293          longTermPublicKeyHex: "f".repeat(64),294          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),295          wsPort: 52345,296          pairedAt: 1_700_000_000_030,297        },298      ],299      issuerKp.privateKey,300    );301302    await adapter.applyIncomingDeviceManifest?.(manifest);303304    const added = await findPairedDeviceById("android-new");305    expect(added).toMatchObject({306      deviceName: "New Pixel",307      deviceType: "android",308      longTermPublicKeyHex: "e".repeat(64),309      trustSource: "vouched",310      wsPort: 51234,311    });312    const existing = await findPairedDeviceById("android-existing");313    expect(existing).toMatchObject({314      deviceName: "Existing Pixel renamed",315      longTermPublicKeyHex: "f".repeat(64),316      trustSource: "direct",317      wsPort: 52345,318    });319    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([320      "android-existing",321      "android-new",322      "extension-issuer",323    ]);324  });325326  it("applyIncomingDeviceManifest ignores non-extension issuers and bad signatures", async () => {327    const { adapter } = await setupVault();328    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");329    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");330    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");331332    const mobileIssuerId = "android-issuer";333    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);334    await upsertPairedDevice({335      deviceId: mobileIssuerId,336      deviceName: "Issuer Pixel",337      deviceType: "android",338      longTermPublicKeyHex: "1".repeat(64),339      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),340      pairedAt: 1_700_000_000_000,341      trustSource: "direct",342    });343    const mobileManifest = await buildSignedDeviceManifest(344      mobileIssuerId,345      [{346        deviceId: "should-not-add",347        deviceName: "Other Pixel",348        deviceType: "android",349        longTermPublicKeyHex: "2".repeat(64),350        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),351        pairedAt: 1_700_000_000_010,352      }],353      mobileIssuerKp.privateKey,354    );355    await adapter.applyIncomingDeviceManifest?.(mobileManifest);356    expect(await findPairedDeviceById("should-not-add")).toBeNull();357358    const extensionIssuerId = "extension-bad-sig";359    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);360    await upsertPairedDevice({361      deviceId: extensionIssuerId,362      deviceName: "Chrome",363      deviceType: "extension",364      longTermPublicKeyHex: "3".repeat(64),365      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),366      pairedAt: 1_700_000_000_020,367      trustSource: "direct",368    });369    const good = await buildSignedDeviceManifest(370      extensionIssuerId,371      [{372        deviceId: "forged-add",373        deviceName: "Forged Pixel",374        deviceType: "android",375        longTermPublicKeyHex: "4".repeat(64),376        signingPublicKeyHex: await signingPubkeyHex("forged-add"),377        pairedAt: 1_700_000_000_030,378      }],379      extensionKp.privateKey,380    );381    const forged = {382      ...good,383      signatureHex:384        good.signatureHex.slice(0, 2) +385        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +386        good.signatureHex.slice(4),387    };388    await adapter.applyIncomingDeviceManifest?.(forged);389    expect(await findPairedDeviceById("forged-add")).toBeNull();390  });391392  it("methods throw VaultLockedError when the vault is locked", async () => {393    const { vault, adapter } = await setupVault();394    vault.lock();395    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });396    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });397    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });398    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });399    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });400    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });401    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });402  });403});