sync-vault-adapter.test.ts

34.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("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 — but S5: the409    // third-party issuer does NOT get to rename it. Keys and ports propagate;410    // the locally-known name is what the user recognises and it stands.411    const existing = await findPairedDeviceById("android-existing");412    expect(existing).toMatchObject({413      deviceName: "Existing Pixel",414      longTermPublicKeyHex: "f".repeat(64),415      trustSource: "direct",416      wsPort: 52345,417    });418    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([419      "android-existing",420      "extension-issuer",421    ]);422  });423424  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {425    const { adapter } = await setupVault();426    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");427    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");428    const { upsertPairedDevice } = await import("./paired-devices");429    const { getPendingSyncAlerts } = await import("./sync-alerts");430431    const issuerId = "extension-issuer";432    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);433    await upsertPairedDevice({434      deviceId: issuerId,435      deviceName: "Firefox",436      deviceType: "extension",437      longTermPublicKeyHex: "c".repeat(64),438      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),439      pairedAt: 1,440      trustSource: "direct",441    });442    await upsertPairedDevice({443      deviceId: "android-existing",444      deviceName: "Pixel",445      deviceType: "android",446      longTermPublicKeyHex: "d".repeat(64),447      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),448      pairedAt: 2,449      trustSource: "direct",450    });451452    const manifest = await buildSignedDeviceManifest(453      issuerId,454      [455        {456          deviceId: "android-existing",457          deviceName: "Pixel",458          deviceType: "android",459          longTermPublicKeyHex: "f".repeat(64),460          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),461          wsPort: 52345,462          pairedAt: 3,463        },464      ],465      issuerKp.privateKey,466    );467468    await adapter.applyIncomingDeviceManifest?.(469      manifest,470      new Uint8Array(32).fill(0xcc),471    );472    // recordSyncAlerts is fire-and-forget — flush microtasks.473    for (let i = 0; i < 25; i++) await Promise.resolve();474475    const alerts = await getPendingSyncAlerts();476    expect(477      alerts.some(478        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",479      ),480    ).toBe(true);481  });482483  it("S5: a rotation alert names the device from local state, not from the manifest", async () => {484    // A paired-but-hostile issuer relabels a device it is vouching for, hoping485    // the rotation alert reads as coming from a device the user trusts more.486    // The alert must show the name the user already knows, and the relabel487    // must not stick in the registry either.488    const { adapter } = await setupVault();489    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");490    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");491    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");492    const { getPendingSyncAlerts } = await import("./sync-alerts");493494    const issuerId = "hostile-issuer";495    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);496    await upsertPairedDevice({497      deviceId: issuerId,498      deviceName: "Old Laptop",499      deviceType: "extension",500      longTermPublicKeyHex: "c".repeat(64),501      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),502      pairedAt: 1,503      trustSource: "direct",504    });505    await upsertPairedDevice({506      deviceId: "android-existing",507      deviceName: "Spare Tablet",508      deviceType: "android",509      longTermPublicKeyHex: "d".repeat(64),510      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),511      pairedAt: 2,512      trustSource: "direct",513    });514515    const manifest = await buildSignedDeviceManifest(516      issuerId,517      [518        {519          deviceId: "android-existing",520          deviceName: "June's Primary Phone",521          deviceType: "android",522          longTermPublicKeyHex: "f".repeat(64),523          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),524          wsPort: 52345,525          pairedAt: 3,526        },527      ],528      issuerKp.privateKey,529    );530531    await adapter.applyIncomingDeviceManifest?.(532      manifest,533      new Uint8Array(32).fill(0xcc),534    );535    for (let i = 0; i < 25; i++) await Promise.resolve();536537    const alerts = await getPendingSyncAlerts();538    const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");539    expect(rotation?.deviceName).toBe("Spare Tablet");540    expect(rotation?.entryTitle).toBe("Spare Tablet");541    expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);542543    // The key rotation itself still lands; only the name is refused.544    const stored = await findPairedDeviceById("android-existing");545    expect(stored).toMatchObject({546      deviceName: "Spare Tablet",547      longTermPublicKeyHex: "f".repeat(64),548    });549  });550551  it("§C: a peer's revocation evicts the device and permanently blocks re-vouching", async () => {552    const { adapter } = await setupVault();553    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");554    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");555    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =556      await import("./paired-devices");557558    const issuerId = "phone-issuer";559    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);560    await upsertPairedDevice({561      deviceId: issuerId,562      deviceName: "Pixel",563      deviceType: "android",564      longTermPublicKeyHex: "c".repeat(64),565      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),566      pairedAt: 1,567      trustSource: "direct",568    });569    await upsertPairedDevice({570      deviceId: "stolen-laptop",571      deviceName: "Old Laptop",572      deviceType: "extension",573      longTermPublicKeyHex: "d".repeat(64),574      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),575      pairedAt: 2,576      trustSource: "direct",577    });578579    const revocation = await buildSignedDeviceManifest(580      issuerId,581      [],582      issuerKp.privateKey,583      {584        revoked: [585          {586            deviceId: "stolen-laptop",587            longTermPublicKeyHex: "d".repeat(64),588            revokedAt: Date.now(),589            revokedByDeviceId: issuerId,590          },591        ],592      },593    );594    await adapter.applyIncomingDeviceManifest?.(595      revocation,596      new Uint8Array(32).fill(0xcc),597    );598599    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();600    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);601602    // The stolen device is now vouched back in by a later manifest carrying a603    // fresh pairedAt — exactly the resurrection a timestamp-ordered design604    // would allow. It must not come back.605    const revouch = await buildSignedDeviceManifest(606      issuerId,607      [608        {609          deviceId: "stolen-laptop",610          deviceName: "Old Laptop",611          deviceType: "extension",612          longTermPublicKeyHex: "d".repeat(64),613          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),614          pairedAt: Date.now() + 60_000,615        },616      ],617      issuerKp.privateKey,618    );619    await adapter.applyIncomingDeviceManifest?.(620      revouch,621      new Uint8Array(32).fill(0xcc),622    );623624    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();625    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");626    expect(await listPendingVouchedDevices()).toHaveLength(0);627  });628629  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {630    const { adapter } = await setupVault();631    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");632    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");633    const {634      isDeviceRevoked,635      revokeDeviceById,636      upsertPairedDevice,637    } = await import("./paired-devices");638    const { getOrCreateDeviceId } = await import("./device-identity");639640    const ourDeviceId = await getOrCreateDeviceId();641    const issuerId = "phone-issuer";642    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);643    await upsertPairedDevice({644      deviceId: issuerId,645      deviceName: "Pixel",646      deviceType: "android",647      longTermPublicKeyHex: "c".repeat(64),648      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),649      pairedAt: 1,650      trustSource: "direct",651    });652653    // A compromised peer tries to sever the mesh by revoking this device.654    const selfRevoke = await buildSignedDeviceManifest(655      issuerId,656      [],657      issuerKp.privateKey,658      {659        revoked: [660          {661            deviceId: ourDeviceId,662            longTermPublicKeyHex: "e".repeat(64),663            revokedAt: Date.now(),664            revokedByDeviceId: issuerId,665          },666        ],667      },668    );669    await adapter.applyIncomingDeviceManifest?.(670      selfRevoke,671      new Uint8Array(32).fill(0xcc),672    );673    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);674675    // Our own revocations ride out in the manifest we build, and the revoked676    // device is absent from the vouched list.677    await upsertPairedDevice({678      deviceId: "retired-tablet",679      deviceName: "Retired Tablet",680      deviceType: "android",681      longTermPublicKeyHex: "f".repeat(64),682      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),683      pairedAt: 3,684      trustSource: "direct",685    });686    await revokeDeviceById("retired-tablet", ourDeviceId);687688    const outgoing = await adapter.buildOutgoingDeviceManifest?.();689    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);690    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");691  });692693  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {694    // The browser can never listen, so it joins the mesh THROUGH a native peer;695    // that peer is the one holding the peer list. A phone-issued manifest must696    // therefore be honoured. MV-7 still applies: the introduced device lands in697    // the pending queue, not the registry.698    const { adapter } = await setupVault();699    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");700    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");701    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");702    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");703704    const mobileIssuerId = "android-issuer";705    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);706    await upsertPairedDevice({707      deviceId: mobileIssuerId,708      deviceName: "Issuer Pixel",709      deviceType: "android",710      longTermPublicKeyHex: "1".repeat(64),711      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),712      pairedAt: 1_700_000_000_000,713      trustSource: "direct",714    });715    const mobileManifest = await buildSignedDeviceManifest(716      mobileIssuerId,717      [{718        deviceId: "mesh-peer",719        deviceName: "Other Pixel",720        deviceType: "android",721        longTermPublicKeyHex: "2".repeat(64),722        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),723        pairedAt: 1_700_000_000_010,724      }],725      mobileIssuerKp.privateKey,726    );727    await adapter.applyIncomingDeviceManifest?.(728      mobileManifest,729      new Uint8Array(32).fill(0x11),730    );731732    expect(await findPairedDeviceById("mesh-peer")).toBeNull();733    expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);734  });735736  it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {737    const { adapter } = await setupVault();738    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");739    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");740    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");741    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");742743    // Trust does not travel two hops: a device we only know because someone744    // else vouched for it cannot go on to vouch for further devices.745    const vouchedIssuerId = "android-vouched-issuer";746    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);747    await upsertPairedDevice({748      deviceId: vouchedIssuerId,749      deviceName: "Second-hand Pixel",750      deviceType: "android",751      longTermPublicKeyHex: "1".repeat(64),752      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),753      pairedAt: 1_700_000_000_000,754      trustSource: "vouched",755    });756    const onward = await buildSignedDeviceManifest(757      vouchedIssuerId,758      [{759        deviceId: "should-not-add",760        deviceName: "Other Pixel",761        deviceType: "android",762        longTermPublicKeyHex: "2".repeat(64),763        signingPublicKeyHex: await signingPubkeyHex("should-not-add"),764        pairedAt: 1_700_000_000_010,765      }],766      vouchedKp.privateKey,767    );768    await adapter.applyIncomingDeviceManifest?.(769      onward,770      new Uint8Array(32).fill(0x11),771    );772    expect(await findPairedDeviceById("should-not-add")).toBeNull();773    expect(await listPendingVouchedDevices()).toEqual([]);774775    const extensionIssuerId = "extension-bad-sig";776    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);777    await upsertPairedDevice({778      deviceId: extensionIssuerId,779      deviceName: "Chrome",780      deviceType: "extension",781      longTermPublicKeyHex: "3".repeat(64),782      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),783      pairedAt: 1_700_000_000_020,784      trustSource: "direct",785    });786    const good = await buildSignedDeviceManifest(787      extensionIssuerId,788      [{789        deviceId: "forged-add",790        deviceName: "Forged Pixel",791        deviceType: "android",792        longTermPublicKeyHex: "4".repeat(64),793        signingPublicKeyHex: await signingPubkeyHex("forged-add"),794        pairedAt: 1_700_000_000_030,795      }],796      extensionKp.privateKey,797    );798    await adapter.applyIncomingDeviceManifest?.(799      good,800      new Uint8Array(32).fill(0x44),801    );802    expect(await findPairedDeviceById("forged-add")).toBeNull();803804    const forged = {805      ...good,806      signatureHex:807        good.signatureHex.slice(0, 2) +808        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +809        good.signatureHex.slice(4),810    };811    await adapter.applyIncomingDeviceManifest?.(812      forged,813      new Uint8Array(32).fill(0x33),814    );815    expect(await findPairedDeviceById("forged-add")).toBeNull();816  });817818  it("methods throw VaultLockedError when the vault is locked", async () => {819    const { vault, adapter } = await setupVault();820    vault.lock();821    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });822    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });823    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });824    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });825    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });826    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });827    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });828  });829});