sync-vault-adapter.test.ts

49.2 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("buildTombstoneManifest retains ids after a full-entry purge", async () => {120    const { vault, adapter } = await setupVault();121    const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" });122    await vault.deleteEntry(entry.id);123    const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;124125    await vault.purgeEntry(entry.id);126127    expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt });128  });129130  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {131    const { vault, adapter } = await setupVault();132    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });133    const env = await adapter.getEncryptedEntries([a.id]);134    expect(Object.keys(env)).toEqual([a.id]);135    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);136    // Plaintext title must not be visible in the envelope.137    expect(env[a.id]).not.toContain("Alpha");138  });139140  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {141    const { adapter } = await setupVault();142    const env = await adapter.getEncryptedEntries(["does-not-exist"]);143    expect(env).toEqual({});144  });145146  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {147    const { vault, adapter } = await setupVault();148    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });149    const e1 = await adapter.getEncryptedEntries([a.id]);150    const e2 = await adapter.getEncryptedEntries([a.id]);151    expect(e1[a.id]).not.toBe(e2[a.id]);152  });153154  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {155    const { vault, adapter } = await setupVault();156    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });157    const envelopes = await adapter.getEncryptedEntries([a.id]);158    // Simulate a remote vault by bumping modifiedAt on the envelope's source159    // and then applying it back — we can't easily fork the module's storage,160    // so we just verify decrypt path by overwriting modifiedAt locally.161    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries162    // will re-add it because the local copy is tombstoned with an OLDER ts (since163    // we set modifiedAt forward by 1s in the new envelope payload).164    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.165    vault.lock();166    await vault.unlock("correct horse battery staple");167    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");168    const adapter2 = createSyncVaultAdapter();169    // applyEntries should be a no-op when local mtime >= envelope mtime170    await adapter2.applyEntries(envelopes);171    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");172  });173174  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {175    const { vault, adapter } = await setupVault();176    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });177    // Manually produce a fresher remote envelope by encrypting an updated entry shape.178    // We can't easily synthesise this without the entry key — so we use a second179    // adapter instance backed by the same vault, after mutating the underlying entry.180    await vault.updateEntry(local.id, { title: "Updated" });181    const newer = vault.getEntries().find((e) => e.id === local.id);182    expect(newer?.title).toBe("Updated");183    const envelopes = await adapter.getEncryptedEntries([local.id]);184    // Now revert the local entry to an older mtime by re-creating from scratch via185    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the186    // decrypt+merge path is that applyEntries on a fresher envelope keeps the187    // newer state, and on an older envelope is a no-op. We've covered the no-op188    // path above; for the apply path, simulate by replacing the in-memory entry189    // with an older copy and re-running applyEntries.190    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };191    await vault.restoreEntries([older], "replace");192    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");193    await adapter.applyEntries(envelopes);194    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");195  });196197  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {198    const { vault, adapter } = await setupVault();199    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });200    const env = await adapter.getEncryptedEntries([a.id]);201    // Re-key the envelope under a different id — adapter must refuse to merge it.202    const swapped: Record<string, string> = { "wrong-id": env[a.id] };203    await adapter.applyEntries(swapped);204    // Local entry remains unchanged; no new entry under "wrong-id"205    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);206  });207208  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {209    const { vault, adapter } = await setupVault();210    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });211    await vault.updateEntry(a.id, { title: "Fresh" });212    const envelopes = await adapter.getEncryptedEntries([a.id]);213    // Roll local back to an older copy, then apply: encrypt+decrypt both use the214    // null-byte-separated info, so the round-trip must succeed.215    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };216    await vault.restoreEntries([stale], "replace");217    await adapter.applyEntries(envelopes);218    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");219  });220221  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {222    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and223    // assert the patched adapter cannot decrypt it — it drops silently. Locks in224    // the conscious wire break and serves as the fixture for any future migration.225    const { vault, adapter } = await setupVault();226    const { HKDF } = await import("@stablelib/hkdf");227    const { SHA256 } = await import("@stablelib/sha256");228229    const masterHex = vault.getRootKeyHex()!;230    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));231    const entryId = crypto.randomUUID();232    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00233    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);234    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);235236    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };237    const nonce = crypto.getRandomValues(new Uint8Array(12));238    const aad = new TextEncoder().encode("peach-sync-entry-v1");239    const ct = new Uint8Array(240      await crypto.subtle.encrypt(241        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },242        key,243        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,244      ),245    );246    const envelope = new Uint8Array(nonce.length + ct.length);247    envelope.set(nonce, 0);248    envelope.set(ct, nonce.length);249    const legacyB64 = Buffer.from(envelope).toString("base64");250251    await adapter.applyEntries({ [entryId]: legacyB64 });252    // Decrypt fails under the new separated info → envelope dropped, entry absent.253    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);254  });255256  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {257    const { vault, adapter } = await setupVault();258    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });259    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });260    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();261    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();262  });263264  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {265    const { vault, adapter } = await setupVault();266    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });267    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });268    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();269  });270271  // --- MV-3: sync receive bounds ---272273  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {274    const { adapter } = await setupVault();275    const { SYNC_LIMITS } = await import("../shared/sync-limits");276    const tooMany: Record<string, string> = {};277    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";278    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);279  });280281  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {282    const { vault, adapter } = await setupVault();283    const { SYNC_LIMITS } = await import("../shared/sync-limits");284    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });285    await vault.updateEntry(local.id, { title: "Updated" });286    const envelopes = await adapter.getEncryptedEntries([local.id]);287    // Make the local copy stale so the valid envelope would win on merge.288    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };289    await vault.restoreEntries([older], "replace");290    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");291292    const oversized = "A".repeat(293      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,294    );295    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });296297    // The valid envelope applied; the oversized one was skipped (never decrypted).298    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");299    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);300  });301302  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {303    const { adapter } = await setupVault();304    const { SYNC_LIMITS } = await import("../shared/sync-limits");305    const tooMany: Record<string, number> = {};306    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;307    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);308  });309310  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {311    const { adapter } = await setupVault();312    const { upsertPairedDevice } = await import("./paired-devices");313    await upsertPairedDevice({314      deviceId: "android-1",315      deviceName: "Pixel",316      deviceType: "android",317      longTermPublicKeyHex: "a".repeat(64),318      signingPublicKeyHex: await signingPubkeyHex("android-1"),319      wsPort: 49152,320      relayUrl: MESH_RELAY,321      pairedAt: 1_700_000_000_000,322      trustSource: "direct",323    });324    await upsertPairedDevice({325      deviceId: "legacy-android",326      deviceName: "Legacy Pixel",327      deviceType: "android",328      longTermPublicKeyHex: "b".repeat(64),329      pairedAt: 1_700_000_000_100,330      trustSource: "direct",331    });332333    const manifest = await adapter.buildOutgoingDeviceManifest?.();334    expect(manifest).toBeDefined();335    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);336    expect(manifest?.body.devices).toHaveLength(1);337    expect(manifest?.body.devices[0]).toMatchObject({338      deviceId: "android-1",339      deviceName: "Pixel",340      deviceType: "android",341      longTermPublicKeyHex: "a".repeat(64),342      wsPort: 49152,343      relayUrl: MESH_RELAY,344    });345  });346347  it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {348    const { vault, adapter } = await setupVault();349    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");350    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");351    const {352      findPairedDeviceById,353      listPairedDevices,354      upsertPairedDevice,355    } = await import("./paired-devices");356    const { listPendingVouchedDevices } = await import(357      "./pending-vouched-devices"358    );359360    const issuerId = "extension-issuer";361    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);362    await upsertPairedDevice({363      deviceId: issuerId,364      deviceName: "Firefox",365      deviceType: "extension",366      longTermPublicKeyHex: "c".repeat(64),367      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),368      pairedAt: 1_700_000_000_000,369      trustSource: "direct",370    });371    await upsertPairedDevice({372      deviceId: "android-existing",373      deviceName: "Existing Pixel",374      deviceType: "android",375      longTermPublicKeyHex: "d".repeat(64),376      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),377      pairedAt: 1_700_000_000_010,378      trustSource: "direct",379    });380381    const manifest = await buildSignedDeviceManifest(382      issuerId,383      [384        {385          deviceId: "android-new",386          deviceName: "New Pixel",387          deviceType: "android",388          longTermPublicKeyHex: "e".repeat(64),389          signingPublicKeyHex: await signingPubkeyHex("android-new"),390          wsPort: 51234,391          relayUrl: MESH_RELAY,392          pairedAt: 1_700_000_000_020,393        },394        {395          deviceId: "android-existing",396          deviceName: "Existing Pixel renamed",397          deviceType: "android",398          longTermPublicKeyHex: "f".repeat(64),399          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),400          wsPort: 52345,401          relayUrl: MESH_RELAY,402          pairedAt: 1_700_000_000_030,403        },404      ],405      issuerKp.privateKey,406    );407408    await adapter.applyIncomingDeviceManifest?.(409      manifest,410      new Uint8Array(32).fill(0xcc),411    );412413    expect(await findPairedDeviceById("android-new")).toMatchObject({414      deviceName: "New Pixel",415      trustSource: "vouched",416      relayUrl: MESH_RELAY,417    });418    // PB-26-213: the row carries no reusable secret; it resolves from the419    // encrypted pair-secret record instead.420    expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();421    const { getPairSecret } = await import("./pair-secrets");422    await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(423      /^[0-9a-f]{64}$/,424    );425    const pending = await listPendingVouchedDevices();426    expect(pending).toEqual([]);427428    // The already-paired device is still updated in place — but S5: the429    // third-party issuer does NOT get to rename it. Keys and ports propagate;430    // the locally-known name is what the user recognises and it stands.431    const existing = await findPairedDeviceById("android-existing");432    expect(existing).toMatchObject({433      deviceName: "Existing Pixel",434      longTermPublicKeyHex: "f".repeat(64),435      trustSource: "direct",436      wsPort: 52345,437    });438    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([439      "android-existing",440      "android-new",441      "extension-issuer",442    ]);443  });444445  it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {446    const { adapter } = await setupVault();447    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");448    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");449    const { upsertPairedDevice } = await import("./paired-devices");450    const { getPendingSyncAlerts } = await import("./sync-alerts");451452    const issuerId = "extension-issuer";453    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);454    await upsertPairedDevice({455      deviceId: issuerId,456      deviceName: "Firefox",457      deviceType: "extension",458      longTermPublicKeyHex: "c".repeat(64),459      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),460      pairedAt: 1,461      trustSource: "direct",462    });463    await upsertPairedDevice({464      deviceId: "android-existing",465      deviceName: "Pixel",466      deviceType: "android",467      longTermPublicKeyHex: "d".repeat(64),468      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),469      pairedAt: 2,470      trustSource: "direct",471    });472473    const manifest = await buildSignedDeviceManifest(474      issuerId,475      [476        {477          deviceId: "android-existing",478          deviceName: "Pixel",479          deviceType: "android",480          longTermPublicKeyHex: "f".repeat(64),481          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),482          wsPort: 52345,483          pairedAt: 3,484        },485      ],486      issuerKp.privateKey,487    );488489    await adapter.applyIncomingDeviceManifest?.(490      manifest,491      new Uint8Array(32).fill(0xcc),492    );493    // recordSyncAlerts is fire-and-forget — flush microtasks.494    for (let i = 0; i < 25; i++) await Promise.resolve();495496    const alerts = await getPendingSyncAlerts();497    expect(498      alerts.some(499        (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",500      ),501    ).toBe(true);502  });503504  it("S5: a rotation alert names the device from local state, not from the manifest", async () => {505    // A paired-but-hostile issuer relabels a device it is vouching for, hoping506    // the rotation alert reads as coming from a device the user trusts more.507    // The alert must show the name the user already knows, and the relabel508    // must not stick in the registry either.509    const { adapter } = await setupVault();510    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");511    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");512    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");513    const { getPendingSyncAlerts } = await import("./sync-alerts");514515    const issuerId = "hostile-issuer";516    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);517    await upsertPairedDevice({518      deviceId: issuerId,519      deviceName: "Old Laptop",520      deviceType: "extension",521      longTermPublicKeyHex: "c".repeat(64),522      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),523      pairedAt: 1,524      trustSource: "direct",525    });526    await upsertPairedDevice({527      deviceId: "android-existing",528      deviceName: "Spare Tablet",529      deviceType: "android",530      longTermPublicKeyHex: "d".repeat(64),531      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),532      pairedAt: 2,533      trustSource: "direct",534    });535536    const manifest = await buildSignedDeviceManifest(537      issuerId,538      [539        {540          deviceId: "android-existing",541          deviceName: "June's Primary Phone",542          deviceType: "android",543          longTermPublicKeyHex: "f".repeat(64),544          signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),545          wsPort: 52345,546          pairedAt: 3,547        },548      ],549      issuerKp.privateKey,550    );551552    await adapter.applyIncomingDeviceManifest?.(553      manifest,554      new Uint8Array(32).fill(0xcc),555    );556    for (let i = 0; i < 25; i++) await Promise.resolve();557558    const alerts = await getPendingSyncAlerts();559    const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");560    expect(rotation?.deviceName).toBe("Spare Tablet");561    expect(rotation?.entryTitle).toBe("Spare Tablet");562    expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);563564    // The key rotation itself still lands; only the name is refused.565    const stored = await findPairedDeviceById("android-existing");566    expect(stored).toMatchObject({567      deviceName: "Spare Tablet",568      longTermPublicKeyHex: "f".repeat(64),569    });570  });571572  it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {573    const { adapter } = await setupVault();574    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");575    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");576    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =577      await import("./paired-devices");578579    const issuerId = "phone-issuer";580    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);581    await upsertPairedDevice({582      deviceId: issuerId,583      deviceName: "Pixel",584      deviceType: "android",585      longTermPublicKeyHex: "c".repeat(64),586      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),587      pairedAt: 1,588      trustSource: "vouched",589    });590    await upsertPairedDevice({591      deviceId: "stolen-laptop",592      deviceName: "Old Laptop",593      deviceType: "extension",594      longTermPublicKeyHex: "d".repeat(64),595      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),596      pairedAt: 2,597      trustSource: "direct",598    });599600    const revocation = await buildSignedDeviceManifest(601      issuerId,602      [],603      issuerKp.privateKey,604      {605        revoked: [606          {607            deviceId: "stolen-laptop",608            longTermPublicKeyHex: "d".repeat(64),609            revokedAt: Date.now(),610            revokedByDeviceId: issuerId,611          },612        ],613      },614    );615    await adapter.applyIncomingDeviceManifest?.(616      revocation,617      new Uint8Array(32).fill(0xcc),618    );619620    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();621    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);622623    // The stolen device is now vouched back in by a later manifest carrying a624    // fresh pairedAt — exactly the resurrection a timestamp-ordered design625    // would allow. It must not come back.626    const revouch = await buildSignedDeviceManifest(627      issuerId,628      [629        {630          deviceId: "stolen-laptop",631          deviceName: "Old Laptop",632          deviceType: "extension",633          longTermPublicKeyHex: "d".repeat(64),634          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),635          pairedAt: Date.now() + 60_000,636        },637      ],638      issuerKp.privateKey,639    );640    await adapter.applyIncomingDeviceManifest?.(641      revouch,642      new Uint8Array(32).fill(0xcc),643    );644645    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();646    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");647    expect(await listPendingVouchedDevices()).toHaveLength(0);648  });649650  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {651    const { adapter } = await setupVault();652    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");653    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");654    const {655      isDeviceRevoked,656      revokeDeviceById,657      upsertPairedDevice,658    } = await import("./paired-devices");659    const { getOrCreateDeviceId } = await import("./device-identity");660661    const ourDeviceId = await getOrCreateDeviceId();662    const issuerId = "phone-issuer";663    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);664    await upsertPairedDevice({665      deviceId: issuerId,666      deviceName: "Pixel",667      deviceType: "android",668      longTermPublicKeyHex: "c".repeat(64),669      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),670      pairedAt: 1,671      trustSource: "direct",672    });673674    // A compromised peer tries to sever the mesh by revoking this device.675    const selfRevoke = await buildSignedDeviceManifest(676      issuerId,677      [],678      issuerKp.privateKey,679      {680        revoked: [681          {682            deviceId: ourDeviceId,683            longTermPublicKeyHex: "e".repeat(64),684            revokedAt: Date.now(),685            revokedByDeviceId: issuerId,686          },687        ],688      },689    );690    await adapter.applyIncomingDeviceManifest?.(691      selfRevoke,692      new Uint8Array(32).fill(0xcc),693    );694    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);695696    // Our own revocations ride out in the manifest we build, and the revoked697    // device is absent from the vouched list.698    await upsertPairedDevice({699      deviceId: "retired-tablet",700      deviceName: "Retired Tablet",701      deviceType: "android",702      longTermPublicKeyHex: "f".repeat(64),703      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),704      pairedAt: 3,705      trustSource: "direct",706    });707    await revokeDeviceById("retired-tablet", ourDeviceId);708709    const outgoing = await adapter.buildOutgoingDeviceManifest?.();710    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);711    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");712  });713714  // PB-26-116: rows written before `trustSource` existed carry no provenance.715  // The manifest-apply path used to default them to "direct", which is the716  // opposite of the fail-closed rule PB-26-006 established for the mesh routing717  // gate — a legacy row became a dial target, manifest issuer, and revocation-718  // repair participant on the strength of a signed manifest alone.719  it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {720    const { adapter } = await setupVault();721    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");722    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");723    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");724    const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");725726    const issuerId = "android-legacy-issuer";727    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);728    await upsertPairedDevice({729      deviceId: issuerId,730      deviceName: "Issuer Pixel",731      deviceType: "android",732      longTermPublicKeyHex: "1".repeat(64),733      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),734      pairedAt: 1_700_000_000_000,735      trustSource: "direct",736    });737738    // A row predating the field: present in the registry, no recorded739    // provenance. Seeded through storage so no writer stamps a default.740    const legacyId = "legacy-peer";741    const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;742    registry.push({743      deviceId: legacyId,744      deviceName: "Legacy Peer",745      deviceType: "android",746      longTermPublicKeyHex: "3".repeat(64),747      signingPublicKeyHex: await signingPubkeyHex(legacyId),748      rendezvousSecretHex: "9".repeat(64),749      relayUrl: MESH_RELAY,750      pairedAt: 1_700_000_000_000,751    });752753    const manifest = await buildSignedDeviceManifest(754      issuerId,755      [{756        deviceId: legacyId,757        deviceName: "Legacy Peer",758        deviceType: "android",759        longTermPublicKeyHex: "3".repeat(64),760        signingPublicKeyHex: await signingPubkeyHex(legacyId),761        relayUrl: MESH_RELAY,762        pairedAt: 1_700_000_000_000,763      }],764      issuerKp.privateKey,765    );766    await adapter.applyIncomingDeviceManifest?.(767      manifest,768      new Uint8Array(32).fill(0x11),769    );770771    const row = await findPairedDeviceById(legacyId);772    expect(row).not.toBeNull();773    // Not silently promoted: unknown provenance stays unknown.774    expect(row?.trustSource).toBeUndefined();775    expect(row?.trustSource).not.toBe("direct");776    // And therefore not a routing authority.777    expect(isRouteCapableMeshPeer(row!)).toBe(false);778  });779780  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {781    // The browser can never listen, so it joins the mesh THROUGH a native peer;782    // that peer is the one holding the peer list. A phone-issued manifest must783    // therefore be honoured. MV-7 still applies: the introduced device lands in784    // the pending queue, not the registry.785    const { adapter } = await setupVault();786    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");787    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");788    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");789    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");790    const vault = await import("./vault");791792    const mobileIssuerId = "android-issuer";793    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);794    await upsertPairedDevice({795      deviceId: mobileIssuerId,796      deviceName: "Issuer Pixel",797      deviceType: "android",798      longTermPublicKeyHex: "1".repeat(64),799      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),800      pairedAt: 1_700_000_000_000,801      trustSource: "direct",802    });803    const mobileManifest = await buildSignedDeviceManifest(804      mobileIssuerId,805      [{806        deviceId: "mesh-peer",807        deviceName: "Other Pixel",808        deviceType: "android",809        longTermPublicKeyHex: "2".repeat(64),810        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),811        relayUrl: MESH_RELAY,812        pairedAt: 1_700_000_000_010,813      }],814      mobileIssuerKp.privateKey,815    );816    await adapter.applyIncomingDeviceManifest?.(817      mobileManifest,818      new Uint8Array(32).fill(0x11),819    );820821    expect(await findPairedDeviceById("mesh-peer")).toMatchObject({822      trustSource: "vouched",823      relayUrl: MESH_RELAY,824    });825    expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();826    const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");827    await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(828      /^[0-9a-f]{64}$/,829    );830    expect(await listPendingVouchedDevices()).toEqual([]);831  });832833  it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", 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    // Once admitted, a peer propagates the pool without relying on the device841    // that introduced it. The Noise-authenticated key and stored signing key842    // still bind the manifest to this known mesh member.843    const vouchedIssuerId = "android-vouched-issuer";844    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);845    await upsertPairedDevice({846      deviceId: vouchedIssuerId,847      deviceName: "Second-hand Pixel",848      deviceType: "android",849      longTermPublicKeyHex: "1".repeat(64),850      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),851      pairedAt: 1_700_000_000_000,852      trustSource: "vouched",853    });854    const onward = await buildSignedDeviceManifest(855      vouchedIssuerId,856      [{857        deviceId: "onward-mesh-peer",858        deviceName: "Other Pixel",859        deviceType: "android",860        longTermPublicKeyHex: "2".repeat(64),861        signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),862        relayUrl: MESH_RELAY,863        pairedAt: 1_700_000_000_010,864      }],865      vouchedKp.privateKey,866    );867    await adapter.applyIncomingDeviceManifest?.(868      onward,869      new Uint8Array(32).fill(0x11),870    );871    expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({872      trustSource: "vouched",873      relayUrl: MESH_RELAY,874    });875    expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();876    const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");877    await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(878      /^[0-9a-f]{64}$/,879    );880    expect(await listPendingVouchedDevices()).toEqual([]);881882    const extensionIssuerId = "extension-bad-sig";883    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);884    await upsertPairedDevice({885      deviceId: extensionIssuerId,886      deviceName: "Chrome",887      deviceType: "extension",888      longTermPublicKeyHex: "3".repeat(64),889      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),890      pairedAt: 1_700_000_000_020,891      trustSource: "direct",892    });893    const good = await buildSignedDeviceManifest(894      extensionIssuerId,895      [{896        deviceId: "forged-add",897        deviceName: "Forged Pixel",898        deviceType: "android",899        longTermPublicKeyHex: "4".repeat(64),900        signingPublicKeyHex: await signingPubkeyHex("forged-add"),901        pairedAt: 1_700_000_000_030,902      }],903      extensionKp.privateKey,904    );905    await adapter.applyIncomingDeviceManifest?.(906      good,907      new Uint8Array(32).fill(0x44),908    );909    expect(await findPairedDeviceById("forged-add")).toBeNull();910911    const forged = {912      ...good,913      signatureHex:914        good.signatureHex.slice(0, 2) +915        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +916        good.signatureHex.slice(4),917    };918    await adapter.applyIncomingDeviceManifest?.(919      forged,920      new Uint8Array(32).fill(0x33),921    );922    expect(await findPairedDeviceById("forged-add")).toBeNull();923  });924925  it("falls back to derived signing key when stored issuer key is stale", async () => {926    const { vault, adapter } = await setupVault();927    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");928    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");929    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");930    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");931932    const issuerId = "stale-key-android";933    const rootHex = vault.getRootKeyHex()!;934    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));935    const issuerKp = await deriveSyncSigningKeypair(root, issuerId);936937    await upsertPairedDevice({938      deviceId: issuerId,939      deviceName: "Pixel",940      deviceType: "android",941      longTermPublicKeyHex: "c".repeat(64),942      // Stale: an older build persisted this at first-pair under a now-bad943      // seed. The fallback must recover from this without trusting the row.944      signingPublicKeyHex: "ff".repeat(32),945      pairedAt: 1_700_000_000_000,946      trustSource: "direct",947    });948949    const manifest = await buildSignedDeviceManifest(950      issuerId,951      [{952        deviceId: "onward-peer",953        deviceName: "Forwarded iPad",954        deviceType: "extension",955        longTermPublicKeyHex: "e".repeat(64),956        signingPublicKeyHex: pubkeyToHex(957          (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,958        ),959        relayUrl: MESH_RELAY,960        pairedAt: 1_700_000_000_010,961      }],962      issuerKp.privateKey,963    );964965    await adapter.applyIncomingDeviceManifest?.(966      manifest,967      new Uint8Array(32).fill(0xcc),968    );969970    expect(await findPairedDeviceById("onward-peer")).toMatchObject({971      trustSource: "vouched",972      relayUrl: MESH_RELAY,973    });974    expect((await findPairedDeviceById("onward-peer"))?.rendezvousSecretHex).toBeUndefined();975    const { getPairSecret: getPairSecretStale } = await import("./pair-secrets");976    await expect(getPairSecretStale("onward-peer", vault.getRootKeyHex())).resolves.toMatch(977      /^[0-9a-f]{64}$/,978    );979    expect(await listPendingVouchedDevices()).toEqual([]);980  });981982  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {983    const { adapter } = await setupVault();984    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");985    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");986    const { upsertPairedDevice } = await import("./paired-devices");987    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");988989    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an990    // unknown-key lookup can be what rejects this — only the binding between991    // the Noise-authenticated static key and the manifest's claimed issuer.992    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");993    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");994    await upsertPairedDevice({995      deviceId: "peer-alpha",996      deviceName: "Alpha",997      deviceType: "android",998      longTermPublicKeyHex: "a".repeat(64),999      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),1000      pairedAt: 1,1001      trustSource: "direct",1002    });1003    await upsertPairedDevice({1004      deviceId: "peer-beta",1005      deviceName: "Beta",1006      deviceType: "android",1007      longTermPublicKeyHex: "b".repeat(64),1008      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),1009      pairedAt: 2,1010      trustSource: "direct",1011    });10121013    const fromBeta = await buildSignedDeviceManifest(1014      "peer-beta",1015      [{1016        deviceId: "smuggled",1017        deviceName: "Smuggled",1018        deviceType: "android",1019        longTermPublicKeyHex: "5".repeat(64),1020        signingPublicKeyHex: await signingPubkeyHex("smuggled"),1021        relayUrl: MESH_RELAY,1022        pairedAt: 3,1023      }],1024      betaKp.privateKey,1025    );10261027    // Delivered over a session Noise-authenticated as ALPHA.1028    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));1029    expect(await listPendingVouchedDevices()).toEqual([]);10301031    // Same manifest, same devices, delivered over BETA's session: it must land.1032    // Without this control the assertion above could pass for any reason.1033    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));1034    const { findPairedDeviceById } = await import("./paired-devices");1035    expect(await findPairedDeviceById("smuggled")).toMatchObject({1036      trustSource: "vouched",1037      relayUrl: MESH_RELAY,1038    });1039    expect(await listPendingVouchedDevices()).toEqual([]);1040  });10411042  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1043    const { adapter } = await setupVault();1044    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1045    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1046    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1047    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10481049    const issuerId = "phone-issuer";1050    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1051    const stolenKey = "d".repeat(64);1052    await upsertPairedDevice({1053      deviceId: issuerId,1054      deviceName: "Pixel",1055      deviceType: "android",1056      longTermPublicKeyHex: "c".repeat(64),1057      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1058      pairedAt: 1,1059      trustSource: "direct",1060    });1061    await upsertPairedDevice({1062      deviceId: "stolen-laptop",1063      deviceName: "Old Laptop",1064      deviceType: "extension",1065      longTermPublicKeyHex: stolenKey,1066      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1067      pairedAt: 2,1068      trustSource: "direct",1069    });10701071    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1072      revoked: [{1073        deviceId: "stolen-laptop",1074        longTermPublicKeyHex: stolenKey,1075        revokedAt: Date.now(),1076        revokedByDeviceId: issuerId,1077      }],1078    });1079    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1080    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10811082    // deviceId is the issuer's to choose, so re-vouching the same key under a1083    // new one is free. Terminality has to follow the key, not the label.1084    const renamed = await buildSignedDeviceManifest(1085      issuerId,1086      [{1087        deviceId: "stolen-laptop-mk2",1088        deviceName: "New Laptop",1089        deviceType: "extension",1090        longTermPublicKeyHex: stolenKey,1091        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1092        relayUrl: MESH_RELAY,1093        pairedAt: Date.now(),1094      }],1095      issuerKp.privateKey,1096    );1097    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1098    expect(await listPendingVouchedDevices()).toEqual([]);1099    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();11001101    // Control: the same fresh id carrying a key that was never revoked queues1102    // normally, so the rejection above is the key check and not the id.1103    const clean = await buildSignedDeviceManifest(1104      issuerId,1105      [{1106        deviceId: "stolen-laptop-mk2",1107        deviceName: "New Laptop",1108        deviceType: "extension",1109        longTermPublicKeyHex: "9".repeat(64),1110        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1111        relayUrl: MESH_RELAY,1112        pairedAt: Date.now(),1113      }],1114      issuerKp.privateKey,1115    );1116    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1117    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1118      trustSource: "vouched",1119      relayUrl: MESH_RELAY,1120    });1121    expect(await listPendingVouchedDevices()).toEqual([]);1122  });11231124  it("S1: a revoked key cannot be written onto a device we still trust", async () => {1125    const { adapter } = await setupVault();1126    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1127    const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1128    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");11291130    const issuerId = "phone-issuer";1131    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1132    const revokedKey = "d".repeat(64);1133    await upsertPairedDevice({1134      deviceId: issuerId,1135      deviceName: "Pixel",1136      deviceType: "android",1137      longTermPublicKeyHex: "c".repeat(64),1138      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1139      pairedAt: 1,1140      trustSource: "direct",1141    });1142    await upsertPairedDevice({1143      deviceId: "trusted-tablet",1144      deviceName: "Tablet",1145      deviceType: "android",1146      longTermPublicKeyHex: "7".repeat(64),1147      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1148      pairedAt: 2,1149      trustSource: "direct",1150    });11511152    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1153      revoked: [{1154        deviceId: "stolen-laptop",1155        longTermPublicKeyHex: revokedKey,1156        revokedAt: Date.now(),1157        revokedByDeviceId: issuerId,1158      }],1159    });1160    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11611162    // The manifest rotates a device we already trust onto the revoked key —1163    // the same re-admission dressed as a key rotation.1164    const rotate = await buildSignedDeviceManifest(1165      issuerId,1166      [{1167        deviceId: "trusted-tablet",1168        deviceName: "Tablet",1169        deviceType: "android",1170        longTermPublicKeyHex: revokedKey,1171        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1172        pairedAt: 2,1173      }],1174      issuerKp.privateKey,1175    );1176    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1177    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1178      longTermPublicKeyHex: "7".repeat(64),1179    });1180  });11811182  it("methods throw VaultLockedError when the vault is locked", async () => {1183    const { vault, adapter } = await setupVault();1184    vault.lock();1185    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1186    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1187    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1188    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1189    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1190    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1191    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1192  });1193});