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("applyEntries rejects envelopes whose decrypted id differs from the map key", async () => {204 const { vault, adapter } = await setupVault();205 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });206 const env = await adapter.getEncryptedEntries([a.id]);207 208 const swapped: Record<string, string> = { "wrong-id": env[a.id] };209 await adapter.applyEntries(swapped);210 211 expect(vault.getEntries().map((e) => e.id)).toEqual([a.id]);212 });213214 it("entry_envelope_roundtrips_with_separated_info (HKDF separator)", async () => {215 const { vault, adapter } = await setupVault();216 const a = await vault.addEntry({ type: "login", title: "Original", password: "pw" });217 await vault.updateEntry(a.id, { title: "Fresh" });218 const envelopes = await adapter.getEncryptedEntries([a.id]);219 220 221 const stale = { ...a, modifiedAt: a.modifiedAt - 1000, title: "Stale" };222 await vault.restoreEntries([stale], "replace");223 await adapter.applyEntries(envelopes);224 expect(vault.getEntries().find((e) => e.id === a.id)?.title).toBe("Fresh");225 });226227 it("legacy_info_envelope_fails_closed (HKDF separator wire break)", async () => {228 229 230 231 const { vault, adapter } = await setupVault();232 const { HKDF } = await import("@stablelib/hkdf");233 const { SHA256 } = await import("@stablelib/sha256");234235 const masterHex = vault.getRootKeyHex()!;236 const masterKey = Uint8Array.from(masterHex.match(/../g)!.map((b) => parseInt(b, 16)));237 const entryId = crypto.randomUUID();238 const legacyInfo = new TextEncoder().encode(entryId + "sync-entry-v1"); 239 const raw = new HKDF(SHA256, masterKey, new Uint8Array(0), legacyInfo).expand(32);240 const key = await crypto.subtle.importKey("raw", raw.buffer as ArrayBuffer, { name: "AES-GCM" }, false, ["encrypt"]);241242 const entry = { id: entryId, type: "login", title: "LegacyOnly", password: "pw", createdAt: Date.now(), modifiedAt: Date.now() };243 const nonce = crypto.getRandomValues(new Uint8Array(12));244 const aad = new TextEncoder().encode("peach-sync-entry-v1");245 const ct = new Uint8Array(246 await crypto.subtle.encrypt(247 { name: "AES-GCM", iv: nonce.buffer as ArrayBuffer, additionalData: aad.buffer as ArrayBuffer, tagLength: 128 },248 key,249 new TextEncoder().encode(JSON.stringify(entry)).buffer as ArrayBuffer,250 ),251 );252 const envelope = new Uint8Array(nonce.length + ct.length);253 envelope.set(nonce, 0);254 envelope.set(ct, nonce.length);255 const legacyB64 = Buffer.from(envelope).toString("base64");256257 await adapter.applyEntries({ [entryId]: legacyB64 });258 259 expect(vault.getEntries().some((e) => e.id === entryId)).toBe(false);260 });261262 it("applyTombstones soft-deletes locally-live entries with newer deletedAt", async () => {263 const { vault, adapter } = await setupVault();264 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });265 await adapter.applyTombstones({ [a.id]: a.modifiedAt + 10_000 });266 expect(vault.getEntries().find((e) => e.id === a.id)).toBeUndefined();267 expect(vault.getDeletedEntries().find((e) => e.id === a.id)).toBeDefined();268 });269270 it("applyTombstones leaves entries alone when local modifiedAt is newer", async () => {271 const { vault, adapter } = await setupVault();272 const a = await vault.addEntry({ type: "login", title: "Alpha", password: "pw" });273 await adapter.applyTombstones({ [a.id]: a.modifiedAt - 10_000 });274 expect(vault.getEntries().find((e) => e.id === a.id)).toBeDefined();275 });276277 278279 it("MV-3: applyEntries throws when the batch exceeds MAX_ENTRY_COUNT", async () => {280 const { adapter } = await setupVault();281 const { SYNC_LIMITS } = await import("../shared/sync-limits");282 const tooMany: Record<string, string> = {};283 for (let i = 0; i <= SYNC_LIMITS.MAX_ENTRY_COUNT; i++) tooMany[`id-${i}`] = "AAAA";284 await expect(adapter.applyEntries(tooMany)).rejects.toThrow(/exceeds limit/);285 });286287 it("MV-3: applyEntries skips an oversized envelope but still applies valid ones", async () => {288 const { vault, adapter } = await setupVault();289 const { SYNC_LIMITS } = await import("../shared/sync-limits");290 const local = await vault.addEntry({ type: "login", title: "Original", password: "pw" });291 await vault.updateEntry(local.id, { title: "Updated" });292 const envelopes = await adapter.getEncryptedEntries([local.id]);293 294 const older = { ...local, modifiedAt: local.modifiedAt - 1000, title: "Stale" };295 await vault.restoreEntries([older], "replace");296 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Stale");297298 const oversized = "A".repeat(299 Math.ceil((SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3) + 128,300 );301 await adapter.applyEntries({ ...envelopes, "oversized-id": oversized });302303 304 expect(vault.getEntries().find((e) => e.id === local.id)?.title).toBe("Updated");305 expect(vault.getEntries().some((e) => e.id === "oversized-id")).toBe(false);306 });307308 it("MV-3: applyTombstones throws when the batch exceeds MAX_TOMBSTONE_COUNT", async () => {309 const { adapter } = await setupVault();310 const { SYNC_LIMITS } = await import("../shared/sync-limits");311 const tooMany: Record<string, number> = {};312 for (let i = 0; i <= SYNC_LIMITS.MAX_TOMBSTONE_COUNT; i++) tooMany[`id-${i}`] = i + 1;313 await expect(adapter.applyTombstones(tooMany)).rejects.toThrow(/exceeds limit/);314 });315316 it("buildOutgoingDeviceManifest signs manifestable paired devices and skips legacy entries without signing keys", async () => {317 const { adapter } = await setupVault();318 const { upsertPairedDevice } = await import("./paired-devices");319 await upsertPairedDevice({320 deviceId: "android-1",321 deviceName: "Pixel",322 deviceType: "android",323 longTermPublicKeyHex: "a".repeat(64),324 signingPublicKeyHex: await signingPubkeyHex("android-1"),325 wsPort: 49152,326 relayUrl: MESH_RELAY,327 pairedAt: 1_700_000_000_000,328 trustSource: "direct",329 });330 await upsertPairedDevice({331 deviceId: "legacy-android",332 deviceName: "Legacy Pixel",333 deviceType: "android",334 longTermPublicKeyHex: "b".repeat(64),335 pairedAt: 1_700_000_000_100,336 trustSource: "direct",337 });338339 const manifest = await adapter.buildOutgoingDeviceManifest?.();340 expect(manifest).toBeDefined();341 expect(manifest?.signatureHex).toMatch(/^[0-9a-f]{128}$/);342 expect(manifest?.body.devices).toHaveLength(1);343 expect(manifest?.body.devices[0]).toMatchObject({344 deviceId: "android-1",345 deviceName: "Pixel",346 deviceType: "android",347 longTermPublicKeyHex: "a".repeat(64),348 wsPort: 49152,349 relayUrl: MESH_RELAY,350 });351 });352353 it("v3 auto-admits same-vault manifest peers with an independent mesh route", async () => {354 const { vault, adapter } = await setupVault();355 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");356 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");357 const { pubkeyToHex } = await import("../shared/sync-identity");358 const {359 findPairedDeviceById,360 listPairedDevices,361 upsertPairedDevice,362 } = await import("./paired-devices");363 const { listPendingVouchedDevices } = await import(364 "./pending-vouched-devices"365 );366367 const issuerId = "extension-issuer";368 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);369 await upsertPairedDevice({370 deviceId: issuerId,371 deviceName: "Firefox",372 deviceType: "extension",373 longTermPublicKeyHex: "c".repeat(64),374 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),375 pairedAt: 1_700_000_000_000,376 trustSource: "direct",377 });378 await upsertPairedDevice({379 deviceId: "android-existing",380 deviceName: "Existing Pixel",381 deviceType: "android",382 longTermPublicKeyHex: "d".repeat(64),383 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),384 pairedAt: 1_700_000_000_010,385 trustSource: "direct",386 });387388 const manifest = await buildSignedDeviceManifest(389 issuerId,390 [391 {392 deviceId: "android-new",393 deviceName: "New Pixel",394 deviceType: "android",395 longTermPublicKeyHex: "e".repeat(64),396 signingPublicKeyHex: await signingPubkeyHex("android-new"),397 wsPort: 51234,398 relayUrl: MESH_RELAY,399 pairedAt: 1_700_000_000_020,400 },401 {402 deviceId: "android-existing",403 deviceName: "Existing Pixel renamed",404 deviceType: "android",405 longTermPublicKeyHex: "d".repeat(64),406 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),407 wsPort: 52345,408 relayUrl: MESH_RELAY,409 pairedAt: 1_700_000_000_030,410 },411 ],412 issuerKp.privateKey,413 );414415 await adapter.applyIncomingDeviceManifest?.(416 manifest,417 new Uint8Array(32).fill(0xcc),418 );419420 expect(await findPairedDeviceById("android-new")).toMatchObject({421 deviceName: "New Pixel",422 trustSource: "vouched",423 relayUrl: MESH_RELAY,424 });425 426 427 expect((await findPairedDeviceById("android-new"))?.rendezvousSecretHex).toBeUndefined();428 const { getPairSecret } = await import("./pair-secrets");429 await expect(getPairSecret("android-new", vault.getRootKeyHex())).resolves.toMatch(430 /^[0-9a-f]{64}$/,431 );432 const pending = await listPendingVouchedDevices();433 expect(pending).toEqual([]);434435 436 437 const existing = await findPairedDeviceById("android-existing");438 expect(existing).toMatchObject({439 deviceName: "Existing Pixel",440 longTermPublicKeyHex: "d".repeat(64),441 trustSource: "direct",442 wsPort: 52345,443 });444 expect((await listPairedDevices()).map((d) => d.deviceId).sort()).toEqual([445 "android-existing",446 "android-new",447 "extension-issuer",448 ]);449 });450451 it("drops manifest key replacement for an existing vouched device", async () => {452 const { adapter } = await setupVault();453 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");454 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");455 const { pubkeyToHex } = await import("../shared/sync-identity");456 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");457 const issuerId = "extension-issuer";458 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x24), issuerId);459 await upsertPairedDevice({460 deviceId: issuerId,461 deviceName: "Firefox",462 deviceType: "extension",463 longTermPublicKeyHex: "c".repeat(64),464 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),465 pairedAt: 1,466 trustSource: "direct",467 });468 await upsertPairedDevice({469 deviceId: "android-existing",470 deviceName: "Pixel",471 deviceType: "android",472 longTermPublicKeyHex: "d".repeat(64),473 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),474 pairedAt: 2,475 trustSource: "vouched",476 });477 const manifest = await buildSignedDeviceManifest(478 issuerId,479 [{480 deviceId: "android-existing",481 deviceName: "Pixel",482 deviceType: "android",483 longTermPublicKeyHex: "f".repeat(64),484 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),485 wsPort: 52345,486 pairedAt: 3,487 }],488 issuerKp.privateKey,489 );490 await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));491 await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({492 longTermPublicKeyHex: "d".repeat(64),493 });494 });495496 it("drops manifest replacement even when the device name changes", async () => {497 const { adapter } = await setupVault();498 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");499 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");500 const { pubkeyToHex } = await import("../shared/sync-identity");501 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");502 const issuerId = "hostile-issuer";503 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), issuerId);504 await upsertPairedDevice({505 deviceId: issuerId,506 deviceName: "Old Laptop",507 deviceType: "extension",508 longTermPublicKeyHex: "c".repeat(64),509 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),510 pairedAt: 1,511 trustSource: "direct",512 });513 await upsertPairedDevice({514 deviceId: "android-existing",515 deviceName: "Spare Tablet",516 deviceType: "android",517 longTermPublicKeyHex: "d".repeat(64),518 signingPublicKeyHex: await signingPubkeyHex("android-existing-old"),519 pairedAt: 2,520 trustSource: "direct",521 });522 const manifest = await buildSignedDeviceManifest(523 issuerId,524 [{525 deviceId: "android-existing",526 deviceName: "June's Primary Phone",527 deviceType: "android",528 longTermPublicKeyHex: "f".repeat(64),529 signingPublicKeyHex: await signingPubkeyHex("android-existing-new"),530 wsPort: 52345,531 pairedAt: 3,532 }],533 issuerKp.privateKey,534 );535 await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));536 await expect(findPairedDeviceById("android-existing")).resolves.toMatchObject({537 deviceName: "Spare Tablet",538 longTermPublicKeyHex: "d".repeat(64),539 });540 });541542 it("§C: a vouched peer's revocation evicts the device and permanently blocks re-vouching", async () => {543 const { adapter } = await setupVault();544 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");545 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");546 const { pubkeyToHex } = await import("../shared/sync-identity");547 const { findPairedDeviceById, isDeviceRevoked, upsertPairedDevice } =548 await import("./paired-devices");549550 const issuerId = "phone-issuer";551 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x27), issuerId);552 await upsertPairedDevice({553 deviceId: issuerId,554 deviceName: "Pixel",555 deviceType: "android",556 longTermPublicKeyHex: "c".repeat(64),557 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),558 pairedAt: 1,559 trustSource: "vouched",560 });561 await upsertPairedDevice({562 deviceId: "stolen-laptop",563 deviceName: "Old Laptop",564 deviceType: "extension",565 longTermPublicKeyHex: "d".repeat(64),566 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),567 pairedAt: 2,568 trustSource: "direct",569 });570571 const signedRevocation = await signDeviceRevocation(572 {573 deviceId: "stolen-laptop",574 longTermPublicKeyHex: "d".repeat(64),575 revokedAt: Date.now(),576 revokedByDeviceId: issuerId,577 },578 issuerKp.privateKey,579 );580 const revocation = await buildSignedDeviceManifest(581 issuerId,582 [],583 issuerKp.privateKey,584 { revoked: [signedRevocation] },585 );586 await adapter.applyIncomingDeviceManifest?.(587 revocation,588 new Uint8Array(32).fill(0xcc),589 );590591 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();592 expect(await isDeviceRevoked("stolen-laptop")).toBe(true);593594 595 596 597 const revouch = await buildSignedDeviceManifest(598 issuerId,599 [600 {601 deviceId: "stolen-laptop",602 deviceName: "Old Laptop",603 deviceType: "extension",604 longTermPublicKeyHex: "d".repeat(64),605 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),606 pairedAt: Date.now() + 60_000,607 },608 ],609 issuerKp.privateKey,610 );611 await adapter.applyIncomingDeviceManifest?.(612 revouch,613 new Uint8Array(32).fill(0xcc),614 );615616 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();617 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");618 expect(await listPendingVouchedDevices()).toHaveLength(0);619 });620621 it("§C: a peer cannot revoke US, and our outgoing manifest carries our revocations", async () => {622 const { adapter } = await setupVault();623 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");624 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");625 const { pubkeyToHex } = await import("../shared/sync-identity");626 const {627 isDeviceRevoked,628 revokeDeviceById,629 upsertPairedDevice,630 } = await import("./paired-devices");631 const { getOrCreateDeviceId } = await import("./device-identity");632633 const ourDeviceId = await getOrCreateDeviceId();634 const issuerId = "phone-issuer";635 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x28), issuerId);636 await upsertPairedDevice({637 deviceId: issuerId,638 deviceName: "Pixel",639 deviceType: "android",640 longTermPublicKeyHex: "c".repeat(64),641 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),642 pairedAt: 1,643 trustSource: "direct",644 });645646 647 const selfRevoke = await buildSignedDeviceManifest(648 issuerId,649 [],650 issuerKp.privateKey,651 {652 revoked: [653 {654 deviceId: ourDeviceId,655 longTermPublicKeyHex: "e".repeat(64),656 revokedAt: Date.now(),657 revokedByDeviceId: issuerId,658 },659 ],660 },661 );662 await adapter.applyIncomingDeviceManifest?.(663 selfRevoke,664 new Uint8Array(32).fill(0xcc),665 );666 expect(await isDeviceRevoked(ourDeviceId)).toBe(false);667668 669 670 await upsertPairedDevice({671 deviceId: "retired-tablet",672 deviceName: "Retired Tablet",673 deviceType: "android",674 longTermPublicKeyHex: "f".repeat(64),675 signingPublicKeyHex: await signingPubkeyHex("retired-tablet"),676 pairedAt: 3,677 trustSource: "direct",678 });679 await revokeDeviceById("retired-tablet", ourDeviceId);680681 const outgoing = await adapter.buildOutgoingDeviceManifest?.();682 expect(outgoing?.body.revoked.map((r) => r.deviceId)).toEqual(["retired-tablet"]);683 expect(outgoing?.body.devices.map((d) => d.deviceId)).not.toContain("retired-tablet");684 });685686 687 688 689 690 691 it("a legacy row with no trustSource is not upgraded to direct by a manifest apply", async () => {692 const { adapter } = await setupVault();693 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");694 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");695 const { pubkeyToHex } = await import("../shared/sync-identity");696 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");697 const { isRouteCapableMeshPeer } = await import("../shared/mesh-peer-role");698699 const issuerId = "android-legacy-issuer";700 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), issuerId);701 await upsertPairedDevice({702 deviceId: issuerId,703 deviceName: "Issuer Pixel",704 deviceType: "android",705 longTermPublicKeyHex: "1".repeat(64),706 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),707 pairedAt: 1_700_000_000_000,708 trustSource: "direct",709 });710711 712 713 const legacyId = "legacy-peer";714 const registry = local.peach_paired_devices_v3 as Array<Record<string, unknown>>;715 registry.push({716 deviceId: legacyId,717 deviceName: "Legacy Peer",718 deviceType: "android",719 longTermPublicKeyHex: "3".repeat(64),720 signingPublicKeyHex: await signingPubkeyHex(legacyId),721 rendezvousSecretHex: "9".repeat(64),722 relayUrl: MESH_RELAY,723 pairedAt: 1_700_000_000_000,724 });725726 const manifest = await buildSignedDeviceManifest(727 issuerId,728 [{729 deviceId: legacyId,730 deviceName: "Legacy Peer",731 deviceType: "android",732 longTermPublicKeyHex: "3".repeat(64),733 signingPublicKeyHex: await signingPubkeyHex(legacyId),734 relayUrl: MESH_RELAY,735 pairedAt: 1_700_000_000_000,736 }],737 issuerKp.privateKey,738 );739 await adapter.applyIncomingDeviceManifest?.(740 manifest,741 new Uint8Array(32).fill(0x11),742 );743744 const row = await findPairedDeviceById(legacyId);745 expect(row).not.toBeNull();746 747 expect(row?.trustSource).toBeUndefined();748 expect(row?.trustSource).not.toBe("direct");749 750 expect(isRouteCapableMeshPeer(row!)).toBe(false);751 });752753 it("accepts a manifest from the phone it paired with — mesh introduction runs in that direction", async () => {754 755 756 757 758 const { adapter } = await setupVault();759 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");760 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");761 const { pubkeyToHex } = await import("../shared/sync-identity");762 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");763 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");764 const vault = await import("./vault");765766 const mobileIssuerId = "android-issuer";767 const mobileIssuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), mobileIssuerId);768 await upsertPairedDevice({769 deviceId: mobileIssuerId,770 deviceName: "Issuer Pixel",771 deviceType: "android",772 longTermPublicKeyHex: "1".repeat(64),773 signingPublicKeyHex: pubkeyToHex(mobileIssuerKp.publicKeyRaw),774 pairedAt: 1_700_000_000_000,775 trustSource: "direct",776 });777 const mobileManifest = await buildSignedDeviceManifest(778 mobileIssuerId,779 [{780 deviceId: "mesh-peer",781 deviceName: "Other Pixel",782 deviceType: "android",783 longTermPublicKeyHex: "2".repeat(64),784 signingPublicKeyHex: await signingPubkeyHex("mesh-peer"),785 relayUrl: MESH_RELAY,786 pairedAt: 1_700_000_000_010,787 }],788 mobileIssuerKp.privateKey,789 );790 await adapter.applyIncomingDeviceManifest?.(791 mobileManifest,792 new Uint8Array(32).fill(0x11),793 );794795 expect(await findPairedDeviceById("mesh-peer")).toMatchObject({796 trustSource: "vouched",797 relayUrl: MESH_RELAY,798 });799 expect((await findPairedDeviceById("mesh-peer"))?.rendezvousSecretHex).toBeUndefined();800 const { getPairSecret: getPairSecretMesh } = await import("./pair-secrets");801 await expect(getPairSecretMesh("mesh-peer", vault.getRootKeyHex())).resolves.toMatch(802 /^[0-9a-f]{64}$/,803 );804 expect(await listPendingVouchedDevices()).toEqual([]);805 });806807 it("applyIncomingDeviceManifest accepts vouched issuers but rejects unauthenticated and bad signatures", async () => {808 const { vault, adapter } = await setupVault();809 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");810 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");811 const { pubkeyToHex } = await import("../shared/sync-identity");812 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");813 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");814815 816 817 818 const vouchedIssuerId = "android-vouched-issuer";819 const vouchedKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x25), vouchedIssuerId);820 await upsertPairedDevice({821 deviceId: vouchedIssuerId,822 deviceName: "Second-hand Pixel",823 deviceType: "android",824 longTermPublicKeyHex: "1".repeat(64),825 signingPublicKeyHex: pubkeyToHex(vouchedKp.publicKeyRaw),826 pairedAt: 1_700_000_000_000,827 trustSource: "vouched",828 });829 const onward = await buildSignedDeviceManifest(830 vouchedIssuerId,831 [{832 deviceId: "onward-mesh-peer",833 deviceName: "Other Pixel",834 deviceType: "android",835 longTermPublicKeyHex: "2".repeat(64),836 signingPublicKeyHex: await signingPubkeyHex("onward-mesh-peer"),837 relayUrl: MESH_RELAY,838 pairedAt: 1_700_000_000_010,839 }],840 vouchedKp.privateKey,841 );842 await adapter.applyIncomingDeviceManifest?.(843 onward,844 new Uint8Array(32).fill(0x11),845 );846 expect(await findPairedDeviceById("onward-mesh-peer")).toMatchObject({847 trustSource: "vouched",848 relayUrl: MESH_RELAY,849 });850 expect((await findPairedDeviceById("onward-mesh-peer"))?.rendezvousSecretHex).toBeUndefined();851 const { getPairSecret: getPairSecretOnward } = await import("./pair-secrets");852 await expect(getPairSecretOnward("onward-mesh-peer", vault.getRootKeyHex())).resolves.toMatch(853 /^[0-9a-f]{64}$/,854 );855 expect(await listPendingVouchedDevices()).toEqual([]);856857 const extensionIssuerId = "extension-bad-sig";858 const extensionKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x26), extensionIssuerId);859 await upsertPairedDevice({860 deviceId: extensionIssuerId,861 deviceName: "Chrome",862 deviceType: "extension",863 longTermPublicKeyHex: "3".repeat(64),864 signingPublicKeyHex: pubkeyToHex(extensionKp.publicKeyRaw),865 pairedAt: 1_700_000_000_020,866 trustSource: "direct",867 });868 const good = await buildSignedDeviceManifest(869 extensionIssuerId,870 [{871 deviceId: "forged-add",872 deviceName: "Forged Pixel",873 deviceType: "android",874 longTermPublicKeyHex: "4".repeat(64),875 signingPublicKeyHex: await signingPubkeyHex("forged-add"),876 pairedAt: 1_700_000_000_030,877 }],878 extensionKp.privateKey,879 );880 await adapter.applyIncomingDeviceManifest?.(881 good,882 new Uint8Array(32).fill(0x44),883 );884 expect(await findPairedDeviceById("forged-add")).toBeNull();885886 const forged = {887 ...good,888 signatureHex:889 good.signatureHex.slice(0, 2) +890 ((parseInt(good.signatureHex.slice(2, 4), 16) ^ 0xff).toString(16).padStart(2, "0")) +891 good.signatureHex.slice(4),892 };893 await adapter.applyIncomingDeviceManifest?.(894 forged,895 new Uint8Array(32).fill(0x33),896 );897 expect(await findPairedDeviceById("forged-add")).toBeNull();898 });899900 it("rejects a manifest when the stored issuer signing key is stale", async () => {901 const { vault, adapter } = await setupVault();902 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");903 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");904 const { pubkeyToHex } = await import("../shared/sync-identity");905 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");906907 const issuerId = "stale-key-android";908 const rootHex = vault.getRootKeyHex()!;909 const root = Uint8Array.from(rootHex.match(/../g)!.map((b) => parseInt(b, 16)));910 const issuerKp = await deriveSyncSigningKeypair(root, issuerId);911912 await upsertPairedDevice({913 deviceId: issuerId,914 deviceName: "Pixel",915 deviceType: "android",916 longTermPublicKeyHex: "c".repeat(64),917 signingPublicKeyHex: "ff".repeat(32),918 pairedAt: 1_700_000_000_000,919 trustSource: "direct",920 });921922 const manifest = await buildSignedDeviceManifest(923 issuerId,924 [{925 deviceId: "onward-peer",926 deviceName: "Forwarded iPad",927 deviceType: "extension",928 longTermPublicKeyHex: "e".repeat(64),929 signingPublicKeyHex: pubkeyToHex(930 (await deriveSyncSigningKeypair(root, "onward-peer")).publicKeyRaw,931 ),932 relayUrl: MESH_RELAY,933 pairedAt: 1_700_000_000_010,934 }],935 issuerKp.privateKey,936 );937938 await adapter.applyIncomingDeviceManifest?.(manifest, new Uint8Array(32).fill(0xcc));939940 expect(await findPairedDeviceById("onward-peer")).toBeNull();941 });942943 it("S1: a manifest signed by one paired peer is refused over a session authenticated as another", async () => {944 const { adapter } = await setupVault();945 const { buildSignedDeviceManifest } = await import("../shared/device-manifest");946 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");947 const { pubkeyToHex } = await import("../shared/sync-identity");948 const { upsertPairedDevice } = await import("./paired-devices");949 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");950951 952 953 954 const alphaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x51), "peer-alpha");955 const betaKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x52), "peer-beta");956 await upsertPairedDevice({957 deviceId: "peer-alpha",958 deviceName: "Alpha",959 deviceType: "android",960 longTermPublicKeyHex: "a".repeat(64),961 signingPublicKeyHex: pubkeyToHex(alphaKp.publicKeyRaw),962 pairedAt: 1,963 trustSource: "direct",964 });965 await upsertPairedDevice({966 deviceId: "peer-beta",967 deviceName: "Beta",968 deviceType: "android",969 longTermPublicKeyHex: "b".repeat(64),970 signingPublicKeyHex: pubkeyToHex(betaKp.publicKeyRaw),971 pairedAt: 2,972 trustSource: "direct",973 });974975 const fromBeta = await buildSignedDeviceManifest(976 "peer-beta",977 [{978 deviceId: "smuggled",979 deviceName: "Smuggled",980 deviceType: "android",981 longTermPublicKeyHex: "5".repeat(64),982 signingPublicKeyHex: await signingPubkeyHex("smuggled"),983 relayUrl: MESH_RELAY,984 pairedAt: 3,985 }],986 betaKp.privateKey,987 );988989 990 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xaa));991 expect(await listPendingVouchedDevices()).toEqual([]);992993 994 995 await adapter.applyIncomingDeviceManifest?.(fromBeta, new Uint8Array(32).fill(0xbb));996 const { findPairedDeviceById } = await import("./paired-devices");997 expect(await findPairedDeviceById("smuggled")).toMatchObject({998 trustSource: "vouched",999 relayUrl: MESH_RELAY,1000 });1001 expect(await listPendingVouchedDevices()).toEqual([]);1002 });1003 it("S1: a revoked long-term key cannot walk back in under a fresh deviceId", async () => {1004 const { adapter } = await setupVault();1005 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1006 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1007 const { pubkeyToHex } = await import("../shared/sync-identity");1008 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");1009 const { listPendingVouchedDevices } = await import("./pending-vouched-devices");10101011 const issuerId = "phone-issuer";1012 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x53), issuerId);1013 const stolenKey = "d".repeat(64);1014 await upsertPairedDevice({1015 deviceId: issuerId,1016 deviceName: "Pixel",1017 deviceType: "android",1018 longTermPublicKeyHex: "c".repeat(64),1019 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1020 pairedAt: 1,1021 trustSource: "direct",1022 });1023 await upsertPairedDevice({1024 deviceId: "stolen-laptop",1025 deviceName: "Old Laptop",1026 deviceType: "extension",1027 longTermPublicKeyHex: stolenKey,1028 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1029 pairedAt: 2,1030 trustSource: "direct",1031 });1032 const signedRevocation = await signDeviceRevocation({1033 deviceId: "stolen-laptop",1034 longTermPublicKeyHex: stolenKey,1035 revokedAt: Date.now(),1036 revokedByDeviceId: issuerId,1037 }, issuerKp.privateKey);1038 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1039 revoked: [signedRevocation],1040 });1041 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));1042 expect(await findPairedDeviceById("stolen-laptop")).toBeNull();10431044 1045 1046 const renamed = await buildSignedDeviceManifest(1047 issuerId,1048 [{1049 deviceId: "stolen-laptop-mk2",1050 deviceName: "New Laptop",1051 deviceType: "extension",1052 longTermPublicKeyHex: stolenKey,1053 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1054 relayUrl: MESH_RELAY,1055 pairedAt: Date.now(),1056 }],1057 issuerKp.privateKey,1058 );1059 await adapter.applyIncomingDeviceManifest?.(renamed, new Uint8Array(32).fill(0xcc));1060 expect(await listPendingVouchedDevices()).toEqual([]);1061 expect(await findPairedDeviceById("stolen-laptop-mk2")).toBeNull();10621063 1064 1065 const clean = await buildSignedDeviceManifest(1066 issuerId,1067 [{1068 deviceId: "stolen-laptop-mk2",1069 deviceName: "New Laptop",1070 deviceType: "extension",1071 longTermPublicKeyHex: "9".repeat(64),1072 signingPublicKeyHex: await signingPubkeyHex("stolen-laptop"),1073 relayUrl: MESH_RELAY,1074 pairedAt: Date.now(),1075 }],1076 issuerKp.privateKey,1077 );1078 await adapter.applyIncomingDeviceManifest?.(clean, new Uint8Array(32).fill(0xcc));1079 expect(await findPairedDeviceById("stolen-laptop-mk2")).toMatchObject({1080 trustSource: "vouched",1081 relayUrl: MESH_RELAY,1082 });1083 expect(await listPendingVouchedDevices()).toEqual([]);1084 });10851086 it("S1: a revoked key cannot be written onto a device we still trust", async () => {1087 const { adapter } = await setupVault();1088 const { buildSignedDeviceManifest, signDeviceRevocation } = await import("../shared/device-manifest");1089 const { deriveSyncSigningKeypair } = await import("../shared/legacy-identity-derivation.test-support");1090 const { pubkeyToHex } = await import("../shared/sync-identity");1091 const { findPairedDeviceById, upsertPairedDevice } = await import("./paired-devices");10921093 const issuerId = "phone-issuer";1094 const issuerKp = await deriveSyncSigningKeypair(new Uint8Array(32).fill(0x54), issuerId);1095 const revokedKey = "d".repeat(64);1096 await upsertPairedDevice({1097 deviceId: issuerId,1098 deviceName: "Pixel",1099 deviceType: "android",1100 longTermPublicKeyHex: "c".repeat(64),1101 signingPublicKeyHex: pubkeyToHex(issuerKp.publicKeyRaw),1102 pairedAt: 1,1103 trustSource: "direct",1104 });1105 await upsertPairedDevice({1106 deviceId: "trusted-tablet",1107 deviceName: "Tablet",1108 deviceType: "android",1109 longTermPublicKeyHex: "7".repeat(64),1110 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1111 pairedAt: 2,1112 trustSource: "direct",1113 });11141115 const signedRevocation = await signDeviceRevocation({1116 deviceId: "stolen-laptop",1117 longTermPublicKeyHex: revokedKey,1118 revokedAt: Date.now(),1119 revokedByDeviceId: issuerId,1120 }, issuerKp.privateKey);1121 const revoke = await buildSignedDeviceManifest(issuerId, [], issuerKp.privateKey, {1122 revoked: [signedRevocation],1123 });1124 await adapter.applyIncomingDeviceManifest?.(revoke, new Uint8Array(32).fill(0xcc));11251126 1127 1128 const rotate = await buildSignedDeviceManifest(1129 issuerId,1130 [{1131 deviceId: "trusted-tablet",1132 deviceName: "Tablet",1133 deviceType: "android",1134 longTermPublicKeyHex: revokedKey,1135 signingPublicKeyHex: await signingPubkeyHex("trusted-tablet"),1136 pairedAt: 2,1137 }],1138 issuerKp.privateKey,1139 );1140 await adapter.applyIncomingDeviceManifest?.(rotate, new Uint8Array(32).fill(0xcc));1141 expect(await findPairedDeviceById("trusted-tablet")).toMatchObject({1142 longTermPublicKeyHex: "7".repeat(64),1143 });1144 });11451146 it("methods throw VaultLockedError when the vault is locked", async () => {1147 const { vault, adapter } = await setupVault();1148 vault.lock();1149 await expect(adapter.computeVaultId()).rejects.toMatchObject({ message: "vault_locked" });1150 await expect(adapter.buildEntryManifest()).rejects.toMatchObject({ message: "vault_locked" });1151 await expect(adapter.buildTombstoneManifest()).rejects.toMatchObject({ message: "vault_locked" });1152 await expect(adapter.getEncryptedEntries(["x"])).rejects.toMatchObject({ message: "vault_locked" });1153 await expect(adapter.applyEntries({ x: "AAAA" })).rejects.toMatchObject({ message: "vault_locked" });1154 await expect(adapter.applyTombstones({ x: 1 })).rejects.toMatchObject({ message: "vault_locked" });1155 await expect(adapter.buildOutgoingDeviceManifest?.()).rejects.toMatchObject({ message: "vault_locked" });1156 });1157});