sync-vault-adapter.test.ts

44.9 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("falls back to derived signing key when stored issuer key is stale", async () => {834    const { vault, adapter } = await setupVault();835    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");836    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");837    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");838    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");839840    const issuerId = "stale-key-android";841    const rootHex = vault.getRootKeyHex()!;842    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));843    const issuerKp = await deriveSyncSigningKeypair(root, issuerId);844845    await upsertPairedDevice({846      deviceId: issuerId,847      deviceName: "Pixel",848      deviceType: "android",849      longTermPublicKeyHex: "c".repeat(64),850      // Stale: an older build persisted this at first-pair under a now-bad851      // seed. The fallback must recover from this without trusting the row.852      signingPublicKeyHex: "ff".repeat(32),853      pairedAt: 1_700_000_000_000,854      trustSource: "direct",855    });856857    const manifest = await buildSignedDeviceManifest(858      issuerId,859      [{860        deviceId: "onward-peer",861        deviceName: "Forwarded iPad",862        deviceType: "extension",863        longTermPublicKeyHex: "e".repeat(64),864        signingPublicKeyHex: pubkeyToHex(865          (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,866        ),867        relayUrl: MESH_RELAY,868        pairedAt: 1_700_000_000_010,869      }],870      issuerKp.privateKey,871    );872873    await adapter.applyIncomingDeviceManifest?.(874      manifest,875      new Uint8Array(32).fill(0xcc),876    );877878    expect(await findPairedDeviceById("onward-peer")).toMatchObject({879      trustSource: "vouched",880      relayUrl: MESH_RELAY,881      rendezvousSecretHex: expect.stringMatching(/^[0-9a-f]{64}$/),882    });883    expect(await listPendingVouchedDevices()).toEqual([]);884  });885886  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {887    const { adapter } = await setupVault();888    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");889    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");890    const { upsertPairedDevice } = await import("./paired-devices");891    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");892893    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an894    // unknown-key lookup can be what rejects this — only the binding between895    // the Noise-authenticated static key and the manifest's claimed issuer.896    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");897    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");898    await upsertPairedDevice({899      deviceId: "peer-alpha",900      deviceName: "Alpha",901      deviceType: "android",902      longTermPublicKeyHex: "a".repeat(64),903      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),904      pairedAt: 1,905      trustSource: "direct",906    });907    await upsertPairedDevice({908      deviceId: "peer-beta",909      deviceName: "Beta",910      deviceType: "android",911      longTermPublicKeyHex: "b".repeat(64),912      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),913      pairedAt: 2,914      trustSource: "direct",915    });916917    const fromBeta = await buildSignedDeviceManifest(918      "peer-beta",919      [{920        deviceId: "smuggled",921        deviceName: "Smuggled",922        deviceType: "android",923        longTermPublicKeyHex: "5".repeat(64),924        signingPublicKeyHex: await signingPubkeyHex("smuggled"),925        relayUrl: MESH_RELAY,926        pairedAt: 3,927      }],928      betaKp.privateKey,929    );930931    // Delivered over a session Noise-authenticated as ALPHA.932    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));933    expect(await listPendingVouchedDevices()).toEqual([]);934935    // Same manifest, same devices, delivered over BETA's session: it must land.936    // Without this control the assertion above could pass for any reason.937    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));938    const { findPairedDeviceById } = await import("./paired-devices");939    expect(await findPairedDeviceById("smuggled")).toMatchObject({940      trustSource: "vouched",941      relayUrl: MESH_RELAY,942    });943    expect(await listPendingVouchedDevices()).toEqual([]);944  });945946  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {947    const { adapter } = await setupVault();948    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");949    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");950    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");951    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");952953    const issuerId = "phone-issuer";954    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);955    const stolenKey = "d".repeat(64);956    await upsertPairedDevice({957      deviceId: issuerId,958      deviceName: "Pixel",959      deviceType: "android",960      longTermPublicKeyHex: "c".repeat(64),961      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),962      pairedAt: 1,963      trustSource: "direct",964    });965    await upsertPairedDevice({966      deviceId: "stolen-laptop",967      deviceName: "Old Laptop",968      deviceType: "extension",969      longTermPublicKeyHex: stolenKey,970      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),971      pairedAt: 2,972      trustSource: "direct",973    });974975    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {976      revoked: [{977        deviceId: "stolen-laptop",978        longTermPublicKeyHex: stolenKey,979        revokedAt: Date.now(),980        revokedByDeviceId: issuerId,981      }],982    });983    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));984    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();985986    // deviceId is the issuer's to choose, so re-vouching the same key under a987    // new one is free. Terminality has to follow the key, not the label.988    const renamed = await buildSignedDeviceManifest(989      issuerId,990      [{991        deviceId: "stolen-laptop-mk2",992        deviceName: "New Laptop",993        deviceType: "extension",994        longTermPublicKeyHex: stolenKey,995        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),996        relayUrl: MESH_RELAY,997        pairedAt: Date.now(),998      }],999      issuerKp.privateKey,1000    );1001    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1002    expect(await listPendingVouchedDevices()).toEqual([]);1003    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10041005    // Control: the same fresh id carrying a key that was never revoked queues1006    // normally, so the rejection above is the key check and not the id.1007    const clean = await buildSignedDeviceManifest(1008      issuerId,1009      [{1010        deviceId: "stolen-laptop-mk2",1011        deviceName: "New Laptop",1012        deviceType: "extension",1013        longTermPublicKeyHex: "9".repeat(64),1014        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1015        relayUrl: MESH_RELAY,1016        pairedAt: Date.now(),1017      }],1018      issuerKp.privateKey,1019    );1020    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1021    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1022      trustSource: "vouched",1023      relayUrl: MESH_RELAY,1024    });1025    expect(await listPendingVouchedDevices()).toEqual([]);1026  });10271028  it("S1: a revoked key cannot be written onto a device we still trust", async () => {1029    const { adapter } = await setupVault();1030    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1031    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1032    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10331034    const issuerId = "phone-issuer";1035    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1036    const revokedKey = "d".repeat(64);1037    await upsertPairedDevice({1038      deviceId: issuerId,1039      deviceName: "Pixel",1040      deviceType: "android",1041      longTermPublicKeyHex: "c".repeat(64),1042      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1043      pairedAt: 1,1044      trustSource: "direct",1045    });1046    await upsertPairedDevice({1047      deviceId: "trusted-tablet",1048      deviceName: "Tablet",1049      deviceType: "android",1050      longTermPublicKeyHex: "7".repeat(64),1051      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1052      pairedAt: 2,1053      trustSource: "direct",1054    });10551056    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1057      revoked: [{1058        deviceId: "stolen-laptop",1059        longTermPublicKeyHex: revokedKey,1060        revokedAt: Date.now(),1061        revokedByDeviceId: issuerId,1062      }],1063    });1064    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));10651066    // The manifest rotates a device we already trust onto the revoked key —1067    // the same re-admission dressed as a key rotation.1068    const rotate = await buildSignedDeviceManifest(1069      issuerId,1070      [{1071        deviceId: "trusted-tablet",1072        deviceName: "Tablet",1073        deviceType: "android",1074        longTermPublicKeyHex: revokedKey,1075        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1076        pairedAt: 2,1077      }],1078      issuerKp.privateKey,1079    );1080    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1081    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1082      longTermPublicKeyHex: "7".repeat(64),1083    });1084  });10851086  it("methods throw VaultLockedError when the vault is locked", async () => {1087    const { vault, adapter } = await setupVault();1088    vault.lock();1089    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1090    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1091    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1092    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1093    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1094    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1095    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1096  });1097});