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 47 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 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 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 165 166 167 168 169 170 171 vault.lock();172 await vault.unlock("correct horse battery staple");173 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");174 const adapter2 = createSyncVaultAdapter();175 176 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 184 185 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 191 192 193 194 195 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 206 207 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 220 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 231 const swapped: Record<string, string> = { "wrong-id": env[a.id] };232 await adapter.applyEntries(swapped);233 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 243 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 252 253 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"); 262 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 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 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 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 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 449 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 459 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 618 619 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 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 692 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 710 711 712 713 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 735 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 770 expect(row?.trustSource).toBeUndefined();771 expect(row?.trustSource).not.toBe("direct");772 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 778 779 780 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 839 840 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 975 976 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 1013 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));1014 expect(await listPendingVouchedDevices()).toEqual([]);10151016 1017 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 1068 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 1087 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 1150 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});