sync-vault-adapter.test.ts

43.0 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;4const MESH_RELAY = "wss://relay.peachpasswords.com";56function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {7  const area = (store: StorageShape) => ({8    get: vi.fn(async (key?: string | string[]) => {9      if (!key) return store;10      if (typeof key === "string") return { [key]: store[key] };11      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});12    }),13    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),14    remove: vi.fn(async (keys: string | string[]) => {15      const list = Array.isArray(keys) ? keys : [keys];16      for (const k of list) delete store[k];17    }),18  });19  return {20    storage: { local: area(local), sync: area(sync) },21    alarms: {22      clear: vi.fn(),23      create: vi.fn(),24      onAlarm: { addListener: vi.fn() },25    },26    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },27    runtime: { sendMessage: vi.fn(async () => undefined) },28  } as unknown as typeof chrome;29}3031describe("SyncVaultAdapter", () => {32  let local: StorageShape;33  let sync: StorageShape;3435  beforeEach(() => {36    vi.resetModules();37    local = {};38    sync = {};39    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);40    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));41  });4243  async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./sync-coordinator").SyncVaultAdapter }> {44    const vault = await import("./vault");45    await vault.createVault("correct horse battery staple");46    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");47    const adapter = createSyncVaultAdapter();48    return { vault, adapter };49  }5051  async function signingPubkeyHex(deviceId: string): Promise<string> {52    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");53    const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);54    return pubkeyToHex(kp.publicKeyRaw);55  }5657  it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {58    const { adapter } = await setupVault();59    const id1 = await adapter.computeVaultId();60    const id2 = await adapter.computeVaultId();61    expect(id1).toBe(id2);62    expect(id1).toMatch(/^[0-9a-f]{64}$/);63  });6465  it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {66    const { vault, adapter } = await setupVault();67    const deviceId = await adapter.getOurDeviceId();68    const vaultIdBefore = await adapter.computeVaultId();6970    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");71    const rootBefore = vault.getRootKeyHex()!;72    const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));73    const signBefore = pubkeyToHex(74      (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,75    );7677    expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);78    vault.lock();79    expect(await vault.unlock("a different passphrase")).toBe(true);8081    // vault_id derives from the stable root key, so it must not move.82    expect(await adapter.computeVaultId()).toBe(vaultIdBefore);83    const rootAfter = vault.getRootKeyHex()!;84    expect(rootAfter).toBe(rootBefore);85    const signAfter = pubkeyToHex(86      (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,87    );88    expect(signAfter).toBe(signBefore);89  });9091  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {92    const { adapter } = await setupVault();93    const id1 = await adapter.getOurDeviceId();94    const id2 = await adapter.getOurDeviceId();95    expect(id1).toBe(id2);96    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}$/);97  });9899  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {100    const { vault, adapter } = await setupVault();101    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });102    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });103    await vault.deleteEntry(b.id);104    const m = await adapter.buildEntryManifest();105    expect(Object.keys(m).sort()).toEqual([a.id]);106    expect(m[a.id]).toBe(a.modifiedAt);107  });108109  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {110    const { vault, adapter } = await setupVault();111    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });112    await vault.deleteEntry(a.id);113    const t = await adapter.buildTombstoneManifest();114    expect(Object.keys(t)).toEqual([a.id]);115    expect(typeof t[a.id]).toBe("number");116    expect(t[a.id]).toBeGreaterThan(0);117  });118119  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {120    const { vault, adapter } = await setupVault();121    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });122    const env = await adapter.getEncryptedEntries([a.id]);123    expect(Object.keys(env)).toEqual([a.id]);124    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);125    // Plaintext title must not be visible in the envelope.126    expect(env[a.id]).not.toContain("Alpha");127  });128129  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {130    const { adapter } = await setupVault();131    const env = await adapter.getEncryptedEntries(["does-not-exist"]);132    expect(env).toEqual({});133  });134135  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {136    const { vault, adapter } = await setupVault();137    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });138    const e1 = await adapter.getEncryptedEntries([a.id]);139    const e2 = await adapter.getEncryptedEntries([a.id]);140    expect(e1[a.id]).not.toBe(e2[a.id]);141  });142143  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {144    const { vault, adapter } = await setupVault();145    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });146    const envelopes = await adapter.getEncryptedEntries([a.id]);147    // Simulate a remote vault by bumping modifiedAt on the envelope's source148    // and then applying it back — we can't easily fork the module's storage,149    // so we just verify decrypt path by overwriting modifiedAt locally.150    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries151    // will re-add it because the local copy is tombstoned with an OLDER ts (since152    // we set modifiedAt forward by 1s in the new envelope payload).153    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.154    vault.lock();155    await vault.unlock("correct horse battery staple");156    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");157    const adapter2 = createSyncVaultAdapter();158    // applyEntries should be a no-op when local mtime >= envelope mtime159    await adapter2.applyEntries(envelopes);160    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");161  });162163  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {164    const { vault, adapter } = await setupVault();165    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });166    // Manually produce a fresher remote envelope by encrypting an updated entry shape.167    // We can't easily synthesise this without the entry key — so we use a second168    // adapter instance backed by the same vault, after mutating the underlying entry.169    await vault.updateEntry(local.id, { title: "Updated" });170    const newer = vault.getEntries().find((e) => e.id === local.id);171    expect(newer?.title).toBe("Updated");172    const envelopes = await adapter.getEncryptedEntries([local.id]);173    // Now revert the local entry to an older mtime by re-creating from scratch via174    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the175    // decrypt+merge path is that applyEntries on a fresher envelope keeps the176    // newer state, and on an older envelope is a no-op. We've covered the no-op177    // path above; for the apply path, simulate by replacing the in-memory entry178    // with an older copy and re-running applyEntries.179    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };180    await vault.restoreEntries([older], "replace");181    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");182    await adapter.applyEntries(envelopes);183    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");184  });185186  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {187    const { vault, adapter } = await setupVault();188    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });189    const env = await adapter.getEncryptedEntries([a.id]);190    // Re-key the envelope under a different id — adapter must refuse to merge it.191    const swapped: Record<string, string> = { "wrong-id": env[a.id] };192    await adapter.applyEntries(swapped);193    // Local entry remains unchanged; no new entry under "wrong-id"194    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);195  });196197  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {198    const { vault, adapter } = await setupVault();199    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });200    await vault.updateEntry(a.id, { title: "Fresh" });201    const envelopes = await adapter.getEncryptedEntries([a.id]);202    // Roll local back to an older copy, then apply: encrypt+decrypt both use the203    // null-byte-separated info, so the round-trip must succeed.204    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };205    await vault.restoreEntries([stale], "replace");206    await adapter.applyEntries(envelopes);207    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");208  });209210  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {211    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and212    // assert the patched adapter cannot decrypt it — it drops silently. Locks in213    // the conscious wire break and serves as the fixture for any future migration.214    const { vault, adapter } = await setupVault();215    const { HKDF } = await import("@stablelib/hkdf");216    const { SHA256 } = await import("@stablelib/sha256");217218    const masterHex = vault.getRootKeyHex()!;219    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));220    const entryId = crypto.randomUUID();221    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00222    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);223    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);224225    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };226    const nonce = crypto.getRandomValues(new Uint8Array(12));227    const aad = new TextEncoder().encode("peach-sync-entry-v1");228    const ct = new Uint8Array(229      await crypto.subtle.encrypt(230        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },231        key,232        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,233      ),234    );235    const envelope = new Uint8Array(nonce.length + ct.length);236    envelope.set(nonce, 0);237    envelope.set(ct, nonce.length);238    const legacyB64 = Buffer.from(envelope).toString("base64");239240    await adapter.applyEntries({ [entryId]: legacyB64 });241    // Decrypt fails under the new separated info → envelope dropped, entry absent.242    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);243  });244245  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {246    const { vault, adapter } = await setupVault();247    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });248    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });249    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();250    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();251  });252253  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {254    const { vault, adapter } = await setupVault();255    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });256    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });257    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();258  });259260  // --- MV-3: sync receive bounds ---261262  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {263    const { adapter } = await setupVault();264    const { SYNC_LIMITS } = await import("../shared/sync-limits");265    const tooMany: Record<string, string> = {};266    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";267    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);268  });269270  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {271    const { vault, adapter } = await setupVault();272    const { SYNC_LIMITS } = await import("../shared/sync-limits");273    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });274    await vault.updateEntry(local.id, { title: "Updated" });275    const envelopes = await adapter.getEncryptedEntries([local.id]);276    // Make the local copy stale so the valid envelope would win on merge.277    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };278    await vault.restoreEntries([older], "replace");279    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");280281    const oversized = "A".repeat(282      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,283    );284    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });285286    // The valid envelope applied; the oversized one was skipped (never decrypted).287    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");288    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);289  });290291  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {292    const { adapter } = await setupVault();293    const { SYNC_LIMITS } = await import("../shared/sync-limits");294    const tooMany: Record<string, number> = {};295    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;296    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);297  });298299  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {300    const { adapter } = await setupVault();301    const { upsertPairedDevice } = await import("./paired-devices");302    await upsertPairedDevice({303      deviceId: "android-1",304      deviceName: "Pixel",305      deviceType: "android",306      longTermPublicKeyHex: "a".repeat(64),307      signingPublicKeyHex: await signingPubkeyHex("android-1"),308      wsPort: 49152,309      relayUrl: MESH_RELAY,310      pairedAt: 1_700_000_000_000,311      trustSource: "direct",312    });313    await upsertPairedDevice({314      deviceId: "legacy-android",315      deviceName: "Legacy Pixel",316      deviceType: "android",317      longTermPublicKeyHex: "b".repeat(64),318      pairedAt: 1_700_000_000_100,319      trustSource: "direct",320    });321322    const manifest = await adapter.buildOutgoingDeviceManifest?.();323    expect(manifest).toBeDefined();324    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);325    expect(manifest?.body.devices).toHaveLength(1);326    expect(manifest?.body.devices[0]).toMatchObject({327      deviceId: "android-1",328      deviceName: "Pixel",329      deviceType: "android",330      longTermPublicKeyHex: "a".repeat(64),331      wsPort: 49152,332      relayUrl: MESH_RELAY,333    });334  });335336  it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {337    const { adapter } = await setupVault();338    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");339    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");340    const {341      findPairedDeviceById,342      listPairedDevices,343      upsertPairedDevice,344    } = await import("./paired-devices");345    const { listPendingVouchedDevices } = await import(346      "./pending-vouched-devices"347    );348349    const issuerId = "extension-issuer";350    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);351    await upsertPairedDevice({352      deviceId: issuerId,353      deviceName: "Firefox",354      deviceType: "extension",355      longTermPublicKeyHex: "c".repeat(64),356      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),357      pairedAt: 1_700_000_000_000,358      trustSource: "direct",359    });360    await upsertPairedDevice({361      deviceId: "android-existing",362      deviceName: "Existing Pixel",363      deviceType: "android",364      longTermPublicKeyHex: "d".repeat(64),365      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),366      pairedAt: 1_700_000_000_010,367      trustSource: "direct",368    });369370    const manifest = await buildSignedDeviceManifest(371      issuerId,372      [373        {374          deviceId: "android-new",375          deviceName: "New Pixel",376          deviceType: "android",377          longTermPublicKeyHex: "e".repeat(64),378          signingPublicKeyHex: await signingPubkeyHex("android-new"),379          wsPort: 51234,380          relayUrl: MESH_RELAY,381          pairedAt: 1_700_000_000_020,382        },383        {384          deviceId: "android-existing",385          deviceName: "Existing Pixel renamed",386          deviceType: "android",387          longTermPublicKeyHex: "f".repeat(64),388          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),389          wsPort: 52345,390          relayUrl: MESH_RELAY,391          pairedAt: 1_700_000_000_030,392        },393      ],394      issuerKp.privateKey,395    );396397    await adapter.applyIncomingDeviceManifest?.(398      manifest,399      new Uint8Array(32).fill(0xcc),400    );401402    expect(await findPairedDeviceById("android-new")).toMatchObject({403      deviceName: "New Pixel",404      trustSource: "vouched",405      relayUrl: MESH_RELAY,406      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),407    });408    const pending = await listPendingVouchedDevices();409    expect(pending).toEqual([]);410411    // The already-paired device is still updated in place — but S5: the412    // third-party issuer does NOT get to rename it. Keys and ports propagate;413    // the locally-known name is what the user recognises and it stands.414    const existing = await findPairedDeviceById("android-existing");415    expect(existing).toMatchObject({416      deviceName: "Existing Pixel",417      longTermPublicKeyHex: "f".repeat(64),418      trustSource: "direct",419      wsPort: 52345,420    });421    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([422      "android-existing",423      "android-new",424      "extension-issuer",425    ]);426  });427428  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {429    const { adapter } = await setupVault();430    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");431    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");432    const { upsertPairedDevice } = await import("./paired-devices");433    const { getPendingSyncAlerts } = await import("./sync-alerts");434435    const issuerId = "extension-issuer";436    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);437    await upsertPairedDevice({438      deviceId: issuerId,439      deviceName: "Firefox",440      deviceType: "extension",441      longTermPublicKeyHex: "c".repeat(64),442      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),443      pairedAt: 1,444      trustSource: "direct",445    });446    await upsertPairedDevice({447      deviceId: "android-existing",448      deviceName: "Pixel",449      deviceType: "android",450      longTermPublicKeyHex: "d".repeat(64),451      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),452      pairedAt: 2,453      trustSource: "direct",454    });455456    const manifest = await buildSignedDeviceManifest(457      issuerId,458      [459        {460          deviceId: "android-existing",461          deviceName: "Pixel",462          deviceType: "android",463          longTermPublicKeyHex: "f".repeat(64),464          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),465          wsPort: 52345,466          pairedAt: 3,467        },468      ],469      issuerKp.privateKey,470    );471472    await adapter.applyIncomingDeviceManifest?.(473      manifest,474      new Uint8Array(32).fill(0xcc),475    );476    // recordSyncAlerts is fire-and-forget — flush microtasks.477    for (let i = 0; i < 25; i++) await Promise.resolve();478479    const alerts = await getPendingSyncAlerts();480    expect(481      alerts.some(482        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",483      ),484    ).toBe(true);485  });486487  it("S5: a rotation alert names the device from local state, not from the manifest", async () => {488    // A paired-but-hostile issuer relabels a device it is vouching for, hoping489    // the rotation alert reads as coming from a device the user trusts more.490    // The alert must show the name the user already knows, and the relabel491    // must not stick in the registry either.492    const { adapter } = await setupVault();493    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");494    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");495    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");496    const { getPendingSyncAlerts } = await import("./sync-alerts");497498    const issuerId = "hostile-issuer";499    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);500    await upsertPairedDevice({501      deviceId: issuerId,502      deviceName: "Old Laptop",503      deviceType: "extension",504      longTermPublicKeyHex: "c".repeat(64),505      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),506      pairedAt: 1,507      trustSource: "direct",508    });509    await upsertPairedDevice({510      deviceId: "android-existing",511      deviceName: "Spare Tablet",512      deviceType: "android",513      longTermPublicKeyHex: "d".repeat(64),514      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),515      pairedAt: 2,516      trustSource: "direct",517    });518519    const manifest = await buildSignedDeviceManifest(520      issuerId,521      [522        {523          deviceId: "android-existing",524          deviceName: "June's Primary Phone",525          deviceType: "android",526          longTermPublicKeyHex: "f".repeat(64),527          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),528          wsPort: 52345,529          pairedAt: 3,530        },531      ],532      issuerKp.privateKey,533    );534535    await adapter.applyIncomingDeviceManifest?.(536      manifest,537      new Uint8Array(32).fill(0xcc),538    );539    for (let i = 0; i < 25; i++) await Promise.resolve();540541    const alerts = await getPendingSyncAlerts();542    const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");543    expect(rotation?.deviceName).toBe("Spare Tablet");544    expect(rotation?.entryTitle).toBe("Spare Tablet");545    expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);546547    // The key rotation itself still lands; only the name is refused.548    const stored = await findPairedDeviceById("android-existing");549    expect(stored).toMatchObject({550      deviceName: "Spare Tablet",551      longTermPublicKeyHex: "f".repeat(64),552    });553  });554555  it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {556    const { adapter } = await setupVault();557    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");558    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");559    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =560      await import("./paired-devices");561562    const issuerId = "phone-issuer";563    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);564    await upsertPairedDevice({565      deviceId: issuerId,566      deviceName: "Pixel",567      deviceType: "android",568      longTermPublicKeyHex: "c".repeat(64),569      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),570      pairedAt: 1,571      trustSource: "vouched",572    });573    await upsertPairedDevice({574      deviceId: "stolen-laptop",575      deviceName: "Old Laptop",576      deviceType: "extension",577      longTermPublicKeyHex: "d".repeat(64),578      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),579      pairedAt: 2,580      trustSource: "direct",581    });582583    const revocation = await buildSignedDeviceManifest(584      issuerId,585      [],586      issuerKp.privateKey,587      {588        revoked: [589          {590            deviceId: "stolen-laptop",591            longTermPublicKeyHex: "d".repeat(64),592            revokedAt: Date.now(),593            revokedByDeviceId: issuerId,594          },595        ],596      },597    );598    await adapter.applyIncomingDeviceManifest?.(599      revocation,600      new Uint8Array(32).fill(0xcc),601    );602603    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();604    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);605606    // The stolen device is now vouched back in by a later manifest carrying a607    // fresh pairedAt — exactly the resurrection a timestamp-ordered design608    // would allow. It must not come back.609    const revouch = await buildSignedDeviceManifest(610      issuerId,611      [612        {613          deviceId: "stolen-laptop",614          deviceName: "Old Laptop",615          deviceType: "extension",616          longTermPublicKeyHex: "d".repeat(64),617          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),618          pairedAt: Date.now() + 60_000,619        },620      ],621      issuerKp.privateKey,622    );623    await adapter.applyIncomingDeviceManifest?.(624      revouch,625      new Uint8Array(32).fill(0xcc),626    );627628    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();629    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");630    expect(await listPendingVouchedDevices()).toHaveLength(0);631  });632633  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {634    const { adapter } = await setupVault();635    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");636    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");637    const {638      isDeviceRevoked,639      revokeDeviceById,640      upsertPairedDevice,641    } = await import("./paired-devices");642    const { getOrCreateDeviceId } = await import("./device-identity");643644    const ourDeviceId = await getOrCreateDeviceId();645    const issuerId = "phone-issuer";646    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);647    await upsertPairedDevice({648      deviceId: issuerId,649      deviceName: "Pixel",650      deviceType: "android",651      longTermPublicKeyHex: "c".repeat(64),652      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),653      pairedAt: 1,654      trustSource: "direct",655    });656657    // A compromised peer tries to sever the mesh by revoking this device.658    const selfRevoke = await buildSignedDeviceManifest(659      issuerId,660      [],661      issuerKp.privateKey,662      {663        revoked: [664          {665            deviceId: ourDeviceId,666            longTermPublicKeyHex: "e".repeat(64),667            revokedAt: Date.now(),668            revokedByDeviceId: issuerId,669          },670        ],671      },672    );673    await adapter.applyIncomingDeviceManifest?.(674      selfRevoke,675      new Uint8Array(32).fill(0xcc),676    );677    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);678679    // Our own revocations ride out in the manifest we build, and the revoked680    // device is absent from the vouched list.681    await upsertPairedDevice({682      deviceId: "retired-tablet",683      deviceName: "Retired Tablet",684      deviceType: "android",685      longTermPublicKeyHex: "f".repeat(64),686      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),687      pairedAt: 3,688      trustSource: "direct",689    });690    await revokeDeviceById("retired-tablet", ourDeviceId);691692    const outgoing = await adapter.buildOutgoingDeviceManifest?.();693    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);694    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");695  });696697  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {698    // The browser can never listen, so it joins the mesh THROUGH a native peer;699    // that peer is the one holding the peer list. A phone-issued manifest must700    // therefore be honoured. MV-7 still applies: the introduced device lands in701    // the pending queue, not the registry.702    const { adapter } = await setupVault();703    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");704    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");705    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");706    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");707708    const mobileIssuerId = "android-issuer";709    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);710    await upsertPairedDevice({711      deviceId: mobileIssuerId,712      deviceName: "Issuer Pixel",713      deviceType: "android",714      longTermPublicKeyHex: "1".repeat(64),715      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),716      pairedAt: 1_700_000_000_000,717      trustSource: "direct",718    });719    const mobileManifest = await buildSignedDeviceManifest(720      mobileIssuerId,721      [{722        deviceId: "mesh-peer",723        deviceName: "Other Pixel",724        deviceType: "android",725        longTermPublicKeyHex: "2".repeat(64),726        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),727        relayUrl: MESH_RELAY,728        pairedAt: 1_700_000_000_010,729      }],730      mobileIssuerKp.privateKey,731    );732    await adapter.applyIncomingDeviceManifest?.(733      mobileManifest,734      new Uint8Array(32).fill(0x11),735    );736737    expect(await findPairedDeviceById("mesh-peer")).toMatchObject({738      trustSource: "vouched",739      relayUrl: MESH_RELAY,740      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),741    });742    expect(await listPendingVouchedDevices()).toEqual([]);743  });744745  it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {746    const { adapter } = await setupVault();747    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");748    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");749    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");750    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");751752    // Once admitted, a peer propagates the pool without relying on the device753    // that introduced it. The Noise-authenticated key and stored signing key754    // still bind the manifest to this known mesh member.755    const vouchedIssuerId = "android-vouched-issuer";756    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);757    await upsertPairedDevice({758      deviceId: vouchedIssuerId,759      deviceName: "Second-hand Pixel",760      deviceType: "android",761      longTermPublicKeyHex: "1".repeat(64),762      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),763      pairedAt: 1_700_000_000_000,764      trustSource: "vouched",765    });766    const onward = await buildSignedDeviceManifest(767      vouchedIssuerId,768      [{769        deviceId: "onward-mesh-peer",770        deviceName: "Other Pixel",771        deviceType: "android",772        longTermPublicKeyHex: "2".repeat(64),773        signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),774        relayUrl: MESH_RELAY,775        pairedAt: 1_700_000_000_010,776      }],777      vouchedKp.privateKey,778    );779    await adapter.applyIncomingDeviceManifest?.(780      onward,781      new Uint8Array(32).fill(0x11),782    );783    expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({784      trustSource: "vouched",785      relayUrl: MESH_RELAY,786      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),787    });788    expect(await listPendingVouchedDevices()).toEqual([]);789790    const extensionIssuerId = "extension-bad-sig";791    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);792    await upsertPairedDevice({793      deviceId: extensionIssuerId,794      deviceName: "Chrome",795      deviceType: "extension",796      longTermPublicKeyHex: "3".repeat(64),797      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),798      pairedAt: 1_700_000_000_020,799      trustSource: "direct",800    });801    const good = await buildSignedDeviceManifest(802      extensionIssuerId,803      [{804        deviceId: "forged-add",805        deviceName: "Forged Pixel",806        deviceType: "android",807        longTermPublicKeyHex: "4".repeat(64),808        signingPublicKeyHex: await signingPubkeyHex("forged-add"),809        pairedAt: 1_700_000_000_030,810      }],811      extensionKp.privateKey,812    );813    await adapter.applyIncomingDeviceManifest?.(814      good,815      new Uint8Array(32).fill(0x44),816    );817    expect(await findPairedDeviceById("forged-add")).toBeNull();818819    const forged = {820      ...good,821      signatureHex:822        good.signatureHex.slice(0, 2) +823        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +824        good.signatureHex.slice(4),825    };826    await adapter.applyIncomingDeviceManifest?.(827      forged,828      new Uint8Array(32).fill(0x33),829    );830    expect(await findPairedDeviceById("forged-add")).toBeNull();831  });832833  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {834    const { adapter } = await setupVault();835    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");836    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");837    const { upsertPairedDevice } = await import("./paired-devices");838    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");839840    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an841    // unknown-key lookup can be what rejects this — only the binding between842    // the Noise-authenticated static key and the manifest's claimed issuer.843    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");844    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");845    await upsertPairedDevice({846      deviceId: "peer-alpha",847      deviceName: "Alpha",848      deviceType: "android",849      longTermPublicKeyHex: "a".repeat(64),850      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),851      pairedAt: 1,852      trustSource: "direct",853    });854    await upsertPairedDevice({855      deviceId: "peer-beta",856      deviceName: "Beta",857      deviceType: "android",858      longTermPublicKeyHex: "b".repeat(64),859      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),860      pairedAt: 2,861      trustSource: "direct",862    });863864    const fromBeta = await buildSignedDeviceManifest(865      "peer-beta",866      [{867        deviceId: "smuggled",868        deviceName: "Smuggled",869        deviceType: "android",870        longTermPublicKeyHex: "5".repeat(64),871        signingPublicKeyHex: await signingPubkeyHex("smuggled"),872        relayUrl: MESH_RELAY,873        pairedAt: 3,874      }],875      betaKp.privateKey,876    );877878    // Delivered over a session Noise-authenticated as ALPHA.879    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));880    expect(await listPendingVouchedDevices()).toEqual([]);881882    // Same manifest, same devices, delivered over BETA's session: it must land.883    // Without this control the assertion above could pass for any reason.884    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));885    const { findPairedDeviceById } = await import("./paired-devices");886    expect(await findPairedDeviceById("smuggled")).toMatchObject({887      trustSource: "vouched",888      relayUrl: MESH_RELAY,889    });890    expect(await listPendingVouchedDevices()).toEqual([]);891  });892893  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {894    const { adapter } = await setupVault();895    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");896    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");897    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");898    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");899900    const issuerId = "phone-issuer";901    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);902    const stolenKey = "d".repeat(64);903    await upsertPairedDevice({904      deviceId: issuerId,905      deviceName: "Pixel",906      deviceType: "android",907      longTermPublicKeyHex: "c".repeat(64),908      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),909      pairedAt: 1,910      trustSource: "direct",911    });912    await upsertPairedDevice({913      deviceId: "stolen-laptop",914      deviceName: "Old Laptop",915      deviceType: "extension",916      longTermPublicKeyHex: stolenKey,917      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),918      pairedAt: 2,919      trustSource: "direct",920    });921922    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {923      revoked: [{924        deviceId: "stolen-laptop",925        longTermPublicKeyHex: stolenKey,926        revokedAt: Date.now(),927        revokedByDeviceId: issuerId,928      }],929    });930    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));931    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();932933    // deviceId is the issuer's to choose, so re-vouching the same key under a934    // new one is free. Terminality has to follow the key, not the label.935    const renamed = await buildSignedDeviceManifest(936      issuerId,937      [{938        deviceId: "stolen-laptop-mk2",939        deviceName: "New Laptop",940        deviceType: "extension",941        longTermPublicKeyHex: stolenKey,942        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),943        relayUrl: MESH_RELAY,944        pairedAt: Date.now(),945      }],946      issuerKp.privateKey,947    );948    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));949    expect(await listPendingVouchedDevices()).toEqual([]);950    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();951952    // Control: the same fresh id carrying a key that was never revoked queues953    // normally, so the rejection above is the key check and not the id.954    const clean = await buildSignedDeviceManifest(955      issuerId,956      [{957        deviceId: "stolen-laptop-mk2",958        deviceName: "New Laptop",959        deviceType: "extension",960        longTermPublicKeyHex: "9".repeat(64),961        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),962        relayUrl: MESH_RELAY,963        pairedAt: Date.now(),964      }],965      issuerKp.privateKey,966    );967    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));968    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({969      trustSource: "vouched",970      relayUrl: MESH_RELAY,971    });972    expect(await listPendingVouchedDevices()).toEqual([]);973  });974975  it("S1: a revoked key cannot be written onto a device we still trust", async () => {976    const { adapter } = await setupVault();977    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");978    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");979    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");980981    const issuerId = "phone-issuer";982    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);983    const revokedKey = "d".repeat(64);984    await upsertPairedDevice({985      deviceId: issuerId,986      deviceName: "Pixel",987      deviceType: "android",988      longTermPublicKeyHex: "c".repeat(64),989      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),990      pairedAt: 1,991      trustSource: "direct",992    });993    await upsertPairedDevice({994      deviceId: "trusted-tablet",995      deviceName: "Tablet",996      deviceType: "android",997      longTermPublicKeyHex: "7".repeat(64),998      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),999      pairedAt: 2,1000      trustSource: "direct",1001    });10021003    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1004      revoked: [{1005        deviceId: "stolen-laptop",1006        longTermPublicKeyHex: revokedKey,1007        revokedAt: Date.now(),1008        revokedByDeviceId: issuerId,1009      }],1010    });1011    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));10121013    // The manifest rotates a device we already trust onto the revoked key —1014    // the same re-admission dressed as a key rotation.1015    const rotate = await buildSignedDeviceManifest(1016      issuerId,1017      [{1018        deviceId: "trusted-tablet",1019        deviceName: "Tablet",1020        deviceType: "android",1021        longTermPublicKeyHex: revokedKey,1022        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1023        pairedAt: 2,1024      }],1025      issuerKp.privateKey,1026    );1027    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1028    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1029      longTermPublicKeyHex: "7".repeat(64),1030    });1031  });10321033  it("methods throw VaultLockedError when the vault is locked", async () => {1034    const { vault, adapter } = await setupVault();1035    vault.lock();1036    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1037    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1038    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1039    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1040    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1041    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1042    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1043  });1044});