sync-vault-adapter.test.ts

48.9 KB · raw

1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;4const MESH_RELAY = "wss://relay.peachpasswords.com";56function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {7  const area = (store: StorageShape) => ({8    get: vi.fn(async (key?: string | string[]) => {9      if (!key) return store;10      if (typeof key === "string") return { [key]: store[key] };11      return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});12    }),13    set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),14    remove: vi.fn(async (keys: string | string[]) => {15      const list = Array.isArray(keys) ? keys : [keys];16      for (const k of list) delete store[k];17    }),18  });19  return {20    storage: { local: area(local), sync: area(sync) },21    alarms: {22      clear: vi.fn(),23      create: vi.fn(),24      onAlarm: { addListener: vi.fn() },25    },26    idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },27    runtime: { sendMessage: vi.fn(async () => undefined) },28  } as unknown as typeof chrome;29}3031describe("SyncVaultAdapter", () => {32  let local: StorageShape;33  let sync: StorageShape;3435  beforeEach(() => {36    vi.resetModules();37    local = {};38    sync = {};39    (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);40    vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));41  });4243  async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./sync-coordinator").SyncVaultAdapter }> {44    const vault = await import("./vault");45    await vault.createVault("correct horse battery staple");46    // Every real install mints its identity with (or before) its vault, so a47    // fixture that seeds trust state without one is an install §11.5 refuses.48    const identity = await import("./device-identity");49    await identity.getOrCreateDeviceId();50    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");51    const adapter = createSyncVaultAdapter();52    return { vault, adapter };53  }5455  async function signingPubkeyHex(deviceId: string): Promise<string> {56    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");57    const { pubkeyToHex } = await import("../shared/sync-identity");58    const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);59    return pubkeyToHex(kp.publicKeyRaw);60  }6162  it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {63    const { adapter } = await setupVault();64    const id1 = await adapter.computeVaultId();65    const id2 = await adapter.computeVaultId();66    expect(id1).toBe(id2);67    expect(id1).toMatch(/^[0-9a-f]{64}$/);68  });6970  it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {71    const { vault, adapter } = await setupVault();72    const deviceId = await adapter.getOurDeviceId();73    const vaultIdBefore = await adapter.computeVaultId();7475    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");76    const { pubkeyToHex } = await import("../shared/sync-identity");77    const rootBefore = vault.getRootKeyHex()!;78    const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));79    const signBefore = pubkeyToHex(80      (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,81    );8283    expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);84    vault.lock();85    expect(await vault.unlock("a different passphrase")).toBe(true);8687    // vault_id derives from the stable root key, so it must not move.88    expect(await adapter.computeVaultId()).toBe(vaultIdBefore);89    const rootAfter = vault.getRootKeyHex()!;90    expect(rootAfter).toBe(rootBefore);91    const signAfter = pubkeyToHex(92      (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,93    );94    expect(signAfter).toBe(signBefore);95  });9697  it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {98    const { adapter } = await setupVault();99    const id1 = await adapter.getOurDeviceId();100    const id2 = await adapter.getOurDeviceId();101    expect(id1).toBe(id2);102    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}$/);103  });104105  it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {106    const { vault, adapter } = await setupVault();107    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });108    const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });109    await vault.deleteEntry(b.id);110    const m = await adapter.buildEntryManifest();111    expect(Object.keys(m).sort()).toEqual([a.id]);112    expect(m[a.id]).toBe(a.modifiedAt);113  });114115  it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {116    const { vault, adapter } = await setupVault();117    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });118    await vault.deleteEntry(a.id);119    const t = await adapter.buildTombstoneManifest();120    expect(Object.keys(t)).toEqual([a.id]);121    expect(typeof t[a.id]).toBe("number");122    expect(t[a.id]).toBeGreaterThan(0);123  });124125  it("buildTombstoneManifest retains ids after a full-entry purge", async () => {126    const { vault, adapter } = await setupVault();127    const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" });128    await vault.deleteEntry(entry.id);129    const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;130131    await vault.purgeEntry(entry.id);132133    expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt });134  });135136  it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {137    const { vault, adapter } = await setupVault();138    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });139    const env = await adapter.getEncryptedEntries([a.id]);140    expect(Object.keys(env)).toEqual([a.id]);141    expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);142    // Plaintext title must not be visible in the envelope.143    expect(env[a.id]).not.toContain("Alpha");144  });145146  it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {147    const { adapter } = await setupVault();148    const env = await adapter.getEncryptedEntries(["does-not-exist"]);149    expect(env).toEqual({});150  });151152  it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {153    const { vault, adapter } = await setupVault();154    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });155    const e1 = await adapter.getEncryptedEntries([a.id]);156    const e2 = await adapter.getEncryptedEntries([a.id]);157    expect(e1[a.id]).not.toBe(e2[a.id]);158  });159160  it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {161    const { vault, adapter } = await setupVault();162    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });163    const envelopes = await adapter.getEncryptedEntries([a.id]);164    // Simulate a remote vault by bumping modifiedAt on the envelope's source165    // and then applying it back — we can't easily fork the module's storage,166    // so we just verify decrypt path by overwriting modifiedAt locally.167    // Delete the local entry first, then apply the envelope — applyMergedRemoteEntries168    // will re-add it because the local copy is tombstoned with an OLDER ts (since169    // we set modifiedAt forward by 1s in the new envelope payload).170    // Instead, simpler proof: apply the envelope as-is to a fresh adapter import.171    vault.lock();172    await vault.unlock("correct horse battery staple");173    const { createSyncVaultAdapter } = await import("./sync-vault-adapter");174    const adapter2 = createSyncVaultAdapter();175    // applyEntries should be a no-op when local mtime >= envelope mtime176    await adapter2.applyEntries(envelopes);177    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");178  });179180  it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {181    const { vault, adapter } = await setupVault();182    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });183    // Manually produce a fresher remote envelope by encrypting an updated entry shape.184    // We can't easily synthesise this without the entry key — so we use a second185    // adapter instance backed by the same vault, after mutating the underlying entry.186    await vault.updateEntry(local.id, { title: "Updated" });187    const newer = vault.getEntries().find((e) => e.id === local.id);188    expect(newer?.title).toBe("Updated");189    const envelopes = await adapter.getEncryptedEntries([local.id]);190    // Now revert the local entry to an older mtime by re-creating from scratch via191    // applyMergedRemoteEntries — but we'd loop. The most reliable proof of the192    // decrypt+merge path is that applyEntries on a fresher envelope keeps the193    // newer state, and on an older envelope is a no-op. We've covered the no-op194    // path above; for the apply path, simulate by replacing the in-memory entry195    // with an older copy and re-running applyEntries.196    const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };197    await vault.restoreEntries([older], "replace");198    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");199    await adapter.applyEntries(envelopes);200    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");201  });202203  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {204    const { vault, adapter } = await setupVault();205    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });206    const env = await adapter.getEncryptedEntries([a.id]);207    // Re-key the envelope under a different id — adapter must refuse to merge it.208    const swapped: Record<string, string> = { "wrong-id": env[a.id] };209    await adapter.applyEntries(swapped);210    // Local entry remains unchanged; no new entry under "wrong-id"211    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);212  });213214  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {215    const { vault, adapter } = await setupVault();216    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });217    await vault.updateEntry(a.id, { title: "Fresh" });218    const envelopes = await adapter.getEncryptedEntries([a.id]);219    // Roll local back to an older copy, then apply: encrypt+decrypt both use the220    // null-byte-separated info, so the round-trip must succeed.221    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };222    await vault.restoreEntries([stale], "replace");223    await adapter.applyEntries(envelopes);224    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");225  });226227  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {228    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and229    // assert the patched adapter cannot decrypt it — it drops silently. Locks in230    // the conscious wire break and serves as the fixture for any future migration.231    const { vault, adapter } = await setupVault();232    const { HKDF } = await import("@stablelib/hkdf");233    const { SHA256 } = await import("@stablelib/sha256");234235    const masterHex = vault.getRootKeyHex()!;236    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));237    const entryId = crypto.randomUUID();238    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00239    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);240    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);241242    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };243    const nonce = crypto.getRandomValues(new Uint8Array(12));244    const aad = new TextEncoder().encode("peach-sync-entry-v1");245    const ct = new Uint8Array(246      await crypto.subtle.encrypt(247        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },248        key,249        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,250      ),251    );252    const envelope = new Uint8Array(nonce.length + ct.length);253    envelope.set(nonce, 0);254    envelope.set(ct, nonce.length);255    const legacyB64 = Buffer.from(envelope).toString("base64");256257    await adapter.applyEntries({ [entryId]: legacyB64 });258    // Decrypt fails under the new separated info → envelope dropped, entry absent.259    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);260  });261262  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {263    const { vault, adapter } = await setupVault();264    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });265    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });266    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();267    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();268  });269270  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {271    const { vault, adapter } = await setupVault();272    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });273    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });274    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();275  });276277  // --- MV-3: sync receive bounds ---278279  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {280    const { adapter } = await setupVault();281    const { SYNC_LIMITS } = await import("../shared/sync-limits");282    const tooMany: Record<string, string> = {};283    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";284    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);285  });286287  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {288    const { vault, adapter } = await setupVault();289    const { SYNC_LIMITS } = await import("../shared/sync-limits");290    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });291    await vault.updateEntry(local.id, { title: "Updated" });292    const envelopes = await adapter.getEncryptedEntries([local.id]);293    // Make the local copy stale so the valid envelope would win on merge.294    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };295    await vault.restoreEntries([older], "replace");296    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");297298    const oversized = "A".repeat(299      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,300    );301    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });302303    // The valid envelope applied; the oversized one was skipped (never decrypted).304    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");305    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);306  });307308  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {309    const { adapter } = await setupVault();310    const { SYNC_LIMITS } = await import("../shared/sync-limits");311    const tooMany: Record<string, number> = {};312    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;313    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);314  });315316  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {317    const { adapter } = await setupVault();318    const { upsertPairedDevice } = await import("./paired-devices");319    await upsertPairedDevice({320      deviceId: "android-1",321      deviceName: "Pixel",322      deviceType: "android",323      longTermPublicKeyHex: "a".repeat(64),324      signingPublicKeyHex: await signingPubkeyHex("android-1"),325      wsPort: 49152,326      relayUrl: MESH_RELAY,327      pairedAt: 1_700_000_000_000,328      trustSource: "direct",329    });330    await upsertPairedDevice({331      deviceId: "legacy-android",332      deviceName: "Legacy Pixel",333      deviceType: "android",334      longTermPublicKeyHex: "b".repeat(64),335      pairedAt: 1_700_000_000_100,336      trustSource: "direct",337    });338339    const manifest = await adapter.buildOutgoingDeviceManifest?.();340    expect(manifest).toBeDefined();341    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);342    expect(manifest?.body.devices).toHaveLength(1);343    expect(manifest?.body.devices[0]).toMatchObject({344      deviceId: "android-1",345      deviceName: "Pixel",346      deviceType: "android",347      longTermPublicKeyHex: "a".repeat(64),348      wsPort: 49152,349      relayUrl: MESH_RELAY,350    });351  });352353  it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {354    const { vault, adapter } = await setupVault();355    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");356    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");357    const { pubkeyToHex } = await import("../shared/sync-identity");358    const {359      findPairedDeviceById,360      listPairedDevices,361      upsertPairedDevice,362    } = await import("./paired-devices");363    const { listPendingVouchedDevices } = await import(364      "./pending-vouched-devices"365    );366367    const issuerId = "extension-issuer";368    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);369    await upsertPairedDevice({370      deviceId: issuerId,371      deviceName: "Firefox",372      deviceType: "extension",373      longTermPublicKeyHex: "c".repeat(64),374      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),375      pairedAt: 1_700_000_000_000,376      trustSource: "direct",377    });378    await upsertPairedDevice({379      deviceId: "android-existing",380      deviceName: "Existing Pixel",381      deviceType: "android",382      longTermPublicKeyHex: "d".repeat(64),383      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),384      pairedAt: 1_700_000_000_010,385      trustSource: "direct",386    });387388    const manifest = await buildSignedDeviceManifest(389      issuerId,390      [391        {392          deviceId: "android-new",393          deviceName: "New Pixel",394          deviceType: "android",395          longTermPublicKeyHex: "e".repeat(64),396          signingPublicKeyHex: await signingPubkeyHex("android-new"),397          wsPort: 51234,398          relayUrl: MESH_RELAY,399          pairedAt: 1_700_000_000_020,400        },401        {402          deviceId: "android-existing",403          deviceName: "Existing Pixel renamed",404          deviceType: "android",405          longTermPublicKeyHex: "d".repeat(64),406          signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),407          wsPort: 52345,408          relayUrl: MESH_RELAY,409          pairedAt: 1_700_000_000_030,410        },411      ],412      issuerKp.privateKey,413    );414415    await adapter.applyIncomingDeviceManifest?.(416      manifest,417      new Uint8Array(32).fill(0xcc),418    );419420    expect(await findPairedDeviceById("android-new")).toMatchObject({421      deviceName: "New Pixel",422      trustSource: "vouched",423      relayUrl: MESH_RELAY,424    });425    // PB-26-213: the row carries no reusable secret; it resolves from the426    // encrypted pair-secret record instead.427    expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();428    const { getPairSecret } = await import("./pair-secrets");429    await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(430      /^[0-9a-f]{64}$/,431    );432    const pending = await listPendingVouchedDevices();433    expect(pending).toEqual([]);434435    // Immutable identity binding rejects remote key rewrites; metadata and436    // routing fields from the signed manifest still update.437    const existing = await findPairedDeviceById("android-existing");438    expect(existing).toMatchObject({439      deviceName: "Existing Pixel",440      longTermPublicKeyHex: "d".repeat(64),441      trustSource: "direct",442      wsPort: 52345,443    });444    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([445      "android-existing",446      "android-new",447      "extension-issuer",448    ]);449  });450451  it("drops manifest key replacement for an existing vouched device", async () => {452    const { adapter } = await setupVault();453    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");454    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");455    const { pubkeyToHex } = await import("../shared/sync-identity");456    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");457    const issuerId = "extension-issuer";458    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);459    await upsertPairedDevice({460      deviceId: issuerId,461      deviceName: "Firefox",462      deviceType: "extension",463      longTermPublicKeyHex: "c".repeat(64),464      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),465      pairedAt: 1,466      trustSource: "direct",467    });468    await upsertPairedDevice({469      deviceId: "android-existing",470      deviceName: "Pixel",471      deviceType: "android",472      longTermPublicKeyHex: "d".repeat(64),473      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),474      pairedAt: 2,475      trustSource: "vouched",476    });477    const manifest = await buildSignedDeviceManifest(478      issuerId,479      [{480        deviceId: "android-existing",481        deviceName: "Pixel",482        deviceType: "android",483        longTermPublicKeyHex: "f".repeat(64),484        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),485        wsPort: 52345,486        pairedAt: 3,487      }],488      issuerKp.privateKey,489    );490    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));491    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({492      longTermPublicKeyHex: "d".repeat(64),493    });494  });495496  it("drops manifest replacement even when the device name changes", async () => {497    const { adapter } = await setupVault();498    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");499    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");500    const { pubkeyToHex } = await import("../shared/sync-identity");501    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");502    const issuerId = "hostile-issuer";503    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);504    await upsertPairedDevice({505      deviceId: issuerId,506      deviceName: "Old Laptop",507      deviceType: "extension",508      longTermPublicKeyHex: "c".repeat(64),509      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),510      pairedAt: 1,511      trustSource: "direct",512    });513    await upsertPairedDevice({514      deviceId: "android-existing",515      deviceName: "Spare Tablet",516      deviceType: "android",517      longTermPublicKeyHex: "d".repeat(64),518      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),519      pairedAt: 2,520      trustSource: "direct",521    });522    const manifest = await buildSignedDeviceManifest(523      issuerId,524      [{525        deviceId: "android-existing",526        deviceName: "June's Primary Phone",527        deviceType: "android",528        longTermPublicKeyHex: "f".repeat(64),529        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),530        wsPort: 52345,531        pairedAt: 3,532      }],533      issuerKp.privateKey,534    );535    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));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 signedRevocation = await signDeviceRevocation(572      {573        deviceId: "stolen-laptop",574        longTermPublicKeyHex: "d".repeat(64),575        revokedAt: Date.now(),576        revokedByDeviceId: issuerId,577      },578      issuerKp.privateKey,579    );580    const revocation = await buildSignedDeviceManifest(581      issuerId,582      [],583      issuerKp.privateKey,584      { revoked: [signedRevocation] },585    );586    await adapter.applyIncomingDeviceManifest?.(587      revocation,588      new Uint8Array(32).fill(0xcc),589    );590591    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();592    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);593594    // The stolen device is now vouched back in by a later manifest carrying a595    // fresh pairedAt — exactly the resurrection a timestamp-ordered design596    // would allow. It must not come back.597    const revouch = await buildSignedDeviceManifest(598      issuerId,599      [600        {601          deviceId: "stolen-laptop",602          deviceName: "Old Laptop",603          deviceType: "extension",604          longTermPublicKeyHex: "d".repeat(64),605          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),606          pairedAt: Date.now() + 60_000,607        },608      ],609      issuerKp.privateKey,610    );611    await adapter.applyIncomingDeviceManifest?.(612      revouch,613      new Uint8Array(32).fill(0xcc),614    );615616    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();617    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");618    expect(await listPendingVouchedDevices()).toHaveLength(0);619  });620621  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {622    const { adapter } = await setupVault();623    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");624    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");625    const { pubkeyToHex } = await import("../shared/sync-identity");626    const {627      isDeviceRevoked,628      revokeDeviceById,629      upsertPairedDevice,630    } = await import("./paired-devices");631    const { getOrCreateDeviceId } = await import("./device-identity");632633    const ourDeviceId = await getOrCreateDeviceId();634    const issuerId = "phone-issuer";635    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);636    await upsertPairedDevice({637      deviceId: issuerId,638      deviceName: "Pixel",639      deviceType: "android",640      longTermPublicKeyHex: "c".repeat(64),641      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),642      pairedAt: 1,643      trustSource: "direct",644    });645646    // A compromised peer tries to sever the mesh by revoking this device.647    const selfRevoke = await buildSignedDeviceManifest(648      issuerId,649      [],650      issuerKp.privateKey,651      {652        revoked: [653          {654            deviceId: ourDeviceId,655            longTermPublicKeyHex: "e".repeat(64),656            revokedAt: Date.now(),657            revokedByDeviceId: issuerId,658          },659        ],660      },661    );662    await adapter.applyIncomingDeviceManifest?.(663      selfRevoke,664      new Uint8Array(32).fill(0xcc),665    );666    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);667668    // Our own revocations ride out in the manifest we build, and the revoked669    // device is absent from the vouched list.670    await upsertPairedDevice({671      deviceId: "retired-tablet",672      deviceName: "Retired Tablet",673      deviceType: "android",674      longTermPublicKeyHex: "f".repeat(64),675      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),676      pairedAt: 3,677      trustSource: "direct",678    });679    await revokeDeviceById("retired-tablet", ourDeviceId);680681    const outgoing = await adapter.buildOutgoingDeviceManifest?.();682    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);683    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");684  });685686  // PB-26-116: rows written before `trustSource` existed carry no provenance.687  // The manifest-apply path used to default them to "direct", which is the688  // opposite of the fail-closed rule PB-26-006 established for the mesh routing689  // gate — a legacy row became a dial target, manifest issuer, and revocation-690  // repair participant on the strength of a signed manifest alone.691  it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {692    const { adapter } = await setupVault();693    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");694    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");695    const { pubkeyToHex } = await import("../shared/sync-identity");696    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");697    const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");698699    const issuerId = "android-legacy-issuer";700    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);701    await upsertPairedDevice({702      deviceId: issuerId,703      deviceName: "Issuer Pixel",704      deviceType: "android",705      longTermPublicKeyHex: "1".repeat(64),706      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),707      pairedAt: 1_700_000_000_000,708      trustSource: "direct",709    });710711    // A row predating the field: present in the registry, no recorded712    // provenance. Seeded through storage so no writer stamps a default.713    const legacyId = "legacy-peer";714    const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;715    registry.push({716      deviceId: legacyId,717      deviceName: "Legacy Peer",718      deviceType: "android",719      longTermPublicKeyHex: "3".repeat(64),720      signingPublicKeyHex: await signingPubkeyHex(legacyId),721      rendezvousSecretHex: "9".repeat(64),722      relayUrl: MESH_RELAY,723      pairedAt: 1_700_000_000_000,724    });725726    const manifest = await buildSignedDeviceManifest(727      issuerId,728      [{729        deviceId: legacyId,730        deviceName: "Legacy Peer",731        deviceType: "android",732        longTermPublicKeyHex: "3".repeat(64),733        signingPublicKeyHex: await signingPubkeyHex(legacyId),734        relayUrl: MESH_RELAY,735        pairedAt: 1_700_000_000_000,736      }],737      issuerKp.privateKey,738    );739    await adapter.applyIncomingDeviceManifest?.(740      manifest,741      new Uint8Array(32).fill(0x11),742    );743744    const row = await findPairedDeviceById(legacyId);745    expect(row).not.toBeNull();746    // Not silently promoted: unknown provenance stays unknown.747    expect(row?.trustSource).toBeUndefined();748    expect(row?.trustSource).not.toBe("direct");749    // And therefore not a routing authority.750    expect(isRouteCapableMeshPeer(row!)).toBe(false);751  });752753  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {754    // The browser can never listen, so it joins the mesh THROUGH a native peer;755    // that peer is the one holding the peer list. A phone-issued manifest must756    // therefore be honoured. MV-7 still applies: the introduced device lands in757    // the pending queue, not the registry.758    const { adapter } = await setupVault();759    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");760    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");761    const { pubkeyToHex } = await import("../shared/sync-identity");762    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");763    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");764    const vault = await import("./vault");765766    const mobileIssuerId = "android-issuer";767    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);768    await upsertPairedDevice({769      deviceId: mobileIssuerId,770      deviceName: "Issuer Pixel",771      deviceType: "android",772      longTermPublicKeyHex: "1".repeat(64),773      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),774      pairedAt: 1_700_000_000_000,775      trustSource: "direct",776    });777    const mobileManifest = await buildSignedDeviceManifest(778      mobileIssuerId,779      [{780        deviceId: "mesh-peer",781        deviceName: "Other Pixel",782        deviceType: "android",783        longTermPublicKeyHex: "2".repeat(64),784        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),785        relayUrl: MESH_RELAY,786        pairedAt: 1_700_000_000_010,787      }],788      mobileIssuerKp.privateKey,789    );790    await adapter.applyIncomingDeviceManifest?.(791      mobileManifest,792      new Uint8Array(32).fill(0x11),793    );794795    expect(await findPairedDeviceById("mesh-peer")).toMatchObject({796      trustSource: "vouched",797      relayUrl: MESH_RELAY,798    });799    expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();800    const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");801    await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(802      /^[0-9a-f]{64}$/,803    );804    expect(await listPendingVouchedDevices()).toEqual([]);805  });806807  it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {808    const { vault, adapter } = await setupVault();809    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");810    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");811    const { pubkeyToHex } = await import("../shared/sync-identity");812    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");813    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");814815    // Once admitted, a peer propagates the pool without relying on the device816    // that introduced it. The Noise-authenticated key and stored signing key817    // still bind the manifest to this known mesh member.818    const vouchedIssuerId = "android-vouched-issuer";819    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);820    await upsertPairedDevice({821      deviceId: vouchedIssuerId,822      deviceName: "Second-hand Pixel",823      deviceType: "android",824      longTermPublicKeyHex: "1".repeat(64),825      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),826      pairedAt: 1_700_000_000_000,827      trustSource: "vouched",828    });829    const onward = await buildSignedDeviceManifest(830      vouchedIssuerId,831      [{832        deviceId: "onward-mesh-peer",833        deviceName: "Other Pixel",834        deviceType: "android",835        longTermPublicKeyHex: "2".repeat(64),836        signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),837        relayUrl: MESH_RELAY,838        pairedAt: 1_700_000_000_010,839      }],840      vouchedKp.privateKey,841    );842    await adapter.applyIncomingDeviceManifest?.(843      onward,844      new Uint8Array(32).fill(0x11),845    );846    expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({847      trustSource: "vouched",848      relayUrl: MESH_RELAY,849    });850    expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();851    const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");852    await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(853      /^[0-9a-f]{64}$/,854    );855    expect(await listPendingVouchedDevices()).toEqual([]);856857    const extensionIssuerId = "extension-bad-sig";858    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);859    await upsertPairedDevice({860      deviceId: extensionIssuerId,861      deviceName: "Chrome",862      deviceType: "extension",863      longTermPublicKeyHex: "3".repeat(64),864      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),865      pairedAt: 1_700_000_000_020,866      trustSource: "direct",867    });868    const good = await buildSignedDeviceManifest(869      extensionIssuerId,870      [{871        deviceId: "forged-add",872        deviceName: "Forged Pixel",873        deviceType: "android",874        longTermPublicKeyHex: "4".repeat(64),875        signingPublicKeyHex: await signingPubkeyHex("forged-add"),876        pairedAt: 1_700_000_000_030,877      }],878      extensionKp.privateKey,879    );880    await adapter.applyIncomingDeviceManifest?.(881      good,882      new Uint8Array(32).fill(0x44),883    );884    expect(await findPairedDeviceById("forged-add")).toBeNull();885886    const forged = {887      ...good,888      signatureHex:889        good.signatureHex.slice(0, 2) +890        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +891        good.signatureHex.slice(4),892    };893    await adapter.applyIncomingDeviceManifest?.(894      forged,895      new Uint8Array(32).fill(0x33),896    );897    expect(await findPairedDeviceById("forged-add")).toBeNull();898  });899900  it("rejects a manifest when the stored issuer signing key is stale", async () => {901    const { vault, adapter } = await setupVault();902    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");903    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");904    const { pubkeyToHex } = await import("../shared/sync-identity");905    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");906907    const issuerId = "stale-key-android";908    const rootHex = vault.getRootKeyHex()!;909    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));910    const issuerKp = await deriveSyncSigningKeypair(root, issuerId);911912    await upsertPairedDevice({913      deviceId: issuerId,914      deviceName: "Pixel",915      deviceType: "android",916      longTermPublicKeyHex: "c".repeat(64),917      signingPublicKeyHex: "ff".repeat(32),918      pairedAt: 1_700_000_000_000,919      trustSource: "direct",920    });921922    const manifest = await buildSignedDeviceManifest(923      issuerId,924      [{925        deviceId: "onward-peer",926        deviceName: "Forwarded iPad",927        deviceType: "extension",928        longTermPublicKeyHex: "e".repeat(64),929        signingPublicKeyHex: pubkeyToHex(930          (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,931        ),932        relayUrl: MESH_RELAY,933        pairedAt: 1_700_000_000_010,934      }],935      issuerKp.privateKey,936    );937938    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));939940    expect(await findPairedDeviceById("onward-peer")).toBeNull();941  });942943  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {944    const { adapter } = await setupVault();945    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");946    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");947    const { pubkeyToHex } = await import("../shared/sync-identity");948    const { upsertPairedDevice } = await import("./paired-devices");949    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");950951    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an952    // unknown-key lookup can be what rejects this — only the binding between953    // the Noise-authenticated static key and the manifest's claimed issuer.954    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");955    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");956    await upsertPairedDevice({957      deviceId: "peer-alpha",958      deviceName: "Alpha",959      deviceType: "android",960      longTermPublicKeyHex: "a".repeat(64),961      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),962      pairedAt: 1,963      trustSource: "direct",964    });965    await upsertPairedDevice({966      deviceId: "peer-beta",967      deviceName: "Beta",968      deviceType: "android",969      longTermPublicKeyHex: "b".repeat(64),970      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),971      pairedAt: 2,972      trustSource: "direct",973    });974975    const fromBeta = await buildSignedDeviceManifest(976      "peer-beta",977      [{978        deviceId: "smuggled",979        deviceName: "Smuggled",980        deviceType: "android",981        longTermPublicKeyHex: "5".repeat(64),982        signingPublicKeyHex: await signingPubkeyHex("smuggled"),983        relayUrl: MESH_RELAY,984        pairedAt: 3,985      }],986      betaKp.privateKey,987    );988989    // Delivered over a session Noise-authenticated as ALPHA.990    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));991    expect(await listPendingVouchedDevices()).toEqual([]);992993    // Same manifest, same devices, delivered over BETA's session: it must land.994    // Without this control the assertion above could pass for any reason.995    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));996    const { findPairedDeviceById } = await import("./paired-devices");997    expect(await findPairedDeviceById("smuggled")).toMatchObject({998      trustSource: "vouched",999      relayUrl: MESH_RELAY,1000    });1001    expect(await listPendingVouchedDevices()).toEqual([]);1002  });1003  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1004    const { adapter } = await setupVault();1005    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1006    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1007    const { pubkeyToHex } = await import("../shared/sync-identity");1008    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1009    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10101011    const issuerId = "phone-issuer";1012    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1013    const stolenKey = "d".repeat(64);1014    await upsertPairedDevice({1015      deviceId: issuerId,1016      deviceName: "Pixel",1017      deviceType: "android",1018      longTermPublicKeyHex: "c".repeat(64),1019      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1020      pairedAt: 1,1021      trustSource: "direct",1022    });1023    await upsertPairedDevice({1024      deviceId: "stolen-laptop",1025      deviceName: "Old Laptop",1026      deviceType: "extension",1027      longTermPublicKeyHex: stolenKey,1028      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1029      pairedAt: 2,1030      trustSource: "direct",1031    });1032    const signedRevocation = await signDeviceRevocation({1033      deviceId: "stolen-laptop",1034      longTermPublicKeyHex: stolenKey,1035      revokedAt: Date.now(),1036      revokedByDeviceId: issuerId,1037    }, issuerKp.privateKey);1038    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1039      revoked: [signedRevocation],1040    });1041    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1042    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10431044    // deviceId is the issuer's to choose, so re-vouching the same key under a1045    // new one is free. Terminality has to follow the key, not the label.1046    const renamed = await buildSignedDeviceManifest(1047      issuerId,1048      [{1049        deviceId: "stolen-laptop-mk2",1050        deviceName: "New Laptop",1051        deviceType: "extension",1052        longTermPublicKeyHex: stolenKey,1053        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1054        relayUrl: MESH_RELAY,1055        pairedAt: Date.now(),1056      }],1057      issuerKp.privateKey,1058    );1059    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1060    expect(await listPendingVouchedDevices()).toEqual([]);1061    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10621063    // Control: the same fresh id carrying a key that was never revoked queues1064    // normally, so the rejection above is the key check and not the id.1065    const clean = await buildSignedDeviceManifest(1066      issuerId,1067      [{1068        deviceId: "stolen-laptop-mk2",1069        deviceName: "New Laptop",1070        deviceType: "extension",1071        longTermPublicKeyHex: "9".repeat(64),1072        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1073        relayUrl: MESH_RELAY,1074        pairedAt: Date.now(),1075      }],1076      issuerKp.privateKey,1077    );1078    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1079    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1080      trustSource: "vouched",1081      relayUrl: MESH_RELAY,1082    });1083    expect(await listPendingVouchedDevices()).toEqual([]);1084  });10851086  it("S1: a revoked key cannot be written onto a device we still trust", async () => {1087    const { adapter } = await setupVault();1088    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1089    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1090    const { pubkeyToHex } = await import("../shared/sync-identity");1091    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10921093    const issuerId = "phone-issuer";1094    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1095    const revokedKey = "d".repeat(64);1096    await upsertPairedDevice({1097      deviceId: issuerId,1098      deviceName: "Pixel",1099      deviceType: "android",1100      longTermPublicKeyHex: "c".repeat(64),1101      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1102      pairedAt: 1,1103      trustSource: "direct",1104    });1105    await upsertPairedDevice({1106      deviceId: "trusted-tablet",1107      deviceName: "Tablet",1108      deviceType: "android",1109      longTermPublicKeyHex: "7".repeat(64),1110      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1111      pairedAt: 2,1112      trustSource: "direct",1113    });11141115    const signedRevocation = await signDeviceRevocation({1116      deviceId: "stolen-laptop",1117      longTermPublicKeyHex: revokedKey,1118      revokedAt: Date.now(),1119      revokedByDeviceId: issuerId,1120    }, issuerKp.privateKey);1121    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1122      revoked: [signedRevocation],1123    });1124    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11251126    // The manifest rotates a device we already trust onto the revoked key —1127    // the same re-admission dressed as a key rotation.1128    const rotate = await buildSignedDeviceManifest(1129      issuerId,1130      [{1131        deviceId: "trusted-tablet",1132        deviceName: "Tablet",1133        deviceType: "android",1134        longTermPublicKeyHex: revokedKey,1135        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1136        pairedAt: 2,1137      }],1138      issuerKp.privateKey,1139    );1140    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1141    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1142      longTermPublicKeyHex: "7".repeat(64),1143    });1144  });11451146  it("methods throw VaultLockedError when the vault is locked", async () => {1147    const { vault, adapter } = await setupVault();1148    vault.lock();1149    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1150    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1151    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1152    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1153    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1154    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1155    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1156  });1157});