chirp-sync-vault-adapter.test.ts

26.7 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("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {197    const { vault, adapter } = await setupVault();198    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });199    await vault.updateEntry(a.id, { title: "Fresh" });200    const envelopes = await adapter.getEncryptedEntries([a.id]);201    // Roll local back to an older copy, then apply: encrypt+decrypt both use the202    // null-byte-separated info, so the round-trip must succeed.203    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };204    await vault.restoreEntries([stale], "replace");205    await adapter.applyEntries(envelopes);206    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");207  });208209  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {210    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and211    // assert the patched adapter cannot decrypt it — it drops silently. Locks in212    // the conscious wire break and serves as the fixture for any future migration.213    const { vault, adapter } = await setupVault();214    const { HKDF } = await import("@stablelib/hkdf");215    const { SHA256 } = await import("@stablelib/sha256");216217    const masterHex = vault.getRootKeyHex()!;218    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));219    const entryId = crypto.randomUUID();220    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00221    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);222    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);223224    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };225    const nonce = crypto.getRandomValues(new Uint8Array(12));226    const aad = new TextEncoder().encode("peach-sync-entry-v1");227    const ct = new Uint8Array(228      await crypto.subtle.encrypt(229        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },230        key,231        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,232      ),233    );234    const envelope = new Uint8Array(nonce.length + ct.length);235    envelope.set(nonce, 0);236    envelope.set(ct, nonce.length);237    const legacyB64 = Buffer.from(envelope).toString("base64");238239    await adapter.applyEntries({ [entryId]: legacyB64 });240    // Decrypt fails under the new separated info → envelope dropped, entry absent.241    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);242  });243244  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {245    const { vault, adapter } = await setupVault();246    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });247    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });248    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();249    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();250  });251252  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {253    const { vault, adapter } = await setupVault();254    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });255    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });256    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();257  });258259  // --- MV-3: sync receive bounds ---260261  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {262    const { adapter } = await setupVault();263    const { SYNC_LIMITS } = await import("../shared/sync-limits");264    const tooMany: Record<string, string> = {};265    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";266    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);267  });268269  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {270    const { vault, adapter } = await setupVault();271    const { SYNC_LIMITS } = await import("../shared/sync-limits");272    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });273    await vault.updateEntry(local.id, { title: "Updated" });274    const envelopes = await adapter.getEncryptedEntries([local.id]);275    // Make the local copy stale so the valid envelope would win on merge.276    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };277    await vault.restoreEntries([older], "replace");278    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");279280    const oversized = "A".repeat(281      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,282    );283    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });284285    // The valid envelope applied; the oversized one was skipped (never decrypted).286    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");287    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);288  });289290  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {291    const { adapter } = await setupVault();292    const { SYNC_LIMITS } = await import("../shared/sync-limits");293    const tooMany: Record<string, number> = {};294    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;295    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);296  });297298  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {299    const { adapter } = await setupVault();300    const { upsertPairedDevice } = await import("./paired-devices");301    await upsertPairedDevice({302      deviceId: "android-1",303      deviceName: "Pixel",304      deviceType: "android",305      longTermPublicKeyHex: "a".repeat(64),306      signingPublicKeyHex: await signingPubkeyHex("android-1"),307      wsPort: 49152,308      pairedAt: 1_700_000_000_000,309      trustSource: "direct",310    });311    await upsertPairedDevice({312      deviceId: "legacy-android",313      deviceName: "Legacy Pixel",314      deviceType: "android",315      longTermPublicKeyHex: "b".repeat(64),316      pairedAt: 1_700_000_000_100,317      trustSource: "direct",318    });319320    const manifest = await adapter.buildOutgoingDeviceManifest?.();321    expect(manifest).toBeDefined();322    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);323    expect(manifest?.body.devices).toHaveLength(1);324    expect(manifest?.body.devices[0]).toMatchObject({325      deviceId: "android-1",326      deviceName: "Pixel",327      deviceType: "android",328      longTermPublicKeyHex: "a".repeat(64),329      wsPort: 49152,330    });331  });332333  it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {334    const { adapter } = await setupVault();335    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");336    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");337    const {338      findPairedDeviceById,339      listPairedDevices,340      upsertPairedDevice,341    } = await import("./paired-devices");342    const { listPendingVouchedDevices } = await import(343      "./pending-vouched-devices"344    );345346    const issuerId = "extension-issuer";347    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);348    await upsertPairedDevice({349      deviceId: issuerId,350      deviceName: "Firefox",351      deviceType: "extension",352      longTermPublicKeyHex: "c".repeat(64),353      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),354      pairedAt: 1_700_000_000_000,355      trustSource: "direct",356    });357    await upsertPairedDevice({358      deviceId: "android-existing",359      deviceName: "Existing Pixel",360      deviceType: "android",361      longTermPublicKeyHex: "d".repeat(64),362      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),363      pairedAt: 1_700_000_000_010,364      trustSource: "direct",365    });366367    const manifest = await buildSignedDeviceManifest(368      issuerId,369      [370        {371          deviceId: "android-new",372          deviceName: "New Pixel",373          deviceType: "android",374          longTermPublicKeyHex: "e".repeat(64),375          signingPublicKeyHex: await signingPubkeyHex("android-new"),376          wsPort: 51234,377          pairedAt: 1_700_000_000_020,378        },379        {380          deviceId: "android-existing",381          deviceName: "Existing Pixel renamed",382          deviceType: "android",383          longTermPublicKeyHex: "f".repeat(64),384          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),385          wsPort: 52345,386          pairedAt: 1_700_000_000_030,387        },388      ],389      issuerKp.privateKey,390    );391392    await adapter.applyIncomingDeviceManifest?.(manifest);393394    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending395    // queue for explicit user confirmation.396    expect(await findPairedDeviceById("android-new")).toBeNull();397    const pending = await listPendingVouchedDevices();398    expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);399    expect(pending[0]).toMatchObject({400      deviceName: "New Pixel",401      longTermPublicKeyHex: "e".repeat(64),402      vouchedByDeviceId: "extension-issuer",403    });404405    // The already-paired device is still updated in place.406    const existing = await findPairedDeviceById("android-existing");407    expect(existing).toMatchObject({408      deviceName: "Existing Pixel renamed",409      longTermPublicKeyHex: "f".repeat(64),410      trustSource: "direct",411      wsPort: 52345,412    });413    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([414      "android-existing",415      "extension-issuer",416    ]);417  });418419  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {420    const { adapter } = await setupVault();421    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");422    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");423    const { upsertPairedDevice } = await import("./paired-devices");424    const { getPendingSyncAlerts } = await import("./sync-alerts");425426    const issuerId = "extension-issuer";427    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);428    await upsertPairedDevice({429      deviceId: issuerId,430      deviceName: "Firefox",431      deviceType: "extension",432      longTermPublicKeyHex: "c".repeat(64),433      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),434      pairedAt: 1,435      trustSource: "direct",436    });437    await upsertPairedDevice({438      deviceId: "android-existing",439      deviceName: "Pixel",440      deviceType: "android",441      longTermPublicKeyHex: "d".repeat(64),442      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),443      pairedAt: 2,444      trustSource: "direct",445    });446447    const manifest = await buildSignedDeviceManifest(448      issuerId,449      [450        {451          deviceId: "android-existing",452          deviceName: "Pixel",453          deviceType: "android",454          longTermPublicKeyHex: "f".repeat(64),455          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),456          wsPort: 52345,457          pairedAt: 3,458        },459      ],460      issuerKp.privateKey,461    );462463    await adapter.applyIncomingDeviceManifest?.(manifest);464    // recordSyncAlerts is fire-and-forget — flush microtasks.465    for (let i = 0; i < 25; i++) await Promise.resolve();466467    const alerts = await getPendingSyncAlerts();468    expect(469      alerts.some(470        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",471      ),472    ).toBe(true);473  });474475  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {476    // The browser can never listen, so it joins the mesh THROUGH a native peer;477    // that peer is the one holding the peer list. A phone-issued manifest must478    // therefore be honoured. MV-7 still applies: the introduced device lands in479    // the pending queue, not the registry.480    const { adapter } = await setupVault();481    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");482    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");483    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");484    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");485486    const mobileIssuerId = "android-issuer";487    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);488    await upsertPairedDevice({489      deviceId: mobileIssuerId,490      deviceName: "Issuer Pixel",491      deviceType: "android",492      longTermPublicKeyHex: "1".repeat(64),493      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),494      pairedAt: 1_700_000_000_000,495      trustSource: "direct",496    });497    const mobileManifest = await buildSignedDeviceManifest(498      mobileIssuerId,499      [{500        deviceId: "mesh-peer",501        deviceName: "Other Pixel",502        deviceType: "android",503        longTermPublicKeyHex: "2".repeat(64),504        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),505        pairedAt: 1_700_000_000_010,506      }],507      mobileIssuerKp.privateKey,508    );509    await adapter.applyIncomingDeviceManifest?.(mobileManifest);510511    expect(await findPairedDeviceById("mesh-peer")).toBeNull();512    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);513  });514515  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {516    const { adapter } = await setupVault();517    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");518    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");519    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");520    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");521522    // Trust does not travel two hops: a device we only know because someone523    // else vouched for it cannot go on to vouch for further devices.524    const vouchedIssuerId = "android-vouched-issuer";525    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);526    await upsertPairedDevice({527      deviceId: vouchedIssuerId,528      deviceName: "Second-hand Pixel",529      deviceType: "android",530      longTermPublicKeyHex: "1".repeat(64),531      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),532      pairedAt: 1_700_000_000_000,533      trustSource: "vouched",534    });535    const onward = await buildSignedDeviceManifest(536      vouchedIssuerId,537      [{538        deviceId: "should-not-add",539        deviceName: "Other Pixel",540        deviceType: "android",541        longTermPublicKeyHex: "2".repeat(64),542        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),543        pairedAt: 1_700_000_000_010,544      }],545      vouchedKp.privateKey,546    );547    await adapter.applyIncomingDeviceManifest?.(onward);548    expect(await findPairedDeviceById("should-not-add")).toBeNull();549    expect(await listPendingVouchedDevices()).toEqual([]);550551    const extensionIssuerId = "extension-bad-sig";552    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);553    await upsertPairedDevice({554      deviceId: extensionIssuerId,555      deviceName: "Chrome",556      deviceType: "extension",557      longTermPublicKeyHex: "3".repeat(64),558      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),559      pairedAt: 1_700_000_000_020,560      trustSource: "direct",561    });562    const good = await buildSignedDeviceManifest(563      extensionIssuerId,564      [{565        deviceId: "forged-add",566        deviceName: "Forged Pixel",567        deviceType: "android",568        longTermPublicKeyHex: "4".repeat(64),569        signingPublicKeyHex: await signingPubkeyHex("forged-add"),570        pairedAt: 1_700_000_000_030,571      }],572      extensionKp.privateKey,573    );574    const forged = {575      ...good,576      signatureHex:577        good.signatureHex.slice(0, 2) +578        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +579        good.signatureHex.slice(4),580    };581    await adapter.applyIncomingDeviceManifest?.(forged);582    expect(await findPairedDeviceById("forged-add")).toBeNull();583  });584585  it("methods throw VaultLockedError when the vault is locked", async () => {586    const { vault, adapter } = await setupVault();587    vault.lock();588    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });589    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });590    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });591    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });592    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });593    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });594    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });595  });596});