1import { beforeEach, describe, expect, it, vi } from "vitest";23type StorageShape = Record<string, unknown>;4const MESH_RELAY = "wss://relay.peachpasswords.com";56function makeChromeMock(local: StorageShape, sync: StorageShape): typeof chrome {7 const area = (store: StorageShape) => ({8 get: vi.fn(async (key?: string | string[]) => {9 if (!key) return store;10 if (typeof key === "string") return { [key]: store[key] };11 return key.reduce<Record<string, unknown>>((acc, k) => { acc[k] = store[k]; return acc; }, {});12 }),13 set: vi.fn(async (next: Record<string, unknown>) => { Object.assign(store, next); }),14 remove: vi.fn(async (keys: string | string[]) => {15 const list = Array.isArray(keys) ? keys : [keys];16 for (const k of list) delete store[k];17 }),18 });19 return {20 storage: { local: area(local), sync: area(sync) },21 alarms: {22 clear: vi.fn(),23 create: vi.fn(),24 onAlarm: { addListener: vi.fn() },25 },26 idle: { setDetectionInterval: vi.fn(), onStateChanged: { addListener: vi.fn() } },27 runtime: { sendMessage: vi.fn(async () => undefined) },28 } as unknown as typeof chrome;29}3031describe("SyncVaultAdapter", () => {32 let local: StorageShape;33 let sync: StorageShape;3435 beforeEach(() => {36 vi.resetModules();37 local = {};38 sync = {};39 (globalThis as { chrome?: typeof chrome }).chrome = makeChromeMock(local, sync);40 vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("network disabled"); }));41 });4243 async function setupVault(): Promise<{ vault: typeof import("./vault"); adapter: import("./sync-coordinator").SyncVaultAdapter }> {44 const vault = await import("./vault");45 await vault.createVault("correct horse battery staple");46 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");47 const adapter = createSyncVaultAdapter();48 return { vault, adapter };49 }5051 async function signingPubkeyHex(deviceId: string): Promise<string> {52 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");53 const kp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x42), deviceId);54 return pubkeyToHex(kp.publicKeyRaw);55 }5657 it("computeVaultId returns a stable 64-char hex string for the same masterKey", async () => {58 const { adapter } = await setupVault();59 const id1 = await adapter.computeVaultId();60 const id2 = await adapter.computeVaultId();61 expect(id1).toBe(id2);62 expect(id1).toMatch(/^[0-9a-f]{64}$/);63 });6465 it("vault_id and sync signing key are unchanged after a password change (SEV-2-B)", async () => {66 const { vault, adapter } = await setupVault();67 const deviceId = await adapter.getOurDeviceId();68 const vaultIdBefore = await adapter.computeVaultId();6970 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");71 const rootBefore = vault.getRootKeyHex()!;72 const unhex = (h: string) => Uint8Array.from(h.match(/../g)!.map((b) => parseInt(b, 16)));73 const signBefore = pubkeyToHex(74 (await deriveSyncSigningKeypair(unhex(rootBefore), deviceId)).publicKeyRaw,75 );7677 expect(await vault.changePassword("correct horse battery staple", "a different passphrase")).toBe(true);78 vault.lock();79 expect(await vault.unlock("a different passphrase")).toBe(true);8081 82 expect(await adapter.computeVaultId()).toBe(vaultIdBefore);83 const rootAfter = vault.getRootKeyHex()!;84 expect(rootAfter).toBe(rootBefore);85 const signAfter = pubkeyToHex(86 (await deriveSyncSigningKeypair(unhex(rootAfter), deviceId)).publicKeyRaw,87 );88 expect(signAfter).toBe(signBefore);89 });9091 it("getOurDeviceId is stable across calls and a fresh UUID v4 on first call", async () => {92 const { adapter } = await setupVault();93 const id1 = await adapter.getOurDeviceId();94 const id2 = await adapter.getOurDeviceId();95 expect(id1).toBe(id2);96 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}$/);97 });9899 it("buildEntryManifest reports {id: modifiedAt} for live entries only", async () => {100 const { vault, adapter } = await setupVault();101 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });102 const b = await vault.addEntry({ type: "login", title: "Beta", password: "pw" });103 await vault.deleteEntry(b.id);104 const m = await adapter.buildEntryManifest();105 expect(Object.keys(m).sort()).toEqual([a.id]);106 expect(m[a.id]).toBe(a.modifiedAt);107 });108109 it("buildTombstoneManifest reports {id: deletedAt} for soft-deleted entries", async () => {110 const { vault, adapter } = await setupVault();111 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });112 await vault.deleteEntry(a.id);113 const t = await adapter.buildTombstoneManifest();114 expect(Object.keys(t)).toEqual([a.id]);115 expect(typeof t[a.id]).toBe("number");116 expect(t[a.id]).toBeGreaterThan(0);117 });118119 it("buildTombstoneManifest retains ids after a full-entry purge", async () => {120 const { vault, adapter } = await setupVault();121 const entry = await vault.addEntry({ type: "login", title: "Purged", password: "pw" });122 await vault.deleteEntry(entry.id);123 const deletedAt = vault.getDeletedEntries().find((item) => item.id === entry.id)!.deletedAt!;124125 await vault.purgeEntry(entry.id);126127 expect(await adapter.buildTombstoneManifest()).toEqual({ [entry.id]: deletedAt });128 });129130 it("getEncryptedEntries returns opaque base64 strings keyed by id", async () => {131 const { vault, adapter } = await setupVault();132 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });133 const env = await adapter.getEncryptedEntries([a.id]);134 expect(Object.keys(env)).toEqual([a.id]);135 expect(env[a.id]).toMatch(/^[A-Za-z0-9+/]+=*$/);136 137 expect(env[a.id]).not.toContain("Alpha");138 });139140 it("getEncryptedEntries silently skips unknown ids (peer may have a manifest item we dropped)", async () => {141 const { adapter } = await setupVault();142 const env = await adapter.getEncryptedEntries(["does-not-exist"]);143 expect(env).toEqual({});144 });145146 it("encryption uses a fresh nonce per call — same entry yields different envelopes", async () => {147 const { vault, adapter } = await setupVault();148 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });149 const e1 = await adapter.getEncryptedEntries([a.id]);150 const e2 = await adapter.getEncryptedEntries([a.id]);151 expect(e1[a.id]).not.toBe(e2[a.id]);152 });153154 it("round-trips: encrypt on one adapter, apply on a fresh import of the same vault", async () => {155 const { vault, adapter } = await setupVault();156 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "secret", url: "https://a.example" });157 const envelopes = await adapter.getEncryptedEntries([a.id]);158 159 160 161 162 163 164 165 vault.lock();166 await vault.unlock("correct horse battery staple");167 const { createSyncVaultAdapter } = await import("./sync-vault-adapter");168 const adapter2 = createSyncVaultAdapter();169 170 await adapter2.applyEntries(envelopes);171 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Alpha");172 });173174 it("applyEntries decrypts and merges newer remote entries (last-write-wins)", async () => {175 const { vault, adapter } = await setupVault();176 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });177 178 179 180 await vault.updateEntry(local.id, { title: "Updated" });181 const newer = vault.getEntries().find((e) => e.id === local.id);182 expect(newer?.title).toBe("Updated");183 const envelopes = await adapter.getEncryptedEntries([local.id]);184 185 186 187 188 189 190 const older: typeof local = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };191 await vault.restoreEntries([older], "replace");192 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");193 await adapter.applyEntries(envelopes);194 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");195 });196197 it("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {198 const { vault, adapter } = await setupVault();199 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });200 const env = await adapter.getEncryptedEntries([a.id]);201 202 const swapped: Record<string, string> = { "wrong-id": env[a.id] };203 await adapter.applyEntries(swapped);204 205 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);206 });207208 it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {209 const { vault, adapter } = await setupVault();210 const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });211 await vault.updateEntry(a.id, { title: "Fresh" });212 const envelopes = await adapter.getEncryptedEntries([a.id]);213 214 215 const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };216 await vault.restoreEntries([stale], "replace");217 await adapter.applyEntries(envelopes);218 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");219 });220221 it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {222 223 224 225 const { vault, adapter } = await setupVault();226 const { HKDF } = await import("@stablelib/hkdf");227 const { SHA256 } = await import("@stablelib/sha256");228229 const masterHex = vault.getRootKeyHex()!;230 const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));231 const entryId = crypto.randomUUID();232 const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); 233 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);234 const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);235236 const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };237 const nonce = crypto.getRandomValues(new Uint8Array(12));238 const aad = new TextEncoder().encode("peach-sync-entry-v1");239 const ct = new Uint8Array(240 await crypto.subtle.encrypt(241 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },242 key,243 new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,244 ),245 );246 const envelope = new Uint8Array(nonce.length + ct.length);247 envelope.set(nonce, 0);248 envelope.set(ct, nonce.length);249 const legacyB64 = Buffer.from(envelope).toString("base64");250251 await adapter.applyEntries({ [entryId]: legacyB64 });252 253 expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);254 });255256 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {257 const { vault, adapter } = await setupVault();258 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });259 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });260 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();261 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();262 });263264 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {265 const { vault, adapter } = await setupVault();266 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });267 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });268 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();269 });270271 272273 it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {274 const { adapter } = await setupVault();275 const { SYNC_LIMITS } = await import("../shared/sync-limits");276 const tooMany: Record<string, string> = {};277 for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";278 await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);279 });280281 it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {282 const { vault, adapter } = await setupVault();283 const { SYNC_LIMITS } = await import("../shared/sync-limits");284 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });285 await vault.updateEntry(local.id, { title: "Updated" });286 const envelopes = await adapter.getEncryptedEntries([local.id]);287 288 const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };289 await vault.restoreEntries([older], "replace");290 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");291292 const oversized = "A".repeat(293 Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,294 );295 await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });296297 298 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");299 expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);300 });301302 it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {303 const { adapter } = await setupVault();304 const { SYNC_LIMITS } = await import("../shared/sync-limits");305 const tooMany: Record<string, number> = {};306 for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;307 await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);308 });309310 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {311 const { adapter } = await setupVault();312 const { upsertPairedDevice } = await import("./paired-devices");313 await upsertPairedDevice({314 deviceId: "android-1",315 deviceName: "Pixel",316 deviceType: "android",317 longTermPublicKeyHex: "a".repeat(64),318 signingPublicKeyHex: await signingPubkeyHex("android-1"),319 wsPort: 49152,320 relayUrl: MESH_RELAY,321 pairedAt: 1_700_000_000_000,322 trustSource: "direct",323 });324 await upsertPairedDevice({325 deviceId: "legacy-android",326 deviceName: "Legacy Pixel",327 deviceType: "android",328 longTermPublicKeyHex: "b".repeat(64),329 pairedAt: 1_700_000_000_100,330 trustSource: "direct",331 });332333 const manifest = await adapter.buildOutgoingDeviceManifest?.();334 expect(manifest).toBeDefined();335 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);336 expect(manifest?.body.devices).toHaveLength(1);337 expect(manifest?.body.devices[0]).toMatchObject({338 deviceId: "android-1",339 deviceName: "Pixel",340 deviceType: "android",341 longTermPublicKeyHex: "a".repeat(64),342 wsPort: 49152,343 relayUrl: MESH_RELAY,344 });345 });346347 it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {348 const { vault, adapter } = await setupVault();349 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");350 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");351 const {352 findPairedDeviceById,353 listPairedDevices,354 upsertPairedDevice,355 } = await import("./paired-devices");356 const { listPendingVouchedDevices } = await import(357 "./pending-vouched-devices"358 );359360 const issuerId = "extension-issuer";361 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);362 await upsertPairedDevice({363 deviceId: issuerId,364 deviceName: "Firefox",365 deviceType: "extension",366 longTermPublicKeyHex: "c".repeat(64),367 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),368 pairedAt: 1_700_000_000_000,369 trustSource: "direct",370 });371 await upsertPairedDevice({372 deviceId: "android-existing",373 deviceName: "Existing Pixel",374 deviceType: "android",375 longTermPublicKeyHex: "d".repeat(64),376 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),377 pairedAt: 1_700_000_000_010,378 trustSource: "direct",379 });380381 const manifest = await buildSignedDeviceManifest(382 issuerId,383 [384 {385 deviceId: "android-new",386 deviceName: "New Pixel",387 deviceType: "android",388 longTermPublicKeyHex: "e".repeat(64),389 signingPublicKeyHex: await signingPubkeyHex("android-new"),390 wsPort: 51234,391 relayUrl: MESH_RELAY,392 pairedAt: 1_700_000_000_020,393 },394 {395 deviceId: "android-existing",396 deviceName: "Existing Pixel renamed",397 deviceType: "android",398 longTermPublicKeyHex: "f".repeat(64),399 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),400 wsPort: 52345,401 relayUrl: MESH_RELAY,402 pairedAt: 1_700_000_000_030,403 },404 ],405 issuerKp.privateKey,406 );407408 await adapter.applyIncomingDeviceManifest?.(409 manifest,410 new Uint8Array(32).fill(0xcc),411 );412413 expect(await findPairedDeviceById("android-new")).toMatchObject({414 deviceName: "New Pixel",415 trustSource: "vouched",416 relayUrl: MESH_RELAY,417 });418 419 420 expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();421 const { getPairSecret } = await import("./pair-secrets");422 await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(423 /^[0-9a-f]{64}$/,424 );425 const pending = await listPendingVouchedDevices();426 expect(pending).toEqual([]);427428 429 430 431 const existing = await findPairedDeviceById("android-existing");432 expect(existing).toMatchObject({433 deviceName: "Existing Pixel",434 longTermPublicKeyHex: "f".repeat(64),435 trustSource: "direct",436 wsPort: 52345,437 });438 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([439 "android-existing",440 "android-new",441 "extension-issuer",442 ]);443 });444445 it("MV-8d: records a DEVICE_KEY_ROTATED alert when an existing device's key changes in a manifest", async () => {446 const { adapter } = await setupVault();447 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");448 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");449 const { upsertPairedDevice } = await import("./paired-devices");450 const { getPendingSyncAlerts } = await import("./sync-alerts");451452 const issuerId = "extension-issuer";453 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);454 await upsertPairedDevice({455 deviceId: issuerId,456 deviceName: "Firefox",457 deviceType: "extension",458 longTermPublicKeyHex: "c".repeat(64),459 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),460 pairedAt: 1,461 trustSource: "direct",462 });463 await upsertPairedDevice({464 deviceId: "android-existing",465 deviceName: "Pixel",466 deviceType: "android",467 longTermPublicKeyHex: "d".repeat(64),468 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),469 pairedAt: 2,470 trustSource: "direct",471 });472473 const manifest = await buildSignedDeviceManifest(474 issuerId,475 [476 {477 deviceId: "android-existing",478 deviceName: "Pixel",479 deviceType: "android",480 longTermPublicKeyHex: "f".repeat(64),481 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),482 wsPort: 52345,483 pairedAt: 3,484 },485 ],486 issuerKp.privateKey,487 );488489 await adapter.applyIncomingDeviceManifest?.(490 manifest,491 new Uint8Array(32).fill(0xcc),492 );493 494 for (let i = 0; i < 25; i++) await Promise.resolve();495496 const alerts = await getPendingSyncAlerts();497 expect(498 alerts.some(499 (a) => a.type === "DEVICE_KEY_ROTATED" && a.deviceName === "Pixel",500 ),501 ).toBe(true);502 });503504 it("S5: a rotation alert names the device from local state, not from the manifest", async () => {505 506 507 508 509 const { adapter } = await setupVault();510 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");511 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");512 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");513 const { getPendingSyncAlerts } = await import("./sync-alerts");514515 const issuerId = "hostile-issuer";516 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);517 await upsertPairedDevice({518 deviceId: issuerId,519 deviceName: "Old Laptop",520 deviceType: "extension",521 longTermPublicKeyHex: "c".repeat(64),522 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),523 pairedAt: 1,524 trustSource: "direct",525 });526 await upsertPairedDevice({527 deviceId: "android-existing",528 deviceName: "Spare Tablet",529 deviceType: "android",530 longTermPublicKeyHex: "d".repeat(64),531 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),532 pairedAt: 2,533 trustSource: "direct",534 });535536 const manifest = await buildSignedDeviceManifest(537 issuerId,538 [539 {540 deviceId: "android-existing",541 deviceName: "June's Primary Phone",542 deviceType: "android",543 longTermPublicKeyHex: "f".repeat(64),544 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),545 wsPort: 52345,546 pairedAt: 3,547 },548 ],549 issuerKp.privateKey,550 );551552 await adapter.applyIncomingDeviceManifest?.(553 manifest,554 new Uint8Array(32).fill(0xcc),555 );556 for (let i = 0; i < 25; i++) await Promise.resolve();557558 const alerts = await getPendingSyncAlerts();559 const rotation = alerts.find((a) => a.type === "DEVICE_KEY_ROTATED");560 expect(rotation?.deviceName).toBe("Spare Tablet");561 expect(rotation?.entryTitle).toBe("Spare Tablet");562 expect(alerts.some((a) => a.deviceName === "June's Primary Phone")).toBe(false);563564 565 const stored = await findPairedDeviceById("android-existing");566 expect(stored).toMatchObject({567 deviceName: "Spare Tablet",568 longTermPublicKeyHex: "f".repeat(64),569 });570 });571572 it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {573 const { adapter } = await setupVault();574 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");575 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");576 const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =577 await import("./paired-devices");578579 const issuerId = "phone-issuer";580 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);581 await upsertPairedDevice({582 deviceId: issuerId,583 deviceName: "Pixel",584 deviceType: "android",585 longTermPublicKeyHex: "c".repeat(64),586 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),587 pairedAt: 1,588 trustSource: "vouched",589 });590 await upsertPairedDevice({591 deviceId: "stolen-laptop",592 deviceName: "Old Laptop",593 deviceType: "extension",594 longTermPublicKeyHex: "d".repeat(64),595 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),596 pairedAt: 2,597 trustSource: "direct",598 });599600 const revocation = await buildSignedDeviceManifest(601 issuerId,602 [],603 issuerKp.privateKey,604 {605 revoked: [606 {607 deviceId: "stolen-laptop",608 longTermPublicKeyHex: "d".repeat(64),609 revokedAt: Date.now(),610 revokedByDeviceId: issuerId,611 },612 ],613 },614 );615 await adapter.applyIncomingDeviceManifest?.(616 revocation,617 new Uint8Array(32).fill(0xcc),618 );619620 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();621 expect(await isDeviceRevoked("stolen-laptop")).toBe(true);622623 624 625 626 const revouch = await buildSignedDeviceManifest(627 issuerId,628 [629 {630 deviceId: "stolen-laptop",631 deviceName: "Old Laptop",632 deviceType: "extension",633 longTermPublicKeyHex: "d".repeat(64),634 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),635 pairedAt: Date.now() + 60_000,636 },637 ],638 issuerKp.privateKey,639 );640 await adapter.applyIncomingDeviceManifest?.(641 revouch,642 new Uint8Array(32).fill(0xcc),643 );644645 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();646 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");647 expect(await listPendingVouchedDevices()).toHaveLength(0);648 });649650 it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {651 const { adapter } = await setupVault();652 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");653 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");654 const {655 isDeviceRevoked,656 revokeDeviceById,657 upsertPairedDevice,658 } = await import("./paired-devices");659 const { getOrCreateDeviceId } = await import("./device-identity");660661 const ourDeviceId = await getOrCreateDeviceId();662 const issuerId = "phone-issuer";663 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);664 await upsertPairedDevice({665 deviceId: issuerId,666 deviceName: "Pixel",667 deviceType: "android",668 longTermPublicKeyHex: "c".repeat(64),669 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),670 pairedAt: 1,671 trustSource: "direct",672 });673674 675 const selfRevoke = await buildSignedDeviceManifest(676 issuerId,677 [],678 issuerKp.privateKey,679 {680 revoked: [681 {682 deviceId: ourDeviceId,683 longTermPublicKeyHex: "e".repeat(64),684 revokedAt: Date.now(),685 revokedByDeviceId: issuerId,686 },687 ],688 },689 );690 await adapter.applyIncomingDeviceManifest?.(691 selfRevoke,692 new Uint8Array(32).fill(0xcc),693 );694 expect(await isDeviceRevoked(ourDeviceId)).toBe(false);695696 697 698 await upsertPairedDevice({699 deviceId: "retired-tablet",700 deviceName: "Retired Tablet",701 deviceType: "android",702 longTermPublicKeyHex: "f".repeat(64),703 signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),704 pairedAt: 3,705 trustSource: "direct",706 });707 await revokeDeviceById("retired-tablet", ourDeviceId);708709 const outgoing = await adapter.buildOutgoingDeviceManifest?.();710 expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);711 expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");712 });713714 715 716 717 718 719 it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {720 const { adapter } = await setupVault();721 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");722 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");723 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");724 const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");725726 const issuerId = "android-legacy-issuer";727 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);728 await upsertPairedDevice({729 deviceId: issuerId,730 deviceName: "Issuer Pixel",731 deviceType: "android",732 longTermPublicKeyHex: "1".repeat(64),733 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),734 pairedAt: 1_700_000_000_000,735 trustSource: "direct",736 });737738 739 740 const legacyId = "legacy-peer";741 const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;742 registry.push({743 deviceId: legacyId,744 deviceName: "Legacy Peer",745 deviceType: "android",746 longTermPublicKeyHex: "3".repeat(64),747 signingPublicKeyHex: await signingPubkeyHex(legacyId),748 rendezvousSecretHex: "9".repeat(64),749 relayUrl: MESH_RELAY,750 pairedAt: 1_700_000_000_000,751 });752753 const manifest = await buildSignedDeviceManifest(754 issuerId,755 [{756 deviceId: legacyId,757 deviceName: "Legacy Peer",758 deviceType: "android",759 longTermPublicKeyHex: "3".repeat(64),760 signingPublicKeyHex: await signingPubkeyHex(legacyId),761 relayUrl: MESH_RELAY,762 pairedAt: 1_700_000_000_000,763 }],764 issuerKp.privateKey,765 );766 await adapter.applyIncomingDeviceManifest?.(767 manifest,768 new Uint8Array(32).fill(0x11),769 );770771 const row = await findPairedDeviceById(legacyId);772 expect(row).not.toBeNull();773 774 expect(row?.trustSource).toBeUndefined();775 expect(row?.trustSource).not.toBe("direct");776 777 expect(isRouteCapableMeshPeer(row!)).toBe(false);778 });779780 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {781 782 783 784 785 const { adapter } = await setupVault();786 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");787 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");788 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");789 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");790 const vault = await import("./vault");791792 const mobileIssuerId = "android-issuer";793 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);794 await upsertPairedDevice({795 deviceId: mobileIssuerId,796 deviceName: "Issuer Pixel",797 deviceType: "android",798 longTermPublicKeyHex: "1".repeat(64),799 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),800 pairedAt: 1_700_000_000_000,801 trustSource: "direct",802 });803 const mobileManifest = await buildSignedDeviceManifest(804 mobileIssuerId,805 [{806 deviceId: "mesh-peer",807 deviceName: "Other Pixel",808 deviceType: "android",809 longTermPublicKeyHex: "2".repeat(64),810 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),811 relayUrl: MESH_RELAY,812 pairedAt: 1_700_000_000_010,813 }],814 mobileIssuerKp.privateKey,815 );816 await adapter.applyIncomingDeviceManifest?.(817 mobileManifest,818 new Uint8Array(32).fill(0x11),819 );820821 expect(await findPairedDeviceById("mesh-peer")).toMatchObject({822 trustSource: "vouched",823 relayUrl: MESH_RELAY,824 });825 expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();826 const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");827 await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(828 /^[0-9a-f]{64}$/,829 );830 expect(await listPendingVouchedDevices()).toEqual([]);831 });832833 it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {834 const { vault, adapter } = await setupVault();835 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");836 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");837 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");838 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");839840 841 842 843 const vouchedIssuerId = "android-vouched-issuer";844 const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);845 await upsertPairedDevice({846 deviceId: vouchedIssuerId,847 deviceName: "Second-hand Pixel",848 deviceType: "android",849 longTermPublicKeyHex: "1".repeat(64),850 signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),851 pairedAt: 1_700_000_000_000,852 trustSource: "vouched",853 });854 const onward = await buildSignedDeviceManifest(855 vouchedIssuerId,856 [{857 deviceId: "onward-mesh-peer",858 deviceName: "Other Pixel",859 deviceType: "android",860 longTermPublicKeyHex: "2".repeat(64),861 signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),862 relayUrl: MESH_RELAY,863 pairedAt: 1_700_000_000_010,864 }],865 vouchedKp.privateKey,866 );867 await adapter.applyIncomingDeviceManifest?.(868 onward,869 new Uint8Array(32).fill(0x11),870 );871 expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({872 trustSource: "vouched",873 relayUrl: MESH_RELAY,874 });875 expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();876 const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");877 await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(878 /^[0-9a-f]{64}$/,879 );880 expect(await listPendingVouchedDevices()).toEqual([]);881882 const extensionIssuerId = "extension-bad-sig";883 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);884 await upsertPairedDevice({885 deviceId: extensionIssuerId,886 deviceName: "Chrome",887 deviceType: "extension",888 longTermPublicKeyHex: "3".repeat(64),889 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),890 pairedAt: 1_700_000_000_020,891 trustSource: "direct",892 });893 const good = await buildSignedDeviceManifest(894 extensionIssuerId,895 [{896 deviceId: "forged-add",897 deviceName: "Forged Pixel",898 deviceType: "android",899 longTermPublicKeyHex: "4".repeat(64),900 signingPublicKeyHex: await signingPubkeyHex("forged-add"),901 pairedAt: 1_700_000_000_030,902 }],903 extensionKp.privateKey,904 );905 await adapter.applyIncomingDeviceManifest?.(906 good,907 new Uint8Array(32).fill(0x44),908 );909 expect(await findPairedDeviceById("forged-add")).toBeNull();910911 const forged = {912 ...good,913 signatureHex:914 good.signatureHex.slice(0, 2) +915 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +916 good.signatureHex.slice(4),917 };918 await adapter.applyIncomingDeviceManifest?.(919 forged,920 new Uint8Array(32).fill(0x33),921 );922 expect(await findPairedDeviceById("forged-add")).toBeNull();923 });924925 it("falls back to derived signing key when stored issuer key is stale", async () => {926 const { vault, adapter } = await setupVault();927 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");928 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");929 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");930 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");931932 const issuerId = "stale-key-android";933 const rootHex = vault.getRootKeyHex()!;934 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));935 const issuerKp = await deriveSyncSigningKeypair(root, issuerId);936937 await upsertPairedDevice({938 deviceId: issuerId,939 deviceName: "Pixel",940 deviceType: "android",941 longTermPublicKeyHex: "c".repeat(64),942 943 944 signingPublicKeyHex: "ff".repeat(32),945 pairedAt: 1_700_000_000_000,946 trustSource: "direct",947 });948949 const manifest = await buildSignedDeviceManifest(950 issuerId,951 [{952 deviceId: "onward-peer",953 deviceName: "Forwarded iPad",954 deviceType: "extension",955 longTermPublicKeyHex: "e".repeat(64),956 signingPublicKeyHex: pubkeyToHex(957 (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,958 ),959 relayUrl: MESH_RELAY,960 pairedAt: 1_700_000_000_010,961 }],962 issuerKp.privateKey,963 );964965 await adapter.applyIncomingDeviceManifest?.(966 manifest,967 new Uint8Array(32).fill(0xcc),968 );969970 expect(await findPairedDeviceById("onward-peer")).toMatchObject({971 trustSource: "vouched",972 relayUrl: MESH_RELAY,973 });974 expect((await findPairedDeviceById("onward-peer"))?.rendezvousSecretHex).toBeUndefined();975 const { getPairSecret: getPairSecretStale } = await import("./pair-secrets");976 await expect(getPairSecretStale("onward-peer", vault.getRootKeyHex())).resolves.toMatch(977 /^[0-9a-f]{64}$/,978 );979 expect(await listPendingVouchedDevices()).toEqual([]);980 });981982 it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {983 const { adapter } = await setupVault();984 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");985 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");986 const { upsertPairedDevice } = await import("./paired-devices");987 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");988989 990 991 992 const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");993 const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");994 await upsertPairedDevice({995 deviceId: "peer-alpha",996 deviceName: "Alpha",997 deviceType: "android",998 longTermPublicKeyHex: "a".repeat(64),999 signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),1000 pairedAt: 1,1001 trustSource: "direct",1002 });1003 await upsertPairedDevice({1004 deviceId: "peer-beta",1005 deviceName: "Beta",1006 deviceType: "android",1007 longTermPublicKeyHex: "b".repeat(64),1008 signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),1009 pairedAt: 2,1010 trustSource: "direct",1011 });10121013 const fromBeta = await buildSignedDeviceManifest(1014 "peer-beta",1015 [{1016 deviceId: "smuggled",1017 deviceName: "Smuggled",1018 deviceType: "android",1019 longTermPublicKeyHex: "5".repeat(64),1020 signingPublicKeyHex: await signingPubkeyHex("smuggled"),1021 relayUrl: MESH_RELAY,1022 pairedAt: 3,1023 }],1024 betaKp.privateKey,1025 );10261027 1028 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));1029 expect(await listPendingVouchedDevices()).toEqual([]);10301031 1032 1033 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));1034 const { findPairedDeviceById } = await import("./paired-devices");1035 expect(await findPairedDeviceById("smuggled")).toMatchObject({1036 trustSource: "vouched",1037 relayUrl: MESH_RELAY,1038 });1039 expect(await listPendingVouchedDevices()).toEqual([]);1040 });10411042 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1043 const { adapter } = await setupVault();1044 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1045 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1046 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1047 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10481049 const issuerId = "phone-issuer";1050 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1051 const stolenKey = "d".repeat(64);1052 await upsertPairedDevice({1053 deviceId: issuerId,1054 deviceName: "Pixel",1055 deviceType: "android",1056 longTermPublicKeyHex: "c".repeat(64),1057 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1058 pairedAt: 1,1059 trustSource: "direct",1060 });1061 await upsertPairedDevice({1062 deviceId: "stolen-laptop",1063 deviceName: "Old Laptop",1064 deviceType: "extension",1065 longTermPublicKeyHex: stolenKey,1066 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1067 pairedAt: 2,1068 trustSource: "direct",1069 });10701071 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1072 revoked: [{1073 deviceId: "stolen-laptop",1074 longTermPublicKeyHex: stolenKey,1075 revokedAt: Date.now(),1076 revokedByDeviceId: issuerId,1077 }],1078 });1079 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1080 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10811082 1083 1084 const renamed = await buildSignedDeviceManifest(1085 issuerId,1086 [{1087 deviceId: "stolen-laptop-mk2",1088 deviceName: "New Laptop",1089 deviceType: "extension",1090 longTermPublicKeyHex: stolenKey,1091 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1092 relayUrl: MESH_RELAY,1093 pairedAt: Date.now(),1094 }],1095 issuerKp.privateKey,1096 );1097 await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1098 expect(await listPendingVouchedDevices()).toEqual([]);1099 expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();11001101 1102 1103 const clean = await buildSignedDeviceManifest(1104 issuerId,1105 [{1106 deviceId: "stolen-laptop-mk2",1107 deviceName: "New Laptop",1108 deviceType: "extension",1109 longTermPublicKeyHex: "9".repeat(64),1110 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1111 relayUrl: MESH_RELAY,1112 pairedAt: Date.now(),1113 }],1114 issuerKp.privateKey,1115 );1116 await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1117 expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1118 trustSource: "vouched",1119 relayUrl: MESH_RELAY,1120 });1121 expect(await listPendingVouchedDevices()).toEqual([]);1122 });11231124 it("S1: a revoked key cannot be written onto a device we still trust", async () => {1125 const { adapter } = await setupVault();1126 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");1127 const { deriveSyncSigningKeypair, pubkeyToHex } = await import("../shared/sync-identity");1128 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");11291130 const issuerId = "phone-issuer";1131 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1132 const revokedKey = "d".repeat(64);1133 await upsertPairedDevice({1134 deviceId: issuerId,1135 deviceName: "Pixel",1136 deviceType: "android",1137 longTermPublicKeyHex: "c".repeat(64),1138 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1139 pairedAt: 1,1140 trustSource: "direct",1141 });1142 await upsertPairedDevice({1143 deviceId: "trusted-tablet",1144 deviceName: "Tablet",1145 deviceType: "android",1146 longTermPublicKeyHex: "7".repeat(64),1147 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1148 pairedAt: 2,1149 trustSource: "direct",1150 });11511152 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1153 revoked: [{1154 deviceId: "stolen-laptop",1155 longTermPublicKeyHex: revokedKey,1156 revokedAt: Date.now(),1157 revokedByDeviceId: issuerId,1158 }],1159 });1160 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11611162 1163 1164 const rotate = await buildSignedDeviceManifest(1165 issuerId,1166 [{1167 deviceId: "trusted-tablet",1168 deviceName: "Tablet",1169 deviceType: "android",1170 longTermPublicKeyHex: revokedKey,1171 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1172 pairedAt: 2,1173 }],1174 issuerKp.privateKey,1175 );1176 await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1177 expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1178 longTermPublicKeyHex: "7".repeat(64),1179 });1180 });11811182 it("methods throw VaultLockedError when the vault is locked", async () => {1183 const { vault, adapter } = await setupVault();1184 vault.lock();1185 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1186 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1187 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1188 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1189 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1190 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1191 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1192 });1193});