1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;45function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {6 const area = (store: StorageShape) => ({7 get: vi.fn(async (key?: string | string[]) => {8 if (!key) return store;9 if (typeof key === "string") return { [key]: store[key] };10 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});11 }),12 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),13 remove: vi.fn(async (keys: string | string[]) => {14 const list = Array.isArray(keys) ? keys : [keys];15 for (const k of list) delete store[k];16 }),17 });18 return {19 storage: { local: area(local), sync: area(sync) },20 alarms: {21 clear: vi.fn(),22 create: vi.fn(),23 onAlarm: { addListener: vi.fn() },24 },25 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },26 runtime: { sendMessage: vi.fn(async () => undefined) },27 } as unknown as typeof chrome;28}2930describe("SyncVaultAdapter", () => {31 let local: StorageShape;32 let sync: StorageShape;3334 beforeEach(() => {35 vi.resetModules();36 local = {};37 sync = {};38 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);39 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));40 });4142 async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./sync-coordinator").SyncVaultAdapter }> {43 const vault = await import("./vault");44 await vault.createVault("correct horse battery staple");45 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");46 const adapter = createSyncVaultAdapter();47 return { vault, adapter };48 }4950 async function signingPubkeyHex(deviceId: string): Promise<string> {51 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");52 const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);53 return pubkeyToHex(kp.publicKeyRaw);54 }5556 it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {57 const { adapter } = await setupVault();58 const id1 = await adapter.computeVaultId();59 const id2 = await adapter.computeVaultId();60 expect(id1).toBe(id2);61 expect(id1).toMatch(/^[0-9a-f]{64}$/);62 });6364 it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {65 const { vault, adapter } = await setupVault();66 const deviceId = await adapter.getOurDeviceId();67 const vaultIdBefore = await adapter.computeVaultId();6869 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");70 const rootBefore = vault.getRootKeyHex()!;71 const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));72 const signBefore = pubkeyToHex(73 (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,74 );7576 expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);77 vault.lock();78 expect(await vault.unlock("a different passphrase")).toBe(true);7980 81 expect(await adapter.computeVaultId()).toBe(vaultIdBefore);82 const rootAfter = vault.getRootKeyHex()!;83 expect(rootAfter).toBe(rootBefore);84 const signAfter = pubkeyToHex(85 (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,86 );87 expect(signAfter).toBe(signBefore);88 });8990 it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {91 const { adapter } = await setupVault();92 const id1 = await adapter.getOurDeviceId();93 const id2 = await adapter.getOurDeviceId();94 expect(id1).toBe(id2);95 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}$/);96 });9798 it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {99 const { vault, adapter } = await setupVault();100 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });101 const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });102 await vault.deleteEntry(b.id);103 const m = await adapter.buildEntryManifest();104 expect(Object.keys(m).sort()).toEqual([a.id]);105 expect(m[a.id]).toBe(a.modifiedAt);106 });107108 it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {109 const { vault, adapter } = await setupVault();110 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });111 await vault.deleteEntry(a.id);112 const t = await adapter.buildTombstoneManifest();113 expect(Object.keys(t)).toEqual([a.id]);114 expect(typeof t[a.id]).toBe("number");115 expect(t[a.id]).toBeGreaterThan(0);116 });117118 it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {119 const { vault, adapter } = await setupVault();120 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });121 const env = await adapter.getEncryptedEntries([a.id]);122 expect(Object.keys(env)).toEqual([a.id]);123 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);124 125 expect(env[a.id]).not.toContain("Alpha");126 });127128 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {129 const { adapter } = await setupVault();130 const env = await adapter.getEncryptedEntries(["does-not-exist"]);131 expect(env).toEqual({});132 });133134 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {135 const { vault, adapter } = await setupVault();136 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });137 const e1 = await adapter.getEncryptedEntries([a.id]);138 const e2 = await adapter.getEncryptedEntries([a.id]);139 expect(e1[a.id]).not.toBe(e2[a.id]);140 });141142 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {143 const { vault, adapter } = await setupVault();144 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });145 const envelopes = await adapter.getEncryptedEntries([a.id]);146 147 148 149 150 151 152 153 vault.lock();154 await vault.unlock("correct horse battery staple");155 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");156 const adapter2 = createSyncVaultAdapter();157 158 await adapter2.applyEntries(envelopes);159 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");160 });161162 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {163 const { vault, adapter } = await setupVault();164 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });165 166 167 168 await vault.updateEntry(local.id, { title: "Updated" });169 const newer = vault.getEntries().find((e) => e.id === local.id);170 expect(newer?.title).toBe("Updated");171 const envelopes = await adapter.getEncryptedEntries([local.id]);172 173 174 175 176 177 178 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };179 await vault.restoreEntries([older], "replace");180 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");181 await adapter.applyEntries(envelopes);182 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");183 });184185 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {186 const { vault, adapter } = await setupVault();187 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });188 const env = await adapter.getEncryptedEntries([a.id]);189 190 const swapped: Record<string, string> = { "wrong-id": env[a.id] };191 await adapter.applyEntries(swapped);192 193 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);194 });195196 it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {197 const { vault, adapter } = await setupVault();198 const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });199 await vault.updateEntry(a.id, { title: "Fresh" });200 const envelopes = await adapter.getEncryptedEntries([a.id]);201 202 203 const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };204 await vault.restoreEntries([stale], "replace");205 await adapter.applyEntries(envelopes);206 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");207 });208209 it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {210 211 212 213 const { vault, adapter } = await setupVault();214 const { HKDF } = await import("@stablelib/hkdf");215 const { SHA256 } = await import("@stablelib/sha256");216217 const masterHex = vault.getRootKeyHex()!;218 const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));219 const entryId = crypto.randomUUID();220 const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); 221 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);222 const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);223224 const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };225 const nonce = crypto.getRandomValues(new Uint8Array(12));226 const aad = new TextEncoder().encode("peach-sync-entry-v1");227 const ct = new Uint8Array(228 await crypto.subtle.encrypt(229 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },230 key,231 new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,232 ),233 );234 const envelope = new Uint8Array(nonce.length + ct.length);235 envelope.set(nonce, 0);236 envelope.set(ct, nonce.length);237 const legacyB64 = Buffer.from(envelope).toString("base64");238239 await adapter.applyEntries({ [entryId]: legacyB64 });240 241 expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);242 });243244 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {245 const { vault, adapter } = await setupVault();246 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });247 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });248 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();249 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();250 });251252 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {253 const { vault, adapter } = await setupVault();254 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });255 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });256 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();257 });258259 260261 it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {262 const { adapter } = await setupVault();263 const { SYNC_LIMITS } = await import("../shared/sync-limits");264 const tooMany: Record<string, string> = {};265 for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";266 await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);267 });268269 it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {270 const { vault, adapter } = await setupVault();271 const { SYNC_LIMITS } = await import("../shared/sync-limits");272 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });273 await vault.updateEntry(local.id, { title: "Updated" });274 const envelopes = await adapter.getEncryptedEntries([local.id]);275 276 const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };277 await vault.restoreEntries([older], "replace");278 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");279280 const oversized = "A".repeat(281 Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,282 );283 await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });284285 286 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");287 expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);288 });289290 it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {291 const { adapter } = await setupVault();292 const { SYNC_LIMITS } = await import("../shared/sync-limits");293 const tooMany: Record<string, number> = {};294 for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;295 await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);296 });297298 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {299 const { adapter } = await setupVault();300 const { upsertPairedDevice } = await import("./paired-devices");301 await upsertPairedDevice({302 deviceId: "android-1",303 deviceName: "Pixel",304 deviceType: "android",305 longTermPublicKeyHex: "a".repeat(64),306 signingPublicKeyHex: await signingPubkeyHex("android-1"),307 wsPort: 49152,308 pairedAt: 1_700_000_000_000,309 trustSource: "direct",310 });311 await upsertPairedDevice({312 deviceId: "legacy-android",313 deviceName: "Legacy Pixel",314 deviceType: "android",315 longTermPublicKeyHex: "b".repeat(64),316 pairedAt: 1_700_000_000_100,317 trustSource: "direct",318 });319320 const manifest = await adapter.buildOutgoingDeviceManifest?.();321 expect(manifest).toBeDefined();322 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);323 expect(manifest?.body.devices).toHaveLength(1);324 expect(manifest?.body.devices[0]).toMatchObject({325 deviceId: "android-1",326 deviceName: "Pixel",327 deviceType: "android",328 longTermPublicKeyHex: "a".repeat(64),329 wsPort: 49152,330 });331 });332333 it("MV-7: applyIncomingDeviceManifest queues unknown vouched devices for confirmation and updates existing ones in place", async () => {334 const { adapter } = await setupVault();335 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");336 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");337 const {338 findPairedDeviceById,339 listPairedDevices,340 upsertPairedDevice,341 } = await import("./paired-devices");342 const { listPendingVouchedDevices } = await import(343 "./pending-vouched-devices"344 );345346 const issuerId = "extension-issuer";347 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);348 await upsertPairedDevice({349 deviceId: issuerId,350 deviceName: "Firefox",351 deviceType: "extension",352 longTermPublicKeyHex: "c".repeat(64),353 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),354 pairedAt: 1_700_000_000_000,355 trustSource: "direct",356 });357 await upsertPairedDevice({358 deviceId: "android-existing",359 deviceName: "Existing Pixel",360 deviceType: "android",361 longTermPublicKeyHex: "d".repeat(64),362 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),363 pairedAt: 1_700_000_000_010,364 trustSource: "direct",365 });366367 const manifest = await buildSignedDeviceManifest(368 issuerId,369 [370 {371 deviceId: "android-new",372 deviceName: "New Pixel",373 deviceType: "android",374 longTermPublicKeyHex: "e".repeat(64),375 signingPublicKeyHex: await signingPubkeyHex("android-new"),376 wsPort: 51234,377 pairedAt: 1_700_000_000_020,378 },379 {380 deviceId: "android-existing",381 deviceName: "Existing Pixel renamed",382 deviceType: "android",383 longTermPublicKeyHex: "f".repeat(64),384 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),385 wsPort: 52345,386 pairedAt: 1_700_000_000_030,387 },388 ],389 issuerKp.privateKey,390 );391392 await adapter.applyIncomingDeviceManifest?.(393 manifest,394 new Uint8Array(32).fill(0xcc),395 );396397 398 399 expect(await findPairedDeviceById("android-new")).toBeNull();400 const pending = await listPendingVouchedDevices();401 expect(pending.map((d) => d.deviceId)).toEqual(["android-new"]);402 expect(pending[0]).toMatchObject({403 deviceName: "New Pixel",404 longTermPublicKeyHex: "e".repeat(64),405 vouchedByDeviceId: "extension-issuer",406 });407408 409 410 411 const existing = await findPairedDeviceById("android-existing");412 expect(existing).toMatchObject({413 deviceName: "Existing Pixel",414 longTermPublicKeyHex: "f".repeat(64),415 trustSource: "direct",416 wsPort: 52345,417 });418 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([419 "android-existing",420 "extension-issuer",421 ]);422 });423424 it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {425 const { adapter } = await setupVault();426 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");427 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");428 const { upsertPairedDevice } = await import("./paired-devices");429 const { getPendingSyncAlerts } = await import("./sync-alerts");430431 const issuerId = "extension-issuer";432 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);433 await upsertPairedDevice({434 deviceId: issuerId,435 deviceName: "Firefox",436 deviceType: "extension",437 longTermPublicKeyHex: "c".repeat(64),438 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),439 pairedAt: 1,440 trustSource: "direct",441 });442 await upsertPairedDevice({443 deviceId: "android-existing",444 deviceName: "Pixel",445 deviceType: "android",446 longTermPublicKeyHex: "d".repeat(64),447 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),448 pairedAt: 2,449 trustSource: "direct",450 });451452 const manifest = await buildSignedDeviceManifest(453 issuerId,454 [455 {456 deviceId: "android-existing",457 deviceName: "Pixel",458 deviceType: "android",459 longTermPublicKeyHex: "f".repeat(64),460 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),461 wsPort: 52345,462 pairedAt: 3,463 },464 ],465 issuerKp.privateKey,466 );467468 await adapter.applyIncomingDeviceManifest?.(469 manifest,470 new Uint8Array(32).fill(0xcc),471 );472 473 for (let i = 0; i < 25; i++) await Promise.resolve();474475 const alerts = await getPendingSyncAlerts();476 expect(477 alerts.some(478 (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",479 ),480 ).toBe(true);481 });482483 it("S5: a rotation alert names the device from local state, not from the manifest", async () => {484 485 486 487 488 const { adapter } = await setupVault();489 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");490 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");491 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");492 const { getPendingSyncAlerts } = await import("./sync-alerts");493494 const issuerId = "hostile-issuer";495 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);496 await upsertPairedDevice({497 deviceId: issuerId,498 deviceName: "Old Laptop",499 deviceType: "extension",500 longTermPublicKeyHex: "c".repeat(64),501 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),502 pairedAt: 1,503 trustSource: "direct",504 });505 await upsertPairedDevice({506 deviceId: "android-existing",507 deviceName: "Spare Tablet",508 deviceType: "android",509 longTermPublicKeyHex: "d".repeat(64),510 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),511 pairedAt: 2,512 trustSource: "direct",513 });514515 const manifest = await buildSignedDeviceManifest(516 issuerId,517 [518 {519 deviceId: "android-existing",520 deviceName: "June's Primary Phone",521 deviceType: "android",522 longTermPublicKeyHex: "f".repeat(64),523 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),524 wsPort: 52345,525 pairedAt: 3,526 },527 ],528 issuerKp.privateKey,529 );530531 await adapter.applyIncomingDeviceManifest?.(532 manifest,533 new Uint8Array(32).fill(0xcc),534 );535 for (let i = 0; i < 25; i++) await Promise.resolve();536537 const alerts = await getPendingSyncAlerts();538 const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");539 expect(rotation?.deviceName).toBe("Spare Tablet");540 expect(rotation?.entryTitle).toBe("Spare Tablet");541 expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);542543 544 const stored = await findPairedDeviceById("android-existing");545 expect(stored).toMatchObject({546 deviceName: "Spare Tablet",547 longTermPublicKeyHex: "f".repeat(64),548 });549 });550551 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {552 553 554 555 556 const { adapter } = await setupVault();557 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");558 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");559 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");560 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");561562 const mobileIssuerId = "android-issuer";563 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);564 await upsertPairedDevice({565 deviceId: mobileIssuerId,566 deviceName: "Issuer Pixel",567 deviceType: "android",568 longTermPublicKeyHex: "1".repeat(64),569 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),570 pairedAt: 1_700_000_000_000,571 trustSource: "direct",572 });573 const mobileManifest = await buildSignedDeviceManifest(574 mobileIssuerId,575 [{576 deviceId: "mesh-peer",577 deviceName: "Other Pixel",578 deviceType: "android",579 longTermPublicKeyHex: "2".repeat(64),580 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),581 pairedAt: 1_700_000_000_010,582 }],583 mobileIssuerKp.privateKey,584 );585 await adapter.applyIncomingDeviceManifest?.(586 mobileManifest,587 new Uint8Array(32).fill(0x11),588 );589590 expect(await findPairedDeviceById("mesh-peer")).toBeNull();591 expect((await listPendingVouchedDevices()).map((d) => d.deviceId)).toEqual(["mesh-peer"]);592 });593594 it("applyIncomingDeviceManifest ignores vouched issuers and bad signatures", async () => {595 const { adapter } = await setupVault();596 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");597 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");598 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");599 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");600601 602 603 const vouchedIssuerId = "android-vouched-issuer";604 const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);605 await upsertPairedDevice({606 deviceId: vouchedIssuerId,607 deviceName: "Second-hand Pixel",608 deviceType: "android",609 longTermPublicKeyHex: "1".repeat(64),610 signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),611 pairedAt: 1_700_000_000_000,612 trustSource: "vouched",613 });614 const onward = await buildSignedDeviceManifest(615 vouchedIssuerId,616 [{617 deviceId: "should-not-add",618 deviceName: "Other Pixel",619 deviceType: "android",620 longTermPublicKeyHex: "2".repeat(64),621 signingPublicKeyHex: await signingPubkeyHex("should-not-add"),622 pairedAt: 1_700_000_000_010,623 }],624 vouchedKp.privateKey,625 );626 await adapter.applyIncomingDeviceManifest?.(627 onward,628 new Uint8Array(32).fill(0x11),629 );630 expect(await findPairedDeviceById("should-not-add")).toBeNull();631 expect(await listPendingVouchedDevices()).toEqual([]);632633 const extensionIssuerId = "extension-bad-sig";634 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);635 await upsertPairedDevice({636 deviceId: extensionIssuerId,637 deviceName: "Chrome",638 deviceType: "extension",639 longTermPublicKeyHex: "3".repeat(64),640 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),641 pairedAt: 1_700_000_000_020,642 trustSource: "direct",643 });644 const good = await buildSignedDeviceManifest(645 extensionIssuerId,646 [{647 deviceId: "forged-add",648 deviceName: "Forged Pixel",649 deviceType: "android",650 longTermPublicKeyHex: "4".repeat(64),651 signingPublicKeyHex: await signingPubkeyHex("forged-add"),652 pairedAt: 1_700_000_000_030,653 }],654 extensionKp.privateKey,655 );656 await adapter.applyIncomingDeviceManifest?.(657 good,658 new Uint8Array(32).fill(0x44),659 );660 expect(await findPairedDeviceById("forged-add")).toBeNull();661662 const forged = {663 ...good,664 signatureHex:665 good.signatureHex.slice(0, 2) +666 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +667 good.signatureHex.slice(4),668 };669 await adapter.applyIncomingDeviceManifest?.(670 forged,671 new Uint8Array(32).fill(0x33),672 );673 expect(await findPairedDeviceById("forged-add")).toBeNull();674 });675676 it("methods throw VaultLockedError when the vault is locked", async () => {677 const { vault, adapter } = await setupVault();678 vault.lock();679 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });680 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });681 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });682 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });683 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });684 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });685 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });686 });687});