sync-vault-adapter.test.ts

27.0 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("SyncVaultAdapter", () => {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("./sync-coordinator").SyncVaultAdapter }> {43    const vault = await import("./vault");44    await vault.createVault("correct horse battery staple");45    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");46    const adapter = createSyncVaultAdapter();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 { createSyncVaultAdapter } = await import("./sync-vault-adapter");156    const adapter2 = createSyncVaultAdapter();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?.(393      manifest,394      new Uint8Array(32).fill(0xcc),395    );396397    // MV-7: the never-seen device is NOT auto-paired; it waits in the pending398    // queue for explicit user confirmation.399    expect(await findPairedDeviceById("android-new")).toBeNull();400    const pending = await listPendingVouchedDevices();401    expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);402    expect(pending[0]).toMatchObject({403      deviceName: "New Pixel",404      longTermPublicKeyHex: "e".repeat(64),405      vouchedByDeviceId: "extension-issuer",406    });407408    // The already-paired device is still updated in place.409    const existing = await findPairedDeviceById("android-existing");410    expect(existing).toMatchObject({411      deviceName: "Existing Pixel renamed",412      longTermPublicKeyHex: "f".repeat(64),413      trustSource: "direct",414      wsPort: 52345,415    });416    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([417      "android-existing",418      "extension-issuer",419    ]);420  });421422  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {423    const { adapter } = await setupVault();424    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");425    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");426    const { upsertPairedDevice } = await import("./paired-devices");427    const { getPendingSyncAlerts } = await import("./sync-alerts");428429    const issuerId = "extension-issuer";430    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);431    await upsertPairedDevice({432      deviceId: issuerId,433      deviceName: "Firefox",434      deviceType: "extension",435      longTermPublicKeyHex: "c".repeat(64),436      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),437      pairedAt: 1,438      trustSource: "direct",439    });440    await upsertPairedDevice({441      deviceId: "android-existing",442      deviceName: "Pixel",443      deviceType: "android",444      longTermPublicKeyHex: "d".repeat(64),445      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),446      pairedAt: 2,447      trustSource: "direct",448    });449450    const manifest = await buildSignedDeviceManifest(451      issuerId,452      [453        {454          deviceId: "android-existing",455          deviceName: "Pixel",456          deviceType: "android",457          longTermPublicKeyHex: "f".repeat(64),458          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),459          wsPort: 52345,460          pairedAt: 3,461        },462      ],463      issuerKp.privateKey,464    );465466    await adapter.applyIncomingDeviceManifest?.(467      manifest,468      new Uint8Array(32).fill(0xcc),469    );470    // recordSyncAlerts is fire-and-forget — flush microtasks.471    for (let i = 0; i < 25; i++) await Promise.resolve();472473    const alerts = await getPendingSyncAlerts();474    expect(475      alerts.some(476        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",477      ),478    ).toBe(true);479  });480481  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {482    // The browser can never listen, so it joins the mesh THROUGH a native peer;483    // that peer is the one holding the peer list. A phone-issued manifest must484    // therefore be honoured. MV-7 still applies: the introduced device lands in485    // the pending queue, not the registry.486    const { adapter } = await setupVault();487    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");488    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");489    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");490    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");491492    const mobileIssuerId = "android-issuer";493    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);494    await upsertPairedDevice({495      deviceId: mobileIssuerId,496      deviceName: "Issuer Pixel",497      deviceType: "android",498      longTermPublicKeyHex: "1".repeat(64),499      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),500      pairedAt: 1_700_000_000_000,501      trustSource: "direct",502    });503    const mobileManifest = await buildSignedDeviceManifest(504      mobileIssuerId,505      [{506        deviceId: "mesh-peer",507        deviceName: "Other Pixel",508        deviceType: "android",509        longTermPublicKeyHex: "2".repeat(64),510        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),511        pairedAt: 1_700_000_000_010,512      }],513      mobileIssuerKp.privateKey,514    );515    await adapter.applyIncomingDeviceManifest?.(516      mobileManifest,517      new Uint8Array(32).fill(0x11),518    );519520    expect(await findPairedDeviceById("mesh-peer")).toBeNull();521    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);522  });523524  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {525    const { adapter } = await setupVault();526    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");527    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");528    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");529    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");530531    // Trust does not travel two hops: a device we only know because someone532    // else vouched for it cannot go on to vouch for further devices.533    const vouchedIssuerId = "android-vouched-issuer";534    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);535    await upsertPairedDevice({536      deviceId: vouchedIssuerId,537      deviceName: "Second-hand Pixel",538      deviceType: "android",539      longTermPublicKeyHex: "1".repeat(64),540      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),541      pairedAt: 1_700_000_000_000,542      trustSource: "vouched",543    });544    const onward = await buildSignedDeviceManifest(545      vouchedIssuerId,546      [{547        deviceId: "should-not-add",548        deviceName: "Other Pixel",549        deviceType: "android",550        longTermPublicKeyHex: "2".repeat(64),551        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),552        pairedAt: 1_700_000_000_010,553      }],554      vouchedKp.privateKey,555    );556    await adapter.applyIncomingDeviceManifest?.(557      onward,558      new Uint8Array(32).fill(0x11),559    );560    expect(await findPairedDeviceById("should-not-add")).toBeNull();561    expect(await listPendingVouchedDevices()).toEqual([]);562563    const extensionIssuerId = "extension-bad-sig";564    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);565    await upsertPairedDevice({566      deviceId: extensionIssuerId,567      deviceName: "Chrome",568      deviceType: "extension",569      longTermPublicKeyHex: "3".repeat(64),570      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),571      pairedAt: 1_700_000_000_020,572      trustSource: "direct",573    });574    const good = await buildSignedDeviceManifest(575      extensionIssuerId,576      [{577        deviceId: "forged-add",578        deviceName: "Forged Pixel",579        deviceType: "android",580        longTermPublicKeyHex: "4".repeat(64),581        signingPublicKeyHex: await signingPubkeyHex("forged-add"),582        pairedAt: 1_700_000_000_030,583      }],584      extensionKp.privateKey,585    );586    await adapter.applyIncomingDeviceManifest?.(587      good,588      new Uint8Array(32).fill(0x44),589    );590    expect(await findPairedDeviceById("forged-add")).toBeNull();591592    const forged = {593      ...good,594      signatureHex:595        good.signatureHex.slice(0, 2) +596        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +597        good.signatureHex.slice(4),598    };599    await adapter.applyIncomingDeviceManifest?.(600      forged,601      new Uint8Array(32).fill(0x33),602    );603    expect(await findPairedDeviceById("forged-add")).toBeNull();604  });605606  it("methods throw VaultLockedError when the vault is locked", async () => {607    const { vault, adapter } = await setupVault();608    vault.lock();609    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });610    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });611    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });612    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });613    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });614    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });615    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });616  });617});