sync-vault-adapter.test.ts

49.1 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 } = await import("../shared/legacy-identity-derivation.test-support");53    const { pubkeyToHex } = await import("../shared/sync-identity");54    const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);55    return pubkeyToHex(kp.publicKeyRaw);56  }5758  it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {59    const { adapter } = await setupVault();60    const id1 = await adapter.computeVaultId();61    const id2 = await adapter.computeVaultId();62    expect(id1).toBe(id2);63    expect(id1).toMatch(/^[0-9a-f]{64}$/);64  });6566  it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {67    const { vault, adapter } = await setupVault();68    const deviceId = await adapter.getOurDeviceId();69    const vaultIdBefore = await adapter.computeVaultId();7071    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");72    const { pubkeyToHex } = await import("../shared/sync-identity");73    const rootBefore = vault.getRootKeyHex()!;74    const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));75    const signBefore = pubkeyToHex(76      (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,77    );7879    expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);80    vault.lock();81    expect(await vault.unlock("a different passphrase")).toBe(true);8283    // vault_id derives from the stable root key, so it must not move.84    expect(await adapter.computeVaultId()).toBe(vaultIdBefore);85    const rootAfter = vault.getRootKeyHex()!;86    expect(rootAfter).toBe(rootBefore);87    const signAfter = pubkeyToHex(88      (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,89    );90    expect(signAfter).toBe(signBefore);91  });9293  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {94    const { adapter } = await setupVault();95    const id1 = await adapter.getOurDeviceId();96    const id2 = await adapter.getOurDeviceId();97    expect(id1).toBe(id2);98    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}$/);99  });100101  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {102    const { vault, adapter } = await setupVault();103    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });104    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });105    await vault.deleteEntry(b.id);106    const m = await adapter.buildEntryManifest();107    expect(Object.keys(m).sort()).toEqual([a.id]);108    expect(m[a.id]).toBe(a.modifiedAt);109  });110111  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {112    const { vault, adapter } = await setupVault();113    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });114    await vault.deleteEntry(a.id);115    const t = await adapter.buildTombstoneManifest();116    expect(Object.keys(t)).toEqual([a.id]);117    expect(typeof t[a.id]).toBe("number");118    expect(t[a.id]).toBeGreaterThan(0);119  });120121  it("buildTombstoneManifest retains ids after a full-entry purge", async () => {122    const { vault, adapter } = await setupVault();123    const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" });124    await vault.deleteEntry(entry.id);125    const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;126127    await vault.purgeEntry(entry.id);128129    expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt });130  });131132  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {133    const { vault, adapter } = await setupVault();134    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });135    const env = await adapter.getEncryptedEntries([a.id]);136    expect(Object.keys(env)).toEqual([a.id]);137    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);138    // Plaintext title must not be visible in the envelope.139    expect(env[a.id]).not.toContain("Alpha");140  });141142  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {143    const { adapter } = await setupVault();144    const env = await adapter.getEncryptedEntries(["does-not-exist"]);145    expect(env).toEqual({});146  });147148  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {149    const { vault, adapter } = await setupVault();150    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });151    const e1 = await adapter.getEncryptedEntries([a.id]);152    const e2 = await adapter.getEncryptedEntries([a.id]);153    expect(e1[a.id]).not.toBe(e2[a.id]);154  });155156  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {157    const { vault, adapter } = await setupVault();158    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });159    const envelopes = await adapter.getEncryptedEntries([a.id]);160    // Simulate a remote vault by bumping modifiedAt on the envelope's source161    // and then applying it back — we can't easily fork the module's storage,162    // so we just verify decrypt path by overwriting modifiedAt locally.163    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries164    // will re-add it because the local copy is tombstoned with an OLDER ts (since165    // we set modifiedAt forward by 1s in the new envelope payload).166    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.167    vault.lock();168    await vault.unlock("correct horse battery staple");169    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");170    const adapter2 = createSyncVaultAdapter();171    // applyEntries should be a no-op when local mtime >= envelope mtime172    await adapter2.applyEntries(envelopes);173    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");174  });175176  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {177    const { vault, adapter } = await setupVault();178    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });179    // Manually produce a fresher remote envelope by encrypting an updated entry shape.180    // We can't easily synthesise this without the entry key — so we use a second181    // adapter instance backed by the same vault, after mutating the underlying entry.182    await vault.updateEntry(local.id, { title: "Updated" });183    const newer = vault.getEntries().find((e) => e.id === local.id);184    expect(newer?.title).toBe("Updated");185    const envelopes = await adapter.getEncryptedEntries([local.id]);186    // Now revert the local entry to an older mtime by re-creating from scratch via187    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the188    // decrypt+merge path is that applyEntries on a fresher envelope keeps the189    // newer state, and on an older envelope is a no-op. We've covered the no-op190    // path above; for the apply path, simulate by replacing the in-memory entry191    // with an older copy and re-running applyEntries.192    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };193    await vault.restoreEntries([older], "replace");194    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");195    await adapter.applyEntries(envelopes);196    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");197  });198199  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {200    const { vault, adapter } = await setupVault();201    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });202    const env = await adapter.getEncryptedEntries([a.id]);203    // Re-key the envelope under a different id — adapter must refuse to merge it.204    const swapped: Record<string, string> = { "wrong-id": env[a.id] };205    await adapter.applyEntries(swapped);206    // Local entry remains unchanged; no new entry under "wrong-id"207    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);208  });209210  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {211    const { vault, adapter } = await setupVault();212    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });213    await vault.updateEntry(a.id, { title: "Fresh" });214    const envelopes = await adapter.getEncryptedEntries([a.id]);215    // Roll local back to an older copy, then apply: encrypt+decrypt both use the216    // null-byte-separated info, so the round-trip must succeed.217    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };218    await vault.restoreEntries([stale], "replace");219    await adapter.applyEntries(envelopes);220    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");221  });222223  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {224    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and225    // assert the patched adapter cannot decrypt it — it drops silently. Locks in226    // the conscious wire break and serves as the fixture for any future migration.227    const { vault, adapter } = await setupVault();228    const { HKDF } = await import("@stablelib/hkdf");229    const { SHA256 } = await import("@stablelib/sha256");230231    const masterHex = vault.getRootKeyHex()!;232    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));233    const entryId = crypto.randomUUID();234    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00235    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);236    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);237238    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };239    const nonce = crypto.getRandomValues(new Uint8Array(12));240    const aad = new TextEncoder().encode("peach-sync-entry-v1");241    const ct = new Uint8Array(242      await crypto.subtle.encrypt(243        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },244        key,245        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,246      ),247    );248    const envelope = new Uint8Array(nonce.length + ct.length);249    envelope.set(nonce, 0);250    envelope.set(ct, nonce.length);251    const legacyB64 = Buffer.from(envelope).toString("base64");252253    await adapter.applyEntries({ [entryId]: legacyB64 });254    // Decrypt fails under the new separated info → envelope dropped, entry absent.255    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);256  });257258  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {259    const { vault, adapter } = await setupVault();260    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });261    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });262    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();263    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();264  });265266  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {267    const { vault, adapter } = await setupVault();268    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });269    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });270    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();271  });272273  // --- MV-3: sync receive bounds ---274275  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {276    const { adapter } = await setupVault();277    const { SYNC_LIMITS } = await import("../shared/sync-limits");278    const tooMany: Record<string, string> = {};279    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";280    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);281  });282283  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {284    const { vault, adapter } = await setupVault();285    const { SYNC_LIMITS } = await import("../shared/sync-limits");286    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });287    await vault.updateEntry(local.id, { title: "Updated" });288    const envelopes = await adapter.getEncryptedEntries([local.id]);289    // Make the local copy stale so the valid envelope would win on merge.290    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };291    await vault.restoreEntries([older], "replace");292    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");293294    const oversized = "A".repeat(295      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,296    );297    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });298299    // The valid envelope applied; the oversized one was skipped (never decrypted).300    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");301    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);302  });303304  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {305    const { adapter } = await setupVault();306    const { SYNC_LIMITS } = await import("../shared/sync-limits");307    const tooMany: Record<string, number> = {};308    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;309    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);310  });311312  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {313    const { adapter } = await setupVault();314    const { upsertPairedDevice } = await import("./paired-devices");315    await upsertPairedDevice({316      deviceId: "android-1",317      deviceName: "Pixel",318      deviceType: "android",319      longTermPublicKeyHex: "a".repeat(64),320      signingPublicKeyHex: await signingPubkeyHex("android-1"),321      wsPort: 49152,322      relayUrl: MESH_RELAY,323      pairedAt: 1_700_000_000_000,324      trustSource: "direct",325    });326    await upsertPairedDevice({327      deviceId: "legacy-android",328      deviceName: "Legacy Pixel",329      deviceType: "android",330      longTermPublicKeyHex: "b".repeat(64),331      pairedAt: 1_700_000_000_100,332      trustSource: "direct",333    });334335    const manifest = await adapter.buildOutgoingDeviceManifest?.();336    expect(manifest).toBeDefined();337    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);338    expect(manifest?.body.devices).toHaveLength(1);339    expect(manifest?.body.devices[0]).toMatchObject({340      deviceId: "android-1",341      deviceName: "Pixel",342      deviceType: "android",343      longTermPublicKeyHex: "a".repeat(64),344      wsPort: 49152,345      relayUrl: MESH_RELAY,346    });347  });348349  it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {350    const { vault, adapter } = await setupVault();351    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");352    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");353    const { pubkeyToHex } = await import("../shared/sync-identity");354    const {355      findPairedDeviceById,356      listPairedDevices,357      upsertPairedDevice,358    } = await import("./paired-devices");359    const { listPendingVouchedDevices } = await import(360      "./pending-vouched-devices"361    );362363    const issuerId = "extension-issuer";364    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);365    await upsertPairedDevice({366      deviceId: issuerId,367      deviceName: "Firefox",368      deviceType: "extension",369      longTermPublicKeyHex: "c".repeat(64),370      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),371      pairedAt: 1_700_000_000_000,372      trustSource: "direct",373    });374    await upsertPairedDevice({375      deviceId: "android-existing",376      deviceName: "Existing Pixel",377      deviceType: "android",378      longTermPublicKeyHex: "d".repeat(64),379      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),380      pairedAt: 1_700_000_000_010,381      trustSource: "direct",382    });383384    const manifest = await buildSignedDeviceManifest(385      issuerId,386      [387        {388          deviceId: "android-new",389          deviceName: "New Pixel",390          deviceType: "android",391          longTermPublicKeyHex: "e".repeat(64),392          signingPublicKeyHex: await signingPubkeyHex("android-new"),393          wsPort: 51234,394          relayUrl: MESH_RELAY,395          pairedAt: 1_700_000_000_020,396        },397        {398          deviceId: "android-existing",399          deviceName: "Existing Pixel renamed",400          deviceType: "android",401          longTermPublicKeyHex: "d".repeat(64),402          signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),403          wsPort: 52345,404          relayUrl: MESH_RELAY,405          pairedAt: 1_700_000_000_030,406        },407      ],408      issuerKp.privateKey,409    );410411    await adapter.applyIncomingDeviceManifest?.(412      manifest,413      new Uint8Array(32).fill(0xcc),414    );415416    expect(await findPairedDeviceById("android-new")).toMatchObject({417      deviceName: "New Pixel",418      trustSource: "vouched",419      relayUrl: MESH_RELAY,420    });421    // PB-26-213: the row carries no reusable secret; it resolves from the422    // encrypted pair-secret record instead.423    expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();424    const { getPairSecret } = await import("./pair-secrets");425    await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(426      /^[0-9a-f]{64}$/,427    );428    const pending = await listPendingVouchedDevices();429    expect(pending).toEqual([]);430431    // Immutable identity binding rejects remote key rewrites; metadata and432    // routing fields from the signed manifest still update.433    const existing = await findPairedDeviceById("android-existing");434    expect(existing).toMatchObject({435      deviceName: "Existing Pixel",436      longTermPublicKeyHex: "d".repeat(64),437      trustSource: "direct",438      wsPort: 52345,439    });440    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([441      "android-existing",442      "android-new",443      "extension-issuer",444    ]);445  });446447  it("rejects manifest key replacement for an existing device", async () => {448    const { adapter } = await setupVault();449    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");450    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");451    const { pubkeyToHex } = await import("../shared/sync-identity");452    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");453    const issuerId = "extension-issuer";454    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);455    await upsertPairedDevice({456      deviceId: issuerId,457      deviceName: "Firefox",458      deviceType: "extension",459      longTermPublicKeyHex: "c".repeat(64),460      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),461      pairedAt: 1,462      trustSource: "direct",463    });464    await upsertPairedDevice({465      deviceId: "android-existing",466      deviceName: "Pixel",467      deviceType: "android",468      longTermPublicKeyHex: "d".repeat(64),469      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),470      pairedAt: 2,471      trustSource: "direct",472    });473    const manifest = await buildSignedDeviceManifest(474      issuerId,475      [{476        deviceId: "android-existing",477        deviceName: "Pixel",478        deviceType: "android",479        longTermPublicKeyHex: "f".repeat(64),480        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),481        wsPort: 52345,482        pairedAt: 3,483      }],484      issuerKp.privateKey,485    );486    await expect(487      adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),488    ).rejects.toThrow("device_identity_immutable");489    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({490      longTermPublicKeyHex: "d".repeat(64),491    });492  });493494  it("rejects manifest replacement even when the device name changes", async () => {495    const { adapter } = await setupVault();496    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");497    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");498    const { pubkeyToHex } = await import("../shared/sync-identity");499    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");500    const issuerId = "hostile-issuer";501    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);502    await upsertPairedDevice({503      deviceId: issuerId,504      deviceName: "Old Laptop",505      deviceType: "extension",506      longTermPublicKeyHex: "c".repeat(64),507      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),508      pairedAt: 1,509      trustSource: "direct",510    });511    await upsertPairedDevice({512      deviceId: "android-existing",513      deviceName: "Spare Tablet",514      deviceType: "android",515      longTermPublicKeyHex: "d".repeat(64),516      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),517      pairedAt: 2,518      trustSource: "direct",519    });520    const manifest = await buildSignedDeviceManifest(521      issuerId,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      issuerKp.privateKey,532    );533    await expect(534      adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc)),535    ).rejects.toThrow("device_identity_immutable");536    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({537      deviceName: "Spare Tablet",538      longTermPublicKeyHex: "d".repeat(64),539    });540  });541542  it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {543    const { adapter } = await setupVault();544    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");545    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");546    const { pubkeyToHex } = await import("../shared/sync-identity");547    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =548      await import("./paired-devices");549550    const issuerId = "phone-issuer";551    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);552    await upsertPairedDevice({553      deviceId: issuerId,554      deviceName: "Pixel",555      deviceType: "android",556      longTermPublicKeyHex: "c".repeat(64),557      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),558      pairedAt: 1,559      trustSource: "vouched",560    });561    await upsertPairedDevice({562      deviceId: "stolen-laptop",563      deviceName: "Old Laptop",564      deviceType: "extension",565      longTermPublicKeyHex: "d".repeat(64),566      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),567      pairedAt: 2,568      trustSource: "direct",569    });570571    const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);572    const signedRevocation = await signDeviceRevocation(573      {574        deviceId: "stolen-laptop",575        longTermPublicKeyHex: "d".repeat(64),576        revokedAt: Date.now(),577        revokedByDeviceId: issuerId,578      },579      issuerKp.privateKey,580      issuerSigningPublicKeyHex,581    );582    const revocation = await buildSignedDeviceManifest(583      issuerId,584      [],585      issuerKp.privateKey,586      { revoked: [signedRevocation] },587    );588    await adapter.applyIncomingDeviceManifest?.(589      revocation,590      new Uint8Array(32).fill(0xcc),591    );592593    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();594    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);595596    // The stolen device is now vouched back in by a later manifest carrying a597    // fresh pairedAt — exactly the resurrection a timestamp-ordered design598    // would allow. It must not come back.599    const revouch = await buildSignedDeviceManifest(600      issuerId,601      [602        {603          deviceId: "stolen-laptop",604          deviceName: "Old Laptop",605          deviceType: "extension",606          longTermPublicKeyHex: "d".repeat(64),607          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),608          pairedAt: Date.now() + 60_000,609        },610      ],611      issuerKp.privateKey,612    );613    await adapter.applyIncomingDeviceManifest?.(614      revouch,615      new Uint8Array(32).fill(0xcc),616    );617618    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();619    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");620    expect(await listPendingVouchedDevices()).toHaveLength(0);621  });622623  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {624    const { adapter } = await setupVault();625    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");626    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");627    const { pubkeyToHex } = await import("../shared/sync-identity");628    const {629      isDeviceRevoked,630      revokeDeviceById,631      upsertPairedDevice,632    } = await import("./paired-devices");633    const { getOrCreateDeviceId } = await import("./device-identity");634635    const ourDeviceId = await getOrCreateDeviceId();636    const issuerId = "phone-issuer";637    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);638    await upsertPairedDevice({639      deviceId: issuerId,640      deviceName: "Pixel",641      deviceType: "android",642      longTermPublicKeyHex: "c".repeat(64),643      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),644      pairedAt: 1,645      trustSource: "direct",646    });647648    // A compromised peer tries to sever the mesh by revoking this device.649    const selfRevoke = await buildSignedDeviceManifest(650      issuerId,651      [],652      issuerKp.privateKey,653      {654        revoked: [655          {656            deviceId: ourDeviceId,657            longTermPublicKeyHex: "e".repeat(64),658            revokedAt: Date.now(),659            revokedByDeviceId: issuerId,660          },661        ],662      },663    );664    await adapter.applyIncomingDeviceManifest?.(665      selfRevoke,666      new Uint8Array(32).fill(0xcc),667    );668    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);669670    // Our own revocations ride out in the manifest we build, and the revoked671    // device is absent from the vouched list.672    await upsertPairedDevice({673      deviceId: "retired-tablet",674      deviceName: "Retired Tablet",675      deviceType: "android",676      longTermPublicKeyHex: "f".repeat(64),677      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),678      pairedAt: 3,679      trustSource: "direct",680    });681    await revokeDeviceById("retired-tablet", ourDeviceId);682683    const outgoing = await adapter.buildOutgoingDeviceManifest?.();684    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);685    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");686  });687688  // PB-26-116: rows written before `trustSource` existed carry no provenance.689  // The manifest-apply path used to default them to "direct", which is the690  // opposite of the fail-closed rule PB-26-006 established for the mesh routing691  // gate — a legacy row became a dial target, manifest issuer, and revocation-692  // repair participant on the strength of a signed manifest alone.693  it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {694    const { adapter } = await setupVault();695    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");696    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");697    const { pubkeyToHex } = await import("../shared/sync-identity");698    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");699    const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");700701    const issuerId = "android-legacy-issuer";702    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);703    await upsertPairedDevice({704      deviceId: issuerId,705      deviceName: "Issuer Pixel",706      deviceType: "android",707      longTermPublicKeyHex: "1".repeat(64),708      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),709      pairedAt: 1_700_000_000_000,710      trustSource: "direct",711    });712713    // A row predating the field: present in the registry, no recorded714    // provenance. Seeded through storage so no writer stamps a default.715    const legacyId = "legacy-peer";716    const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;717    registry.push({718      deviceId: legacyId,719      deviceName: "Legacy Peer",720      deviceType: "android",721      longTermPublicKeyHex: "3".repeat(64),722      signingPublicKeyHex: await signingPubkeyHex(legacyId),723      rendezvousSecretHex: "9".repeat(64),724      relayUrl: MESH_RELAY,725      pairedAt: 1_700_000_000_000,726    });727728    const manifest = await buildSignedDeviceManifest(729      issuerId,730      [{731        deviceId: legacyId,732        deviceName: "Legacy Peer",733        deviceType: "android",734        longTermPublicKeyHex: "3".repeat(64),735        signingPublicKeyHex: await signingPubkeyHex(legacyId),736        relayUrl: MESH_RELAY,737        pairedAt: 1_700_000_000_000,738      }],739      issuerKp.privateKey,740    );741    await adapter.applyIncomingDeviceManifest?.(742      manifest,743      new Uint8Array(32).fill(0x11),744    );745746    const row = await findPairedDeviceById(legacyId);747    expect(row).not.toBeNull();748    // Not silently promoted: unknown provenance stays unknown.749    expect(row?.trustSource).toBeUndefined();750    expect(row?.trustSource).not.toBe("direct");751    // And therefore not a routing authority.752    expect(isRouteCapableMeshPeer(row!)).toBe(false);753  });754755  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {756    // The browser can never listen, so it joins the mesh THROUGH a native peer;757    // that peer is the one holding the peer list. A phone-issued manifest must758    // therefore be honoured. MV-7 still applies: the introduced device lands in759    // the pending queue, not the registry.760    const { adapter } = await setupVault();761    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");762    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");763    const { pubkeyToHex } = await import("../shared/sync-identity");764    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");765    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");766    const vault = await import("./vault");767768    const mobileIssuerId = "android-issuer";769    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);770    await upsertPairedDevice({771      deviceId: mobileIssuerId,772      deviceName: "Issuer Pixel",773      deviceType: "android",774      longTermPublicKeyHex: "1".repeat(64),775      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),776      pairedAt: 1_700_000_000_000,777      trustSource: "direct",778    });779    const mobileManifest = await buildSignedDeviceManifest(780      mobileIssuerId,781      [{782        deviceId: "mesh-peer",783        deviceName: "Other Pixel",784        deviceType: "android",785        longTermPublicKeyHex: "2".repeat(64),786        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),787        relayUrl: MESH_RELAY,788        pairedAt: 1_700_000_000_010,789      }],790      mobileIssuerKp.privateKey,791    );792    await adapter.applyIncomingDeviceManifest?.(793      mobileManifest,794      new Uint8Array(32).fill(0x11),795    );796797    expect(await findPairedDeviceById("mesh-peer")).toMatchObject({798      trustSource: "vouched",799      relayUrl: MESH_RELAY,800    });801    expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();802    const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");803    await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(804      /^[0-9a-f]{64}$/,805    );806    expect(await listPendingVouchedDevices()).toEqual([]);807  });808809  it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {810    const { vault, adapter } = await setupVault();811    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");812    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");813    const { pubkeyToHex } = await import("../shared/sync-identity");814    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");815    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");816817    // Once admitted, a peer propagates the pool without relying on the device818    // that introduced it. The Noise-authenticated key and stored signing key819    // still bind the manifest to this known mesh member.820    const vouchedIssuerId = "android-vouched-issuer";821    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);822    await upsertPairedDevice({823      deviceId: vouchedIssuerId,824      deviceName: "Second-hand Pixel",825      deviceType: "android",826      longTermPublicKeyHex: "1".repeat(64),827      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),828      pairedAt: 1_700_000_000_000,829      trustSource: "vouched",830    });831    const onward = await buildSignedDeviceManifest(832      vouchedIssuerId,833      [{834        deviceId: "onward-mesh-peer",835        deviceName: "Other Pixel",836        deviceType: "android",837        longTermPublicKeyHex: "2".repeat(64),838        signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),839        relayUrl: MESH_RELAY,840        pairedAt: 1_700_000_000_010,841      }],842      vouchedKp.privateKey,843    );844    await adapter.applyIncomingDeviceManifest?.(845      onward,846      new Uint8Array(32).fill(0x11),847    );848    expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({849      trustSource: "vouched",850      relayUrl: MESH_RELAY,851    });852    expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();853    const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");854    await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(855      /^[0-9a-f]{64}$/,856    );857    expect(await listPendingVouchedDevices()).toEqual([]);858859    const extensionIssuerId = "extension-bad-sig";860    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);861    await upsertPairedDevice({862      deviceId: extensionIssuerId,863      deviceName: "Chrome",864      deviceType: "extension",865      longTermPublicKeyHex: "3".repeat(64),866      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),867      pairedAt: 1_700_000_000_020,868      trustSource: "direct",869    });870    const good = await buildSignedDeviceManifest(871      extensionIssuerId,872      [{873        deviceId: "forged-add",874        deviceName: "Forged Pixel",875        deviceType: "android",876        longTermPublicKeyHex: "4".repeat(64),877        signingPublicKeyHex: await signingPubkeyHex("forged-add"),878        pairedAt: 1_700_000_000_030,879      }],880      extensionKp.privateKey,881    );882    await adapter.applyIncomingDeviceManifest?.(883      good,884      new Uint8Array(32).fill(0x44),885    );886    expect(await findPairedDeviceById("forged-add")).toBeNull();887888    const forged = {889      ...good,890      signatureHex:891        good.signatureHex.slice(0, 2) +892        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +893        good.signatureHex.slice(4),894    };895    await adapter.applyIncomingDeviceManifest?.(896      forged,897      new Uint8Array(32).fill(0x33),898    );899    expect(await findPairedDeviceById("forged-add")).toBeNull();900  });901902  it("rejects a manifest when the stored issuer signing key is stale", async () => {903    const { vault, adapter } = await setupVault();904    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");905    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");906    const { pubkeyToHex } = await import("../shared/sync-identity");907    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");908909    const issuerId = "stale-key-android";910    const rootHex = vault.getRootKeyHex()!;911    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));912    const issuerKp = await deriveSyncSigningKeypair(root, issuerId);913914    await upsertPairedDevice({915      deviceId: issuerId,916      deviceName: "Pixel",917      deviceType: "android",918      longTermPublicKeyHex: "c".repeat(64),919      signingPublicKeyHex: "ff".repeat(32),920      pairedAt: 1_700_000_000_000,921      trustSource: "direct",922    });923924    const manifest = await buildSignedDeviceManifest(925      issuerId,926      [{927        deviceId: "onward-peer",928        deviceName: "Forwarded iPad",929        deviceType: "extension",930        longTermPublicKeyHex: "e".repeat(64),931        signingPublicKeyHex: pubkeyToHex(932          (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,933        ),934        relayUrl: MESH_RELAY,935        pairedAt: 1_700_000_000_010,936      }],937      issuerKp.privateKey,938    );939940    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));941942    expect(await findPairedDeviceById("onward-peer")).toBeNull();943  });944945  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {946    const { adapter } = await setupVault();947    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");948    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");949    const { pubkeyToHex } = await import("../shared/sync-identity");950    const { upsertPairedDevice } = await import("./paired-devices");951    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");952953    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an954    // unknown-key lookup can be what rejects this — only the binding between955    // the Noise-authenticated static key and the manifest's claimed issuer.956    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");957    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");958    await upsertPairedDevice({959      deviceId: "peer-alpha",960      deviceName: "Alpha",961      deviceType: "android",962      longTermPublicKeyHex: "a".repeat(64),963      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),964      pairedAt: 1,965      trustSource: "direct",966    });967    await upsertPairedDevice({968      deviceId: "peer-beta",969      deviceName: "Beta",970      deviceType: "android",971      longTermPublicKeyHex: "b".repeat(64),972      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),973      pairedAt: 2,974      trustSource: "direct",975    });976977    const fromBeta = await buildSignedDeviceManifest(978      "peer-beta",979      [{980        deviceId: "smuggled",981        deviceName: "Smuggled",982        deviceType: "android",983        longTermPublicKeyHex: "5".repeat(64),984        signingPublicKeyHex: await signingPubkeyHex("smuggled"),985        relayUrl: MESH_RELAY,986        pairedAt: 3,987      }],988      betaKp.privateKey,989    );990991    // Delivered over a session Noise-authenticated as ALPHA.992    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));993    expect(await listPendingVouchedDevices()).toEqual([]);994995    // Same manifest, same devices, delivered over BETA's session: it must land.996    // Without this control the assertion above could pass for any reason.997    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));998    const { findPairedDeviceById } = await import("./paired-devices");999    expect(await findPairedDeviceById("smuggled")).toMatchObject({1000      trustSource: "vouched",1001      relayUrl: MESH_RELAY,1002    });1003    expect(await listPendingVouchedDevices()).toEqual([]);1004  });1005  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1006    const { adapter } = await setupVault();1007    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1008    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1009    const { pubkeyToHex } = await import("../shared/sync-identity");1010    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1011    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10121013    const issuerId = "phone-issuer";1014    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1015    const stolenKey = "d".repeat(64);1016    await upsertPairedDevice({1017      deviceId: issuerId,1018      deviceName: "Pixel",1019      deviceType: "android",1020      longTermPublicKeyHex: "c".repeat(64),1021      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1022      pairedAt: 1,1023      trustSource: "direct",1024    });1025    await upsertPairedDevice({1026      deviceId: "stolen-laptop",1027      deviceName: "Old Laptop",1028      deviceType: "extension",1029      longTermPublicKeyHex: stolenKey,1030      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1031      pairedAt: 2,1032      trustSource: "direct",1033    });1034    const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);1035    const signedRevocation = await signDeviceRevocation({1036      deviceId: "stolen-laptop",1037      longTermPublicKeyHex: stolenKey,1038      revokedAt: Date.now(),1039      revokedByDeviceId: issuerId,1040    }, issuerKp.privateKey, issuerSigningPublicKeyHex);1041    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1042      revoked: [signedRevocation],1043    });1044    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1045    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10461047    // deviceId is the issuer's to choose, so re-vouching the same key under a1048    // new one is free. Terminality has to follow the key, not the label.1049    const renamed = await buildSignedDeviceManifest(1050      issuerId,1051      [{1052        deviceId: "stolen-laptop-mk2",1053        deviceName: "New Laptop",1054        deviceType: "extension",1055        longTermPublicKeyHex: stolenKey,1056        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1057        relayUrl: MESH_RELAY,1058        pairedAt: Date.now(),1059      }],1060      issuerKp.privateKey,1061    );1062    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1063    expect(await listPendingVouchedDevices()).toEqual([]);1064    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10651066    // Control: the same fresh id carrying a key that was never revoked queues1067    // normally, so the rejection above is the key check and not the id.1068    const clean = await buildSignedDeviceManifest(1069      issuerId,1070      [{1071        deviceId: "stolen-laptop-mk2",1072        deviceName: "New Laptop",1073        deviceType: "extension",1074        longTermPublicKeyHex: "9".repeat(64),1075        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1076        relayUrl: MESH_RELAY,1077        pairedAt: Date.now(),1078      }],1079      issuerKp.privateKey,1080    );1081    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1082    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1083      trustSource: "vouched",1084      relayUrl: MESH_RELAY,1085    });1086    expect(await listPendingVouchedDevices()).toEqual([]);1087  });10881089  it("S1: a revoked key cannot be written onto a device we still trust", async () => {1090    const { adapter } = await setupVault();1091    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1092    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1093    const { pubkeyToHex } = await import("../shared/sync-identity");1094    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10951096    const issuerId = "phone-issuer";1097    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1098    const revokedKey = "d".repeat(64);1099    await upsertPairedDevice({1100      deviceId: issuerId,1101      deviceName: "Pixel",1102      deviceType: "android",1103      longTermPublicKeyHex: "c".repeat(64),1104      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1105      pairedAt: 1,1106      trustSource: "direct",1107    });1108    await upsertPairedDevice({1109      deviceId: "trusted-tablet",1110      deviceName: "Tablet",1111      deviceType: "android",1112      longTermPublicKeyHex: "7".repeat(64),1113      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1114      pairedAt: 2,1115      trustSource: "direct",1116    });11171118    const issuerSigningPublicKeyHex = pubkeyToHex(issuerKp.publicKeyRaw);1119    const signedRevocation = await signDeviceRevocation({1120      deviceId: "stolen-laptop",1121      longTermPublicKeyHex: revokedKey,1122      revokedAt: Date.now(),1123      revokedByDeviceId: issuerId,1124    }, issuerKp.privateKey, issuerSigningPublicKeyHex);1125    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1126      revoked: [signedRevocation],1127    });1128    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11291130    // The manifest rotates a device we already trust onto the revoked key —1131    // the same re-admission dressed as a key rotation.1132    const rotate = await buildSignedDeviceManifest(1133      issuerId,1134      [{1135        deviceId: "trusted-tablet",1136        deviceName: "Tablet",1137        deviceType: "android",1138        longTermPublicKeyHex: revokedKey,1139        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1140        pairedAt: 2,1141      }],1142      issuerKp.privateKey,1143    );1144    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1145    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1146      longTermPublicKeyHex: "7".repeat(64),1147    });1148  });11491150  it("methods throw VaultLockedError when the vault is locked", async () => {1151    const { vault, adapter } = await setupVault();1152    vault.lock();1153    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1154    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1155    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1156    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1157    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1158    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1159    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1160  });1161});