sync-vault-adapter.test.ts

50.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    // 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("tells an open popup to reload when a round merges remote entries, and stays silent otherwise", async () => {204    const { vault, adapter } = await setupVault();205    // The user's case: a note authored on the phone. An open popup keeps the206    // list it loaded at mount and polls nothing, so a merged note would never207    // appear without this broadcast.208    const note = await vault.addEntry({ type: "note", title: "From phone", notes: "body" });209    await vault.updateEntry(note.id, { notes: "edited on the peer" });210    const envelopes = await adapter.getEncryptedEntries([note.id]);211212    const sendMessage = vi.mocked(chrome.runtime.sendMessage);213    await vault.restoreEntries([{ ...note, modifiedAt: note.modifiedAt - 1000, notes: "stale" }], "replace");214215    sendMessage.mockClear();216    await adapter.applyEntries(envelopes);217    expect(sendMessage).toHaveBeenCalledWith({ type: "VAULT_ENTRIES_CHANGED" });218219    // The 10-minute dial sees equal mtimes most of the time; a no-op round must220    // not churn the popup list.221    sendMessage.mockClear();222    await adapter.applyEntries(envelopes);223    expect(sendMessage).not.toHaveBeenCalled();224  });225226  it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {227    const { vault, adapter } = await setupVault();228    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });229    const env = await adapter.getEncryptedEntries([a.id]);230    // Re-key the envelope under a different id — adapter must refuse to merge it.231    const swapped: Record<string, string> = { "wrong-id": env[a.id] };232    await adapter.applyEntries(swapped);233    // Local entry remains unchanged; no new entry under "wrong-id"234    expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);235  });236237  it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {238    const { vault, adapter } = await setupVault();239    const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });240    await vault.updateEntry(a.id, { title: "Fresh" });241    const envelopes = await adapter.getEncryptedEntries([a.id]);242    // Roll local back to an older copy, then apply: encrypt+decrypt both use the243    // null-byte-separated info, so the round-trip must succeed.244    const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };245    await vault.restoreEntries([stale], "replace");246    await adapter.applyEntries(envelopes);247    expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");248  });249250  it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {251    // Mint an envelope under the OLD, unseparated info (`entryId + suffix`) and252    // assert the patched adapter cannot decrypt it — it drops silently. Locks in253    // the conscious wire break and serves as the fixture for any future migration.254    const { vault, adapter } = await setupVault();255    const { HKDF } = await import("@stablelib/hkdf");256    const { SHA256 } = await import("@stablelib/sha256");257258    const masterHex = vault.getRootKeyHex()!;259    const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));260    const entryId = crypto.randomUUID();261    const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); // no \x00262    const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);263    const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);264265    const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };266    const nonce = crypto.getRandomValues(new Uint8Array(12));267    const aad = new TextEncoder().encode("peach-sync-entry-v1");268    const ct = new Uint8Array(269      await crypto.subtle.encrypt(270        { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },271        key,272        new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,273      ),274    );275    const envelope = new Uint8Array(nonce.length + ct.length);276    envelope.set(nonce, 0);277    envelope.set(ct, nonce.length);278    const legacyB64 = Buffer.from(envelope).toString("base64");279280    await adapter.applyEntries({ [entryId]: legacyB64 });281    // Decrypt fails under the new separated info → envelope dropped, entry absent.282    expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);283  });284285  it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {286    const { vault, adapter } = await setupVault();287    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });288    await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });289    expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();290    expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();291  });292293  it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {294    const { vault, adapter } = await setupVault();295    const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });296    await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });297    expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();298  });299300  // --- MV-3: sync receive bounds ---301302  it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {303    const { adapter } = await setupVault();304    const { SYNC_LIMITS } = await import("../shared/sync-limits");305    const tooMany: Record<string, string> = {};306    for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";307    await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);308  });309310  it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {311    const { vault, adapter } = await setupVault();312    const { SYNC_LIMITS } = await import("../shared/sync-limits");313    const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });314    await vault.updateEntry(local.id, { title: "Updated" });315    const envelopes = await adapter.getEncryptedEntries([local.id]);316    // Make the local copy stale so the valid envelope would win on merge.317    const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };318    await vault.restoreEntries([older], "replace");319    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");320321    const oversized = "A".repeat(322      Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,323    );324    await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });325326    // The valid envelope applied; the oversized one was skipped (never decrypted).327    expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");328    expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);329  });330331  it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {332    const { adapter } = await setupVault();333    const { SYNC_LIMITS } = await import("../shared/sync-limits");334    const tooMany: Record<string, number> = {};335    for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;336    await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);337  });338339  it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {340    const { adapter } = await setupVault();341    const { upsertPairedDevice } = await import("./paired-devices");342    await upsertPairedDevice({343      deviceId: "android-1",344      deviceName: "Pixel",345      deviceType: "android",346      longTermPublicKeyHex: "a".repeat(64),347      signingPublicKeyHex: await signingPubkeyHex("android-1"),348      wsPort: 49152,349      relayUrl: MESH_RELAY,350      pairedAt: 1_700_000_000_000,351      trustSource: "direct",352    });353    await upsertPairedDevice({354      deviceId: "legacy-android",355      deviceName: "Legacy Pixel",356      deviceType: "android",357      longTermPublicKeyHex: "b".repeat(64),358      pairedAt: 1_700_000_000_100,359      trustSource: "direct",360    });361362    const manifest = await adapter.buildOutgoingDeviceManifest?.();363    expect(manifest).toBeDefined();364    expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);365    expect(manifest?.body.devices).toHaveLength(1);366    expect(manifest?.body.devices[0]).toMatchObject({367      deviceId: "android-1",368      deviceName: "Pixel",369      deviceType: "android",370      longTermPublicKeyHex: "a".repeat(64),371      wsPort: 49152,372      relayUrl: MESH_RELAY,373    });374  });375376  it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {377    const { vault, adapter } = await setupVault();378    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");379    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");380    const { pubkeyToHex } = await import("../shared/sync-identity");381    const {382      findPairedDeviceById,383      listPairedDevices,384      upsertPairedDevice,385    } = await import("./paired-devices");386    const { listPendingVouchedDevices } = await import(387      "./pending-vouched-devices"388    );389390    const issuerId = "extension-issuer";391    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);392    await upsertPairedDevice({393      deviceId: issuerId,394      deviceName: "Firefox",395      deviceType: "extension",396      longTermPublicKeyHex: "c".repeat(64),397      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),398      pairedAt: 1_700_000_000_000,399      trustSource: "direct",400    });401    await upsertPairedDevice({402      deviceId: "android-existing",403      deviceName: "Existing Pixel",404      deviceType: "android",405      longTermPublicKeyHex: "d".repeat(64),406      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),407      pairedAt: 1_700_000_000_010,408      trustSource: "direct",409    });410411    const manifest = await buildSignedDeviceManifest(412      issuerId,413      [414        {415          deviceId: "android-new",416          deviceName: "New Pixel",417          deviceType: "android",418          longTermPublicKeyHex: "e".repeat(64),419          signingPublicKeyHex: await signingPubkeyHex("android-new"),420          wsPort: 51234,421          relayUrl: MESH_RELAY,422          pairedAt: 1_700_000_000_020,423        },424        {425          deviceId: "android-existing",426          deviceName: "Existing Pixel renamed",427          deviceType: "android",428          longTermPublicKeyHex: "d".repeat(64),429          signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),430          wsPort: 52345,431          relayUrl: MESH_RELAY,432          pairedAt: 1_700_000_000_030,433        },434      ],435      issuerKp.privateKey,436    );437438    await adapter.applyIncomingDeviceManifest?.(439      manifest,440      new Uint8Array(32).fill(0xcc),441    );442443    expect(await findPairedDeviceById("android-new")).toMatchObject({444      deviceName: "New Pixel",445      trustSource: "vouched",446      relayUrl: MESH_RELAY,447    });448    // PB-26-213: the row carries no reusable secret; it resolves from the449    // encrypted pair-secret record instead.450    expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();451    const { getPairSecret } = await import("./pair-secrets");452    await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(453      /^[0-9a-f]{64}$/,454    );455    const pending = await listPendingVouchedDevices();456    expect(pending).toEqual([]);457458    // Immutable identity binding rejects remote key rewrites; metadata and459    // routing fields from the signed manifest still update.460    const existing = await findPairedDeviceById("android-existing");461    expect(existing).toMatchObject({462      deviceName: "Existing Pixel",463      longTermPublicKeyHex: "d".repeat(64),464      trustSource: "direct",465      wsPort: 52345,466    });467    expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([468      "android-existing",469      "android-new",470      "extension-issuer",471    ]);472  });473474  it("drops manifest key replacement for an existing vouched device", async () => {475    const { adapter } = await setupVault();476    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");477    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");478    const { pubkeyToHex } = await import("../shared/sync-identity");479    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");480    const issuerId = "extension-issuer";481    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);482    await upsertPairedDevice({483      deviceId: issuerId,484      deviceName: "Firefox",485      deviceType: "extension",486      longTermPublicKeyHex: "c".repeat(64),487      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),488      pairedAt: 1,489      trustSource: "direct",490    });491    await upsertPairedDevice({492      deviceId: "android-existing",493      deviceName: "Pixel",494      deviceType: "android",495      longTermPublicKeyHex: "d".repeat(64),496      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),497      pairedAt: 2,498      trustSource: "vouched",499    });500    const manifest = await buildSignedDeviceManifest(501      issuerId,502      [{503        deviceId: "android-existing",504        deviceName: "Pixel",505        deviceType: "android",506        longTermPublicKeyHex: "f".repeat(64),507        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),508        wsPort: 52345,509        pairedAt: 3,510      }],511      issuerKp.privateKey,512    );513    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));514    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({515      longTermPublicKeyHex: "d".repeat(64),516    });517  });518519  it("drops manifest replacement even when the device name changes", async () => {520    const { adapter } = await setupVault();521    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");522    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");523    const { pubkeyToHex } = await import("../shared/sync-identity");524    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");525    const issuerId = "hostile-issuer";526    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);527    await upsertPairedDevice({528      deviceId: issuerId,529      deviceName: "Old Laptop",530      deviceType: "extension",531      longTermPublicKeyHex: "c".repeat(64),532      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),533      pairedAt: 1,534      trustSource: "direct",535    });536    await upsertPairedDevice({537      deviceId: "android-existing",538      deviceName: "Spare Tablet",539      deviceType: "android",540      longTermPublicKeyHex: "d".repeat(64),541      signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),542      pairedAt: 2,543      trustSource: "direct",544    });545    const manifest = await buildSignedDeviceManifest(546      issuerId,547      [{548        deviceId: "android-existing",549        deviceName: "June's Primary Phone",550        deviceType: "android",551        longTermPublicKeyHex: "f".repeat(64),552        signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),553        wsPort: 52345,554        pairedAt: 3,555      }],556      issuerKp.privateKey,557    );558    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));559    await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({560      deviceName: "Spare Tablet",561      longTermPublicKeyHex: "d".repeat(64),562    });563  });564565  it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {566    const { adapter } = await setupVault();567    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");568    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");569    const { pubkeyToHex } = await import("../shared/sync-identity");570    const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =571      await import("./paired-devices");572573    const issuerId = "phone-issuer";574    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);575    await upsertPairedDevice({576      deviceId: issuerId,577      deviceName: "Pixel",578      deviceType: "android",579      longTermPublicKeyHex: "c".repeat(64),580      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),581      pairedAt: 1,582      trustSource: "vouched",583    });584    await upsertPairedDevice({585      deviceId: "stolen-laptop",586      deviceName: "Old Laptop",587      deviceType: "extension",588      longTermPublicKeyHex: "d".repeat(64),589      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),590      pairedAt: 2,591      trustSource: "direct",592    });593594    const signedRevocation = await signDeviceRevocation(595      {596        deviceId: "stolen-laptop",597        longTermPublicKeyHex: "d".repeat(64),598        revokedAt: Date.now(),599        revokedByDeviceId: issuerId,600      },601      issuerKp.privateKey,602    );603    const revocation = await buildSignedDeviceManifest(604      issuerId,605      [],606      issuerKp.privateKey,607      { revoked: [signedRevocation] },608    );609    await adapter.applyIncomingDeviceManifest?.(610      revocation,611      new Uint8Array(32).fill(0xcc),612    );613614    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();615    expect(await isDeviceRevoked("stolen-laptop")).toBe(true);616617    // The stolen device is now vouched back in by a later manifest carrying a618    // fresh pairedAt — exactly the resurrection a timestamp-ordered design619    // would allow. It must not come back.620    const revouch = await buildSignedDeviceManifest(621      issuerId,622      [623        {624          deviceId: "stolen-laptop",625          deviceName: "Old Laptop",626          deviceType: "extension",627          longTermPublicKeyHex: "d".repeat(64),628          signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),629          pairedAt: Date.now() + 60_000,630        },631      ],632      issuerKp.privateKey,633    );634    await adapter.applyIncomingDeviceManifest?.(635      revouch,636      new Uint8Array(32).fill(0xcc),637    );638639    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();640    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");641    expect(await listPendingVouchedDevices()).toHaveLength(0);642  });643644  it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {645    const { adapter } = await setupVault();646    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");647    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");648    const { pubkeyToHex } = await import("../shared/sync-identity");649    const {650      isDeviceRevoked,651      revokeDeviceById,652      upsertPairedDevice,653    } = await import("./paired-devices");654    const { getOrCreateDeviceId } = await import("./device-identity");655656    const ourDeviceId = await getOrCreateDeviceId();657    const issuerId = "phone-issuer";658    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);659    await upsertPairedDevice({660      deviceId: issuerId,661      deviceName: "Pixel",662      deviceType: "android",663      longTermPublicKeyHex: "c".repeat(64),664      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),665      pairedAt: 1,666      trustSource: "direct",667    });668669    // A compromised peer tries to sever the mesh by revoking this device.670    const selfRevoke = await buildSignedDeviceManifest(671      issuerId,672      [],673      issuerKp.privateKey,674      {675        revoked: [676          {677            deviceId: ourDeviceId,678            longTermPublicKeyHex: "e".repeat(64),679            revokedAt: Date.now(),680            revokedByDeviceId: issuerId,681          },682        ],683      },684    );685    await adapter.applyIncomingDeviceManifest?.(686      selfRevoke,687      new Uint8Array(32).fill(0xcc),688    );689    expect(await isDeviceRevoked(ourDeviceId)).toBe(false);690691    // Our own revocations ride out in the manifest we build, and the revoked692    // device is absent from the vouched list.693    await upsertPairedDevice({694      deviceId: "retired-tablet",695      deviceName: "Retired Tablet",696      deviceType: "android",697      longTermPublicKeyHex: "f".repeat(64),698      signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),699      pairedAt: 3,700      trustSource: "direct",701    });702    await revokeDeviceById("retired-tablet", ourDeviceId);703704    const outgoing = await adapter.buildOutgoingDeviceManifest?.();705    expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);706    expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");707  });708709  // PB-26-116: rows written before `trustSource` existed carry no provenance.710  // The manifest-apply path used to default them to "direct", which is the711  // opposite of the fail-closed rule PB-26-006 established for the mesh routing712  // gate — a legacy row became a dial target, manifest issuer, and revocation-713  // repair participant on the strength of a signed manifest alone.714  it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {715    const { adapter } = await setupVault();716    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");717    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");718    const { pubkeyToHex } = await import("../shared/sync-identity");719    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");720    const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");721722    const issuerId = "android-legacy-issuer";723    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);724    await upsertPairedDevice({725      deviceId: issuerId,726      deviceName: "Issuer Pixel",727      deviceType: "android",728      longTermPublicKeyHex: "1".repeat(64),729      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),730      pairedAt: 1_700_000_000_000,731      trustSource: "direct",732    });733734    // A row predating the field: present in the registry, no recorded735    // provenance. Seeded through storage so no writer stamps a default.736    const legacyId = "legacy-peer";737    const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;738    registry.push({739      deviceId: legacyId,740      deviceName: "Legacy Peer",741      deviceType: "android",742      longTermPublicKeyHex: "3".repeat(64),743      signingPublicKeyHex: await signingPubkeyHex(legacyId),744      rendezvousSecretHex: "9".repeat(64),745      relayUrl: MESH_RELAY,746      pairedAt: 1_700_000_000_000,747    });748749    const manifest = await buildSignedDeviceManifest(750      issuerId,751      [{752        deviceId: legacyId,753        deviceName: "Legacy Peer",754        deviceType: "android",755        longTermPublicKeyHex: "3".repeat(64),756        signingPublicKeyHex: await signingPubkeyHex(legacyId),757        relayUrl: MESH_RELAY,758        pairedAt: 1_700_000_000_000,759      }],760      issuerKp.privateKey,761    );762    await adapter.applyIncomingDeviceManifest?.(763      manifest,764      new Uint8Array(32).fill(0x11),765    );766767    const row = await findPairedDeviceById(legacyId);768    expect(row).not.toBeNull();769    // Not silently promoted: unknown provenance stays unknown.770    expect(row?.trustSource).toBeUndefined();771    expect(row?.trustSource).not.toBe("direct");772    // And therefore not a routing authority.773    expect(isRouteCapableMeshPeer(row!)).toBe(false);774  });775776  it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {777    // The browser can never listen, so it joins the mesh THROUGH a native peer;778    // that peer is the one holding the peer list. A phone-issued manifest must779    // therefore be honoured. MV-7 still applies: the introduced device lands in780    // the pending queue, not the registry.781    const { adapter } = await setupVault();782    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");783    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");784    const { pubkeyToHex } = await import("../shared/sync-identity");785    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");786    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");787    const vault = await import("./vault");788789    const mobileIssuerId = "android-issuer";790    const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);791    await upsertPairedDevice({792      deviceId: mobileIssuerId,793      deviceName: "Issuer Pixel",794      deviceType: "android",795      longTermPublicKeyHex: "1".repeat(64),796      signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),797      pairedAt: 1_700_000_000_000,798      trustSource: "direct",799    });800    const mobileManifest = await buildSignedDeviceManifest(801      mobileIssuerId,802      [{803        deviceId: "mesh-peer",804        deviceName: "Other Pixel",805        deviceType: "android",806        longTermPublicKeyHex: "2".repeat(64),807        signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),808        relayUrl: MESH_RELAY,809        pairedAt: 1_700_000_000_010,810      }],811      mobileIssuerKp.privateKey,812    );813    await adapter.applyIncomingDeviceManifest?.(814      mobileManifest,815      new Uint8Array(32).fill(0x11),816    );817818    expect(await findPairedDeviceById("mesh-peer")).toMatchObject({819      trustSource: "vouched",820      relayUrl: MESH_RELAY,821    });822    expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();823    const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");824    await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(825      /^[0-9a-f]{64}$/,826    );827    expect(await listPendingVouchedDevices()).toEqual([]);828  });829830  it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {831    const { vault, adapter } = await setupVault();832    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");833    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");834    const { pubkeyToHex } = await import("../shared/sync-identity");835    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");836    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");837838    // Once admitted, a peer propagates the pool without relying on the device839    // that introduced it. The Noise-authenticated key and stored signing key840    // still bind the manifest to this known mesh member.841    const vouchedIssuerId = "android-vouched-issuer";842    const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);843    await upsertPairedDevice({844      deviceId: vouchedIssuerId,845      deviceName: "Second-hand Pixel",846      deviceType: "android",847      longTermPublicKeyHex: "1".repeat(64),848      signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),849      pairedAt: 1_700_000_000_000,850      trustSource: "vouched",851    });852    const onward = await buildSignedDeviceManifest(853      vouchedIssuerId,854      [{855        deviceId: "onward-mesh-peer",856        deviceName: "Other Pixel",857        deviceType: "android",858        longTermPublicKeyHex: "2".repeat(64),859        signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),860        relayUrl: MESH_RELAY,861        pairedAt: 1_700_000_000_010,862      }],863      vouchedKp.privateKey,864    );865    await adapter.applyIncomingDeviceManifest?.(866      onward,867      new Uint8Array(32).fill(0x11),868    );869    expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({870      trustSource: "vouched",871      relayUrl: MESH_RELAY,872    });873    expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();874    const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");875    await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(876      /^[0-9a-f]{64}$/,877    );878    expect(await listPendingVouchedDevices()).toEqual([]);879880    const extensionIssuerId = "extension-bad-sig";881    const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);882    await upsertPairedDevice({883      deviceId: extensionIssuerId,884      deviceName: "Chrome",885      deviceType: "extension",886      longTermPublicKeyHex: "3".repeat(64),887      signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),888      pairedAt: 1_700_000_000_020,889      trustSource: "direct",890    });891    const good = await buildSignedDeviceManifest(892      extensionIssuerId,893      [{894        deviceId: "forged-add",895        deviceName: "Forged Pixel",896        deviceType: "android",897        longTermPublicKeyHex: "4".repeat(64),898        signingPublicKeyHex: await signingPubkeyHex("forged-add"),899        pairedAt: 1_700_000_000_030,900      }],901      extensionKp.privateKey,902    );903    await adapter.applyIncomingDeviceManifest?.(904      good,905      new Uint8Array(32).fill(0x44),906    );907    expect(await findPairedDeviceById("forged-add")).toBeNull();908909    const forged = {910      ...good,911      signatureHex:912        good.signatureHex.slice(0, 2) +913        ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +914        good.signatureHex.slice(4),915    };916    await adapter.applyIncomingDeviceManifest?.(917      forged,918      new Uint8Array(32).fill(0x33),919    );920    expect(await findPairedDeviceById("forged-add")).toBeNull();921  });922923  it("rejects a manifest when the stored issuer signing key is stale", async () => {924    const { vault, adapter } = await setupVault();925    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");926    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");927    const { pubkeyToHex } = await import("../shared/sync-identity");928    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");929930    const issuerId = "stale-key-android";931    const rootHex = vault.getRootKeyHex()!;932    const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));933    const issuerKp = await deriveSyncSigningKeypair(root, issuerId);934935    await upsertPairedDevice({936      deviceId: issuerId,937      deviceName: "Pixel",938      deviceType: "android",939      longTermPublicKeyHex: "c".repeat(64),940      signingPublicKeyHex: "ff".repeat(32),941      pairedAt: 1_700_000_000_000,942      trustSource: "direct",943    });944945    const manifest = await buildSignedDeviceManifest(946      issuerId,947      [{948        deviceId: "onward-peer",949        deviceName: "Forwarded iPad",950        deviceType: "extension",951        longTermPublicKeyHex: "e".repeat(64),952        signingPublicKeyHex: pubkeyToHex(953          (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,954        ),955        relayUrl: MESH_RELAY,956        pairedAt: 1_700_000_000_010,957      }],958      issuerKp.privateKey,959    );960961    await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));962963    expect(await findPairedDeviceById("onward-peer")).toBeNull();964  });965966  it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {967    const { adapter } = await setupVault();968    const { buildSignedDeviceManifest } = await import("../shared/device-manifest");969    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");970    const { pubkeyToHex } = await import("../shared/sync-identity");971    const { upsertPairedDevice } = await import("./paired-devices");972    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");973974    // Both peers are DIRECTLY paired, so neither the trustSource gate nor an975    // unknown-key lookup can be what rejects this — only the binding between976    // the Noise-authenticated static key and the manifest's claimed issuer.977    const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");978    const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");979    await upsertPairedDevice({980      deviceId: "peer-alpha",981      deviceName: "Alpha",982      deviceType: "android",983      longTermPublicKeyHex: "a".repeat(64),984      signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),985      pairedAt: 1,986      trustSource: "direct",987    });988    await upsertPairedDevice({989      deviceId: "peer-beta",990      deviceName: "Beta",991      deviceType: "android",992      longTermPublicKeyHex: "b".repeat(64),993      signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),994      pairedAt: 2,995      trustSource: "direct",996    });997998    const fromBeta = await buildSignedDeviceManifest(999      "peer-beta",1000      [{1001        deviceId: "smuggled",1002        deviceName: "Smuggled",1003        deviceType: "android",1004        longTermPublicKeyHex: "5".repeat(64),1005        signingPublicKeyHex: await signingPubkeyHex("smuggled"),1006        relayUrl: MESH_RELAY,1007        pairedAt: 3,1008      }],1009      betaKp.privateKey,1010    );10111012    // Delivered over a session Noise-authenticated as ALPHA.1013    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));1014    expect(await listPendingVouchedDevices()).toEqual([]);10151016    // Same manifest, same devices, delivered over BETA's session: it must land.1017    // Without this control the assertion above could pass for any reason.1018    await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));1019    const { findPairedDeviceById } = await import("./paired-devices");1020    expect(await findPairedDeviceById("smuggled")).toMatchObject({1021      trustSource: "vouched",1022      relayUrl: MESH_RELAY,1023    });1024    expect(await listPendingVouchedDevices()).toEqual([]);1025  });1026  it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1027    const { adapter } = await setupVault();1028    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1029    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1030    const { pubkeyToHex } = await import("../shared/sync-identity");1031    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1032    const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10331034    const issuerId = "phone-issuer";1035    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1036    const stolenKey = "d".repeat(64);1037    await upsertPairedDevice({1038      deviceId: issuerId,1039      deviceName: "Pixel",1040      deviceType: "android",1041      longTermPublicKeyHex: "c".repeat(64),1042      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1043      pairedAt: 1,1044      trustSource: "direct",1045    });1046    await upsertPairedDevice({1047      deviceId: "stolen-laptop",1048      deviceName: "Old Laptop",1049      deviceType: "extension",1050      longTermPublicKeyHex: stolenKey,1051      signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1052      pairedAt: 2,1053      trustSource: "direct",1054    });1055    const signedRevocation = await signDeviceRevocation({1056      deviceId: "stolen-laptop",1057      longTermPublicKeyHex: stolenKey,1058      revokedAt: Date.now(),1059      revokedByDeviceId: issuerId,1060    }, issuerKp.privateKey);1061    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1062      revoked: [signedRevocation],1063    });1064    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1065    expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10661067    // deviceId is the issuer's to choose, so re-vouching the same key under a1068    // new one is free. Terminality has to follow the key, not the label.1069    const renamed = await buildSignedDeviceManifest(1070      issuerId,1071      [{1072        deviceId: "stolen-laptop-mk2",1073        deviceName: "New Laptop",1074        deviceType: "extension",1075        longTermPublicKeyHex: stolenKey,1076        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1077        relayUrl: MESH_RELAY,1078        pairedAt: Date.now(),1079      }],1080      issuerKp.privateKey,1081    );1082    await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1083    expect(await listPendingVouchedDevices()).toEqual([]);1084    expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10851086    // Control: the same fresh id carrying a key that was never revoked queues1087    // normally, so the rejection above is the key check and not the id.1088    const clean = await buildSignedDeviceManifest(1089      issuerId,1090      [{1091        deviceId: "stolen-laptop-mk2",1092        deviceName: "New Laptop",1093        deviceType: "extension",1094        longTermPublicKeyHex: "9".repeat(64),1095        signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1096        relayUrl: MESH_RELAY,1097        pairedAt: Date.now(),1098      }],1099      issuerKp.privateKey,1100    );1101    await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1102    expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1103      trustSource: "vouched",1104      relayUrl: MESH_RELAY,1105    });1106    expect(await listPendingVouchedDevices()).toEqual([]);1107  });11081109  it("S1: a revoked key cannot be written onto a device we still trust", async () => {1110    const { adapter } = await setupVault();1111    const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1112    const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1113    const { pubkeyToHex } = await import("../shared/sync-identity");1114    const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");11151116    const issuerId = "phone-issuer";1117    const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1118    const revokedKey = "d".repeat(64);1119    await upsertPairedDevice({1120      deviceId: issuerId,1121      deviceName: "Pixel",1122      deviceType: "android",1123      longTermPublicKeyHex: "c".repeat(64),1124      signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1125      pairedAt: 1,1126      trustSource: "direct",1127    });1128    await upsertPairedDevice({1129      deviceId: "trusted-tablet",1130      deviceName: "Tablet",1131      deviceType: "android",1132      longTermPublicKeyHex: "7".repeat(64),1133      signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1134      pairedAt: 2,1135      trustSource: "direct",1136    });11371138    const signedRevocation = await signDeviceRevocation({1139      deviceId: "stolen-laptop",1140      longTermPublicKeyHex: revokedKey,1141      revokedAt: Date.now(),1142      revokedByDeviceId: issuerId,1143    }, issuerKp.privateKey);1144    const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1145      revoked: [signedRevocation],1146    });1147    await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11481149    // The manifest rotates a device we already trust onto the revoked key —1150    // the same re-admission dressed as a key rotation.1151    const rotate = await buildSignedDeviceManifest(1152      issuerId,1153      [{1154        deviceId: "trusted-tablet",1155        deviceName: "Tablet",1156        deviceType: "android",1157        longTermPublicKeyHex: revokedKey,1158        signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1159        pairedAt: 2,1160      }],1161      issuerKp.privateKey,1162    );1163    await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1164    expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1165      longTermPublicKeyHex: "7".repeat(64),1166    });1167  });11681169  it("methods throw VaultLockedError when the vault is locked", async () => {1170    const { vault, adapter } = await setupVault();1171    vault.lock();1172    await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1173    await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1174    await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1175    await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1176    await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1177    await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1178    await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1179  });1180});